From a0172a4de91c42612d9e3109ef8c53212bd3efd2 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Thu, 5 Nov 2026 12:04:13 +0100 Subject: [PATCH 1/6] feat!: follow the Gotenberg 9 API and require PHP 8.3 --- .gitattributes | 2 +- .github/workflows/continuous_integration.yml | 4 +- Makefile | 4 +- composer.json | 24 +- phpstan.neon | 33 +- phpunit.xml | 2 +- src/ApiModule.php | 35 +- src/DownloadFrom.php | 25 +- src/EmbedMetadata.php | 42 - src/Exceptions/GotenbergApiErrored.php | 6 - src/FacturX.php | 28 - src/Gotenberg.php | 67 +- src/HrtimeIndex.php | 10 +- src/Modules/Chromium.php | 4 +- src/Modules/ChromiumCookie.php | 16 +- src/Modules/ChromiumEmulatedMediaFeatures.php | 25 - .../ChromiumMultipartFormDataModule.php | 219 +- src/Modules/ChromiumPdf.php | 315 +-- src/Modules/ChromiumScreenshot.php | 78 +- src/Modules/LibreOffice.php | 558 ++---- src/Modules/Pdf.php | 137 ++ src/Modules/PdfEngines.php | 549 ----- src/MultipartFormDataModule.php | 214 +- src/SplitMode.php | 33 - src/Stage.php | 473 +++++ src/StagesModule.php | 78 + src/Stream.php | 33 +- tests/ApiModuleTest.php | 14 +- tests/EmbedMetadataTest.php | 64 - tests/FacturXTest.php | 42 - tests/GotenbergTest.php | 29 +- tests/Helpers/Dummies/DummyIndex.php | 4 +- tests/Modules/ChromiumPdfTest.php | 1765 ++--------------- tests/Modules/ChromiumScreenshotTest.php | 943 +-------- tests/Modules/LibreOfficeTest.php | 952 ++------- tests/Modules/PdfEnginesTest.php | 1146 ----------- tests/Modules/PdfTest.php | 132 ++ tests/MultipartFormDataModuleTest.php | 137 +- tests/SplitModeTest.php | 31 - tests/StageTest.php | 159 ++ tests/StreamTest.php | 27 +- tests/TestCase.php | 28 +- 42 files changed, 1804 insertions(+), 6683 deletions(-) delete mode 100644 src/EmbedMetadata.php delete mode 100644 src/FacturX.php delete mode 100644 src/Modules/ChromiumEmulatedMediaFeatures.php create mode 100644 src/Modules/Pdf.php delete mode 100644 src/Modules/PdfEngines.php delete mode 100644 src/SplitMode.php create mode 100644 src/Stage.php create mode 100644 src/StagesModule.php delete mode 100644 tests/EmbedMetadataTest.php delete mode 100644 tests/FacturXTest.php delete mode 100644 tests/Modules/PdfEnginesTest.php create mode 100644 tests/Modules/PdfTest.php delete mode 100644 tests/SplitModeTest.php create mode 100644 tests/StageTest.php diff --git a/.gitattributes b/.gitattributes index 3ab3fc0..d2586b5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,5 +4,5 @@ /.gitignore export-ignore /Makefile export-ignore /phpcs.xml.dist export-ignore -/phpstan.src.neon export-ignore +/phpstan.neon export-ignore /phpunit.xml export-ignore \ No newline at end of file diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 18ca1c4..77f1894 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -19,7 +19,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.3 tools: composer:v2 coverage: none - name: Install PHP dependencies @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ '8.1', '8.2', '8.3', '8.4', '8.5' ] + php: [ '8.3', '8.4', '8.5', '8.6' ] name: Tests on PHP ${{ matrix.php }} steps: - name: Checkout source code diff --git a/Makefile b/Makefile index f10f000..d72dd66 100644 --- a/Makefile +++ b/Makefile @@ -3,9 +3,9 @@ help: ## Show the help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' .PHONY: bash -bash: ## Start a bash process in a PHP 8.1 Docker container +bash: ## Start a bash process in a PHP 8.3 Docker container docker run --rm -it \ -e PHP_EXTENSION_XDEBUG=1 \ -v $(PWD):/usr/src/app/ \ - thecodingmachine/php:8.1-v4-cli \ + thecodingmachine/php:8.3-v4-cli \ bash diff --git a/composer.json b/composer.json index a2c76fc..6047d87 100644 --- a/composer.json +++ b/composer.json @@ -14,14 +14,12 @@ "xlsx", "docx", "pptx", - "pdftk", "libreoffice", - "unoconv", - "wkhtmltopdf", - "unoconv", "chrome", "chromium", - "puppeteer" + "pdf/a", + "pdf/ua", + "factur-x" ], "license": "MIT", "authors": [ @@ -33,18 +31,17 @@ } ], "require": { - "php": "^8.1|^8.2|^8.3|^8.4|^8.5", + "php": "^8.3", "ext-json": "*", - "ext-mbstring": "*", - "guzzlehttp/psr7": "^1 || ^2.1 || ^3", - "php-http/discovery": "^1.14", + "guzzlehttp/psr7": "^2.7 || ^3.0", + "php-http/discovery": "^1.20", "psr/http-client": "^1.0", - "psr/http-message": "^1.0|^2.0" + "psr/http-message": "^2.0" }, "require-dev": { - "phpunit/phpunit": "^10.5", "doctrine/coding-standard": "^14.0", - "phpstan/phpstan": "^1.12", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.4", "squizlabs/php_codesniffer": "^4.0" }, "autoload": { @@ -60,7 +57,7 @@ "scripts": { "lint": [ "phpcs", - "phpstan analyse -l max src tests" + "phpstan analyse --no-progress" ], "lint:fix": "phpcbf", "tests": "XDEBUG_MODE=coverage phpunit --coverage-html coverage_html --coverage-clover coverage.xml", @@ -74,7 +71,6 @@ "sort-packages": true, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, - "pestphp/pest-plugin": true, "php-http/discovery": true } } diff --git a/phpstan.neon b/phpstan.neon index 75c8dbe..a4c8812 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,30 +1,5 @@ parameters: - ignoreErrors: - - - message: '#Method Gotenberg\\Test\\Modules\\ChromiumPdfTest::provideUrlData\(\) should return array#' - path: tests/* - - - message: '#Method Gotenberg\\Test\\Modules\\ChromiumPdfTest::provideHtmlData\(\) should return array#' - path: tests/* - - - message: '#Method Gotenberg\\Test\\Modules\\ChromiumPdfTest::provideMarkdownData\(\) should return array#' - path: tests/* - - - message: '#Method Gotenberg\\Test\\Modules\\ChromiumScreenshotTest::provideUrlData\(\) should return array#' - path: tests/* - - - message: '#Method Gotenberg\\Test\\Modules\\ChromiumScreenshotTest::provideHtmlData\(\) should return array#' - path: tests/* - - - message: '#Method Gotenberg\\Test\\Modules\\ChromiumScreenshotTest::provideMarkdownData\(\) should return array#' - path: tests/* - - - - message: '#Method Gotenberg\\Test\\Modules\\PdfEnginesTest::provideMergeData\(\) should return array#' - path: tests/* - - - message: '#Method Gotenberg\\Test\\Modules\\PdfEnginesTest::provideSplitData\(\) should return array#' - path: tests/* - - - message: '#Method Gotenberg\\Test\\Modules\\PdfEnginesTest::provideConvertData\(\) should return array#' - path: tests/* \ No newline at end of file + level: max + paths: + - src + - tests diff --git a/phpunit.xml b/phpunit.xml index d07c1ba..37bb9b5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + ./tests diff --git a/src/ApiModule.php b/src/ApiModule.php index b540aa3..8483d20 100644 --- a/src/ApiModule.php +++ b/src/ApiModule.php @@ -6,9 +6,8 @@ use Http\Discovery\Psr17FactoryDiscovery; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\StreamInterface; -use function mb_substr; +use function rtrim; trait ApiModule { @@ -19,47 +18,27 @@ trait ApiModule public function __construct(string $baseUrl) { - if (mb_substr($baseUrl, -1) === '/') { - // We remove the trailing slash as the endpoints start with a slash. - $this->url = mb_substr($baseUrl, 0, -1); - - return; - } - - $this->url = $baseUrl; + // The endpoints start with a slash. + $this->url = rtrim($baseUrl, '/'); } /** * Overrides the default UUID correlation ID that identifies a request in - * Gotenberg's logs. + * Gotenberg's logs. Set $header if Gotenberg runs with a custom + * --api-correlation-id-header. */ - public function correlationId(string $correlationId, string $header = 'Gotenberg-Trace'): self + public function correlationId(string $correlationId, string $header = 'Gotenberg-Trace'): static { $this->headers[$header] = $correlationId; return $this; } - /** - * Overrides the default UUID trace, or request ID, that identifies a - * request in Gotenberg's logs. - * - * @deprecated Use correlationId() instead. - */ - public function trace(string $trace, string $header = 'Gotenberg-Trace'): self - { - return $this->correlationId($trace, $header); - } - - protected function request(string $method, StreamInterface|null $body = null): RequestInterface + protected function request(string $method = 'GET'): RequestInterface { $request = Psr17FactoryDiscovery::findRequestFactory() ->createRequest($method, $this->url . $this->endpoint); - if ($body !== null) { - $request->withBody($body); - } - foreach ($this->headers as $key => $value) { $request = $request->withHeader($key, $value); } diff --git a/src/DownloadFrom.php b/src/DownloadFrom.php index 015ec4a..3d59e64 100644 --- a/src/DownloadFrom.php +++ b/src/DownloadFrom.php @@ -6,37 +6,24 @@ use JsonSerializable; -class DownloadFrom implements JsonSerializable +final readonly class DownloadFrom implements JsonSerializable { - public const FIELD_EMBEDDED = 'embedded'; - public const FIELD_WATERMARK = 'watermark'; - public const FIELD_STAMP = 'stamp'; - /** @param array $extraHttpHeaders */ public function __construct( - public readonly string $url, - public readonly array|null $extraHttpHeaders = null, - public readonly bool $embedded = false, - public readonly string $field = '', + public string $url, + public array $extraHttpHeaders = [], ) { } - /** @return array|bool|string> */ + /** @return array{url: string, extraHttpHeaders?: array} */ public function jsonSerialize(): array { - $serialized = [ - 'url' => $this->url, - 'embedded' => $this->embedded, - ]; + $serialized = ['url' => $this->url]; - if (! empty($this->extraHttpHeaders)) { + if ($this->extraHttpHeaders !== []) { $serialized['extraHttpHeaders'] = $this->extraHttpHeaders; } - if ($this->field !== '') { - $serialized['field'] = $this->field; - } - return $serialized; } } diff --git a/src/EmbedMetadata.php b/src/EmbedMetadata.php deleted file mode 100644 index 37188fa..0000000 --- a/src/EmbedMetadata.php +++ /dev/null @@ -1,42 +0,0 @@ - */ - public function jsonSerialize(): array - { - $serialized = []; - - if ($this->mimeType !== null) { - $serialized[self::FIELD_MIME_TYPE] = $this->mimeType; - } - - if ($this->relationship !== null) { - $serialized[self::FIELD_RELATIONSHIP] = $this->relationship; - } - - return $serialized; - } -} diff --git a/src/Exceptions/GotenbergApiErrored.php b/src/Exceptions/GotenbergApiErrored.php index bb9a614..42ad24e 100644 --- a/src/Exceptions/GotenbergApiErrored.php +++ b/src/Exceptions/GotenbergApiErrored.php @@ -25,12 +25,6 @@ public function getCorrelationId(string $header = 'Gotenberg-Trace'): string return $this->response->getHeaderLine($header); } - /** @deprecated Use getCorrelationId() instead. */ - public function getGotenbergTrace(string $header = 'Gotenberg-Trace'): string - { - return $this->getCorrelationId($header); - } - public function getResponse(): ResponseInterface { return $this->response; diff --git a/src/FacturX.php b/src/FacturX.php deleted file mode 100644 index a23c029..0000000 --- a/src/FacturX.php +++ /dev/null @@ -1,28 +0,0 @@ -sendRequest($request); if ($response->getStatusCode() < 200 || $response->getStatusCode() > 299) { @@ -69,26 +70,9 @@ public static function send(RequestInterface $request, ClientInterface|null $cli public static function save(RequestInterface $request, string $dirPath, ClientInterface|null $client = null): string { $response = self::send($request, $client); + $filename = self::filename($response->getHeaderLine('Content-Disposition')); - $contentDisposition = $response->getHeader('Content-Disposition'); - if (count($contentDisposition) === 0) { - throw new NoOutputFileInResponse(); - } - - $filename = false; - foreach ($contentDisposition as $value) { - if (preg_match('/filename="(.+?)"/', $value, $matches)) { - $filename = $matches[1]; - break; - } - - if (preg_match('/filename=([^; ]+)/', $value, $matches)) { - $filename = $matches[1]; - break; - } - } - - if ($filename === false) { + if ($filename === null) { throw new NoOutputFileInResponse(); } @@ -97,8 +81,13 @@ public static function save(RequestInterface $request, string $dirPath, ClientIn throw NativeFunctionErrored::createFromLastPhpError(); } - if (fwrite($file, $response->getBody()->getContents()) === false) { - throw NativeFunctionErrored::createFromLastPhpError(); + $body = $response->getBody(); + while (! $body->eof()) { + if (fwrite($file, $body->read(8192)) === false) { + fclose($file); + + throw NativeFunctionErrored::createFromLastPhpError(); + } } if (fclose($file) === false) { @@ -107,4 +96,26 @@ public static function save(RequestInterface $request, string $dirPath, ClientIn return $filename; } + + /** + * Returns the file name a Content-Disposition header carries, preferring + * the RFC 6266 filename* parameter over its ASCII fallback. The name loses + * any directory part, so that it cannot escape the saving directory. + */ + private static function filename(string $contentDisposition): string|null + { + if (preg_match("/filename\*=UTF-8''([^;\s]+)/i", $contentDisposition, $matches)) { + $filename = rawurldecode($matches[1]); + } elseif (preg_match('/filename="(.+?)"/', $contentDisposition, $matches)) { + $filename = $matches[1]; + } elseif (preg_match('/filename=([^;\s]+)/', $contentDisposition, $matches)) { + $filename = $matches[1]; + } else { + return null; + } + + $filename = basename($filename); + + return $filename === '' || $filename === '.' || $filename === '..' ? null : $filename; + } } diff --git a/src/HrtimeIndex.php b/src/HrtimeIndex.php index ee5c5c4..47cbd7b 100644 --- a/src/HrtimeIndex.php +++ b/src/HrtimeIndex.php @@ -4,20 +4,12 @@ namespace Gotenberg; -use Gotenberg\Exceptions\NativeFunctionErrored; - use function hrtime; -use function is_numeric; final class HrtimeIndex implements Index { public function create(): string { - $index = hrtime(true); - if (! is_numeric($index)) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - return $index . ''; + return (string) hrtime(true); } } diff --git a/src/Modules/Chromium.php b/src/Modules/Chromium.php index c8c76e0..df47f68 100644 --- a/src/Modules/Chromium.php +++ b/src/Modules/Chromium.php @@ -4,9 +4,9 @@ namespace Gotenberg\Modules; -class Chromium +final readonly class Chromium { - public function __construct(public readonly string $baseUrl) + public function __construct(public string $baseUrl) { } diff --git a/src/Modules/ChromiumCookie.php b/src/Modules/ChromiumCookie.php index 76388b0..ca3729d 100644 --- a/src/Modules/ChromiumCookie.php +++ b/src/Modules/ChromiumCookie.php @@ -6,16 +6,16 @@ use JsonSerializable; -class ChromiumCookie implements JsonSerializable +final readonly class ChromiumCookie implements JsonSerializable { public function __construct( - public readonly string $name, - public readonly string $value, - public readonly string $domain, - public readonly string|null $path = null, - public readonly bool|null $secure = null, - public readonly bool|null $httpOnly = null, - public readonly string|null $sameSite = null, + public string $name, + public string $value, + public string $domain, + public string|null $path = null, + public bool|null $secure = null, + public bool|null $httpOnly = null, + public string|null $sameSite = null, ) { } diff --git a/src/Modules/ChromiumEmulatedMediaFeatures.php b/src/Modules/ChromiumEmulatedMediaFeatures.php deleted file mode 100644 index 5380b27..0000000 --- a/src/Modules/ChromiumEmulatedMediaFeatures.php +++ /dev/null @@ -1,25 +0,0 @@ - */ - public function jsonSerialize(): array - { - return [ - 'name' => $this->name, - 'value' => $this->value, - ]; - } -} diff --git a/src/Modules/ChromiumMultipartFormDataModule.php b/src/Modules/ChromiumMultipartFormDataModule.php index 5e1893b..920dcbd 100644 --- a/src/Modules/ChromiumMultipartFormDataModule.php +++ b/src/Modules/ChromiumMultipartFormDataModule.php @@ -4,21 +4,23 @@ namespace Gotenberg\Modules; -use Gotenberg\Exceptions\NativeFunctionErrored; use Gotenberg\MultipartFormDataModule; use Gotenberg\Stream; +use Psr\Http\Message\RequestInterface; +use function array_values; use function json_encode; +use const JSON_THROW_ON_ERROR; + trait ChromiumMultipartFormDataModule { use MultipartFormDataModule; /** - * Hides default white background and allows generating PDFs with - * transparency. + * Hides the default white background and allows transparency. */ - public function omitBackground(): self + public function omitBackground(): static { $this->formValue('omitBackground', true); @@ -26,10 +28,10 @@ public function omitBackground(): self } /** - * Sets the duration (i.e., "1s", "2ms", etc.) to wait when loading an HTML - * document before converting it to PDF. + * Sets the duration (i.e., "1s", "2ms", etc.) to wait when loading the + * page before converting it. */ - public function waitDelay(string $delay): self + public function waitDelay(string $delay): static { $this->formValue('waitDelay', $delay); @@ -37,12 +39,12 @@ public function waitDelay(string $delay): self } /** - * Sets the JavaScript expression to wait before converting an HTML - * document to PDF until it returns true. + * Sets the JavaScript expression to wait for before converting the page, + * until it returns true. * * For instance: "window.status === 'ready'". */ - public function waitForExpression(string $expression): self + public function waitForExpression(string $expression): static { $this->formValue('waitForExpression', $expression); @@ -50,20 +52,31 @@ public function waitForExpression(string $expression): self } /** - * Sets the selector (e.g. '#id') to query before converting an HTML - * document into PDF until it matches a node. + * Sets the selector (e.g. '#id') to query before converting the page, + * until it matches a node. */ - public function waitForSelector(string $selector): self + public function waitForSelector(string $selector): static { $this->formValue('waitForSelector', $selector); return $this; } + /** + * Waits until window.status equals the given value before converting the + * page. + */ + public function waitForWindowStatus(string $status): static + { + $this->formValue('waitForWindowStatus', $status); + + return $this; + } + /** * Forces Chromium to emulate the media type "print". */ - public function emulatePrintMediaType(): self + public function emulatePrintMediaType(): static { $this->formValue('emulatedMediaType', 'print'); @@ -73,7 +86,7 @@ public function emulatePrintMediaType(): self /** * Forces Chromium to emulate the media type "screen". */ - public function emulateScreenMediaType(): self + public function emulateScreenMediaType(): static { $this->formValue('emulatedMediaType', 'screen'); @@ -81,39 +94,24 @@ public function emulateScreenMediaType(): self } /** - * Simulates specific browser conditions by overriding CSS media features + * Overrides CSS media features, such as + * ['prefers-color-scheme' => 'dark']. * - * @param ChromiumEmulatedMediaFeatures[] $features - * - * @throws NativeFunctionErrored + * @param array $features */ - public function emulatedMediaFeatures(array $features): self + public function emulatedMediaFeatures(array $features): static { - $json = json_encode($features); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('emulatedMediaFeatures', $json); + $this->formValue('emulatedMediaFeatures', json_encode($features, JSON_THROW_ON_ERROR)); return $this; } /** * Cookies to store in the Chromium cookie jar. - * - * @param ChromiumCookie[] $cookies - * - * @throws NativeFunctionErrored */ - public function cookies(array $cookies): self + public function cookies(ChromiumCookie ...$cookies): static { - $json = json_encode($cookies); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('cookies', $json); + $this->formValue('cookies', json_encode(array_values($cookies), JSON_THROW_ON_ERROR)); return $this; } @@ -121,7 +119,7 @@ public function cookies(array $cookies): self /** * Overrides the default 'User-Agent' HTTP header. */ - public function userAgent(string $userAgent): self + public function userAgent(string $userAgent): static { $this->formValue('userAgent', $userAgent); @@ -129,21 +127,13 @@ public function userAgent(string $userAgent): self } /** - * Sets extra HTTP headers that Chromium will send when loading the HTML - * document. + * Sets extra HTTP headers that Chromium sends when loading the page. * * @param array $headers - * - * @throws NativeFunctionErrored */ - public function extraHttpHeaders(array $headers): self + public function extraHttpHeaders(array $headers): static { - $json = json_encode($headers); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('extraHttpHeaders', $json); + $this->formValue('extraHttpHeaders', json_encode($headers, JSON_THROW_ON_ERROR)); return $this; } @@ -151,19 +141,10 @@ public function extraHttpHeaders(array $headers): self /** * Forces Gotenberg to return a 409 Conflict response if the HTTP status * code from the main page is not acceptable. - * - * @param int[] $statusCodes - * - * @throws NativeFunctionErrored */ - public function failOnHttpStatusCodes(array $statusCodes): self + public function failOnHttpStatusCodes(int ...$statusCodes): static { - $json = json_encode($statusCodes); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('failOnHttpStatusCodes', $json); + $this->formValue('failOnHttpStatusCodes', json_encode(array_values($statusCodes), JSON_THROW_ON_ERROR)); return $this; } @@ -171,39 +152,21 @@ public function failOnHttpStatusCodes(array $statusCodes): self /** * Forces Gotenberg to return a 409 Conflict response if the HTTP status * code from at least one resource is not acceptable. - * - * @param int[] $statusCodes - * - * @throws NativeFunctionErrored */ - public function failOnResourceHttpStatusCodes(array $statusCodes): self + public function failOnResourceHttpStatusCodes(int ...$statusCodes): static { - $json = json_encode($statusCodes); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('failOnResourceHttpStatusCodes', $json); + $this->formValue('failOnResourceHttpStatusCodes', json_encode(array_values($statusCodes), JSON_THROW_ON_ERROR)); return $this; } - /** - * Excludes resources from "failOnResourceHttpStatusCodes" checks based on - * their hostname. - * - * @param string[] $domains - * - * @throws NativeFunctionErrored - */ - public function ignoreResourceHttpStatusDomains(array $domains): self + /** + * Excludes resources from failOnResourceHttpStatusCodes checks based on + * their hostname. + */ + public function ignoreResourceHttpStatusDomains(string ...$domains): static { - $json = json_encode($domains); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('ignoreResourceHttpStatusDomains', $json); + $this->formValue('ignoreResourceHttpStatusDomains', json_encode(array_values($domains), JSON_THROW_ON_ERROR)); return $this; } @@ -212,7 +175,7 @@ public function ignoreResourceHttpStatusDomains(array $domains): self * Forces Gotenberg to return a 409 Conflict if Chromium fails to load at * least one resource. */ - public function failOnResourceLoadingFailed(): self + public function failOnResourceLoadingFailed(): static { $this->formValue('failOnResourceLoadingFailed', true); @@ -223,7 +186,7 @@ public function failOnResourceLoadingFailed(): self * Forces Gotenberg to return a 409 Conflict response if there are * exceptions in the Chromium console. */ - public function failOnConsoleExceptions(): self + public function failOnConsoleExceptions(): static { $this->formValue('failOnConsoleExceptions', true); @@ -234,41 +197,32 @@ public function failOnConsoleExceptions(): self * Forces Gotenberg to return a 409 Conflict response if the Chromium * console logs messages of the given types (log, debug, info, warning, * error, assert). - * - * @param string[] $types - * - * @throws NativeFunctionErrored */ - public function failOnConsoleMessageTypes(array $types): self + public function failOnConsoleMessageTypes(string ...$types): static { - $json = json_encode($types); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('failOnConsoleMessageTypes', $json); + $this->formValue('failOnConsoleMessageTypes', json_encode(array_values($types), JSON_THROW_ON_ERROR)); return $this; } /** - * Specifies whether Chromium have to wait or not for its network to be - * idle. + * Specifies whether Chromium skips waiting for its network to be idle. + * Pass false for pages that fetch content after they load. */ - public function skipNetworkIdleEvent(bool $skip = true): self + public function skipNetworkIdleEvent(bool $skip = true): static { - $this->formValue('skipNetworkIdleEvent', $skip ?: '0'); + $this->formValue('skipNetworkIdleEvent', $skip); return $this; } /** - * Specifies whether Chromium have to wait or not for its network to be - * almost idle. + * Specifies whether Chromium skips waiting for its network to be almost + * idle. */ - public function skipNetworkAlmostIdleEvent(bool $skip = true): self + public function skipNetworkAlmostIdleEvent(bool $skip = true): static { - $this->formValue('skipNetworkAlmostIdleEvent', $skip ?: '0'); + $this->formValue('skipNetworkAlmostIdleEvent', $skip); return $this; } @@ -276,12 +230,59 @@ public function skipNetworkAlmostIdleEvent(bool $skip = true): self /** * Sets the additional files, like images, fonts, stylesheets, and so on. */ - public function assets(Stream ...$assets): self + public function assets(Stream ...$assets): static { foreach ($assets as $asset) { - $this->formFile($asset->getFilename(), $asset->getStream()); + $this->formFile($asset); } return $this; } + + /** + * Converts a target URL. + */ + public function url(string $url): RequestInterface + { + $this->formValue('url', $url); + + return $this->route('url'); + } + + /** + * Converts an HTML document. + * + * Note: it automatically sets the index filename to "index.html", as + * required by Gotenberg. Pass null when an archive or downloadFrom + * provides it. + */ + public function html(Stream|null $index): RequestInterface + { + if ($index !== null) { + $this->formFile($index, filename: 'index.html'); + } + + return $this->route('html'); + } + + /** + * Converts one or more markdown files. + * + * Note: it automatically sets the index filename to "index.html", as + * required by Gotenberg. + */ + public function markdown(Stream|null $index, Stream ...$markdowns): RequestInterface + { + if ($index !== null) { + $this->formFile($index, filename: 'index.html'); + } + + foreach ($markdowns as $markdown) { + $this->formFile($markdown); + } + + return $this->route('markdown'); + } + + abstract private function route(string $source): RequestInterface; } diff --git a/src/Modules/ChromiumPdf.php b/src/Modules/ChromiumPdf.php index 0c84d21..6d0f235 100644 --- a/src/Modules/ChromiumPdf.php +++ b/src/Modules/ChromiumPdf.php @@ -4,23 +4,19 @@ namespace Gotenberg\Modules; -use Gotenberg\EmbedMetadata; -use Gotenberg\Exceptions\NativeFunctionErrored; -use Gotenberg\FacturX; -use Gotenberg\SplitMode; +use Gotenberg\StagesModule; use Gotenberg\Stream; use Psr\Http\Message\RequestInterface; -use function json_encode; - class ChromiumPdf { use ChromiumMultipartFormDataModule; + use StagesModule; /** - * Defines whether to print the entire content in one single page. + * Prints the entire content in one single page, as tall as the content. */ - public function singlePage(): self + public function singlePage(): static { $this->formValue('singlePage', true); @@ -28,36 +24,22 @@ public function singlePage(): self } /** - * Re-encodes the images of the resulting PDF to reduce its file size. + * Sets the paper size: "A3", "A4", "A5", "B4", "B5", "Letter" (default), + * "Legal" or "Tabloid". paperDimensions() overrides it. */ - public function optimizeImages(int $quality = 80): self + public function paperSize(string $size): static { - $this->formValue('optimizeImages', true); - $this->formValue('imageQuality', $quality); + $this->formValue('paperSize', $size); return $this; } /** - * Overrides the default paper size, using various units such as 72pt, + * Overrides the paper width and height, using various units such as 72pt, * 96px, 1in, 25.4mm, 2.54cm, or 6pc. The default unit is inches when * not specified. - * - * Examples of paper size (width x height): - * - * Letter - 8.5 x 11 (default) - * Legal - 8.5 x 14 - * Tabloid - 11 x 17 - * Ledger - 17 x 11 - * A0 - 33.1 x 46.8 - * A1 - 23.4 x 33.1 - * A2 - 16.54 x 23.4 - * A3 - 11.7 x 16.54 - * A4 - 8.27 x 11.7 - * A5 - 5.83 x 8.27 - * A6 - 4.13 x 5.83 */ - public function paperSize(float|string $width, float|string $height): self + public function paperDimensions(float|string $width, float|string $height): static { $this->formValue('paperWidth', $width); $this->formValue('paperHeight', $height); @@ -70,7 +52,7 @@ public function paperSize(float|string $width, float|string $height): self * 72pt, 96px, 1in, 25.4mm, 2.54cm, or 6pc. The default unit is inches when * not specified. */ - public function margins(float|string $top, float|string $bottom, float|string $left, float|string $right): self + public function margins(float|string $top, float|string $bottom, float|string $left, float|string $right): static { $this->formValue('marginTop', $top); $this->formValue('marginBottom', $bottom); @@ -83,7 +65,7 @@ public function margins(float|string $top, float|string $bottom, float|string $l /** * Forces page size as defined by CSS. */ - public function preferCssPageSize(): self + public function preferCssPageSize(): static { $this->formValue('preferCssPageSize', true); @@ -91,21 +73,21 @@ public function preferCssPageSize(): self } /** - * Embeds the document outline into the PDF. + * Embeds the document outline, made from the headings, as bookmarks. */ - public function generateDocumentOutline(): self + public function nativeBookmarks(): static { - $this->formValue('generateDocumentOutline', true); + $this->formValue('nativeBookmarks', true); return $this; } /** - * Generates tagged (accessible) PDF. + * Generates a tagged (accessible) PDF. */ - public function generateTaggedPdf(): self + public function nativeTaggedPdf(): static { - $this->formValue('generateTaggedPdf', true); + $this->formValue('nativeTaggedPdf', true); return $this; } @@ -113,7 +95,7 @@ public function generateTaggedPdf(): self /** * Prints the background graphics. */ - public function printBackground(): self + public function printBackground(): static { $this->formValue('printBackground', true); @@ -123,7 +105,7 @@ public function printBackground(): self /** * Sets the paper orientation to landscape. */ - public function landscape(): self + public function landscape(): static { $this->formValue('landscape', true); @@ -133,7 +115,7 @@ public function landscape(): self /** * Overrides the default scale of the page rendering (i.e., 1.0). */ - public function scale(float $scale): self + public function scale(float $scale): static { $this->formValue('scale', $scale); @@ -141,12 +123,12 @@ public function scale(float $scale): self } /** - * Set the page ranges to print, e.g., "1-5, 8, 11-13". - * Empty means all pages. + * Sets the pages to print, in Chromium's syntax, e.g., "1-5, 8, 11-13". + * The select stage picks pages after printing. */ - public function nativePageRanges(string $ranges): self + public function nativePages(string $pages): static { - $this->formValue('nativePageRanges', $ranges); + $this->formValue('nativePages', $pages); return $this; } @@ -157,9 +139,9 @@ public function nativePageRanges(string $ranges): self * Note: it automatically sets the filename to "header.html", as required * by Gotenberg. */ - public function header(Stream $header): self + public function header(Stream $header): static { - $this->formFile('header.html', $header->getStream()); + $this->formFile($header, filename: 'header.html'); return $this; } @@ -170,251 +152,16 @@ public function header(Stream $header): self * Note: it automatically sets the filename to "footer.html", as required * by Gotenberg. */ - public function footer(Stream $footer): self - { - $this->formFile('footer.html', $footer->getStream()); - - return $this; - } - - /** - * Splits the resulting PDF. - */ - public function split(SplitMode $mode): self - { - $this->formValue('splitMode', $mode->mode); - $this->formValue('splitSpan', $mode->span); - $this->formValue('splitUnify', $mode->unify ?: '0'); - - return $this; - } - - /** - * Sets the PDF/A format of the resulting PDF. - */ - public function pdfa(string $format): self - { - $this->formValue('pdfa', $format); - - return $this; - } - - /** - * Enables PDF for Universal Access for optimal accessibility. - */ - public function pdfua(): self - { - $this->formValue('pdfua', true); - - return $this; - } - - /** - * Sets the metadata to write. - * - * @param array> $metadata - * - * @throws NativeFunctionErrored - */ - public function metadata(array $metadata): self - { - $json = json_encode($metadata); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('metadata', $json); - - return $this; - } - - /** - * Defines whether the resulting PDF should be flattened. - */ - public function flatten(): self - { - $this->formValue('flatten', true); - - return $this; - } - - /** - * Defines whether the resulting PDF should be encrypted. - */ - public function encrypt(string $userPassword, string $ownerPassword = ''): self - { - $this->formValue('userPassword', $userPassword); - $this->formValue('ownerPassword', $ownerPassword); - - return $this; - } - - /** - * Specifies whether printing the resulting PDF is allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowPrinting(bool $allow = true): self - { - $this->formValue('allowPrinting', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether copying text and graphics from the resulting PDF is - * allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowCopying(bool $allow = true): self - { - $this->formValue('allowCopying', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether modifying the resulting PDF is allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowModifying(bool $allow = true): self - { - $this->formValue('allowModifying', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether adding or modifying annotations in the resulting PDF is - * allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowAnnotating(bool $allow = true): self - { - $this->formValue('allowAnnotating', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether filling interactive form fields in the resulting PDF is - * allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowFillingForms(bool $allow = true): self - { - $this->formValue('allowFillingForms', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether assembling the resulting PDF (insert, rotate, or delete - * pages and create bookmarks or thumbnails) is allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowAssembling(bool $allow = true): self - { - $this->formValue('allowAssembling', $allow ?: '0'); - - return $this; - } - - /** - * Configures the embedding of a Factur-X / ZUGFeRD invoice XML into the - * resulting PDF. Requires a PDF/A-3 variant (set via the pdfa method). - */ - public function facturX(FacturX $facturx): self - { - $this->formFile($facturx->xml->getFilename(), $facturx->xml->getStream(), 'facturxXml'); - $this->formValue('facturxConformanceLevel', $facturx->conformanceLevel); - $this->formValue('facturxDocumentType', $facturx->documentType); - $this->formValue('facturxVersion', $facturx->version); - - return $this; - } - - /** - * Sets the file to embed in the resulting PDF. - */ - public function embeds(Stream ...$embeds): self + public function footer(Stream $footer): static { - foreach ($embeds as $embed) { - $this->formFile($embed->getFilename(), $embed->getStream(), 'embeds'); - } + $this->formFile($footer, filename: 'footer.html'); return $this; } - /** - * Sets metadata on embedded files in the resulting PDF, required for - * Factur-X / ZUGFeRD compliance. - * - * @throws NativeFunctionErrored - */ - public function embedsMetadata(EmbedMetadata ...$metadata): self - { - $map = []; - foreach ($metadata as $entry) { - $map[$entry->filename] = $entry; - } - - $json = json_encode($map); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('embedsMetadata', $json); - - return $this; - } - - /** - * Converts a target URL to PDF. - * - * @throws NativeFunctionErrored - */ - public function url(string $url): RequestInterface - { - $this->formValue('url', $url); - $this->endpoint = '/forms/chromium/convert/url'; - - return $this->request(); - } - - /** - * Converts an HTML document to PDF. - * - * Note: it automatically sets the index filename to "index.html", as - * required by Gotenberg. - */ - public function html(Stream|null $index): RequestInterface + private function route(string $source): RequestInterface { - if ($index !== null) { - $this->formFile('index.html', $index->getStream()); - } - - $this->endpoint = '/forms/chromium/convert/html'; - - return $this->request(); - } - - /** - * Converts one or more markdown files to PDF. - * - * Note: it automatically sets the index filename to "index.html", as - * required by Gotenberg. - */ - public function markdown(Stream|null $index, Stream ...$markdowns): RequestInterface - { - if ($index !== null) { - $this->formFile('index.html', $index->getStream()); - } - - foreach ($markdowns as $markdown) { - $this->formFile($markdown->getFilename(), $markdown->getStream()); - } - - $this->endpoint = '/forms/chromium/convert/markdown'; + $this->endpoint = '/forms/chromium/convert/' . $source; return $this->request(); } diff --git a/src/Modules/ChromiumScreenshot.php b/src/Modules/ChromiumScreenshot.php index afe4e2a..35aa009 100644 --- a/src/Modules/ChromiumScreenshot.php +++ b/src/Modules/ChromiumScreenshot.php @@ -4,8 +4,6 @@ namespace Gotenberg\Modules; -use Gotenberg\Exceptions\NativeFunctionErrored; -use Gotenberg\Stream; use Psr\Http\Message\RequestInterface; class ChromiumScreenshot @@ -15,7 +13,7 @@ class ChromiumScreenshot /** * The device screen width in pixels. */ - public function width(int $width): self + public function width(int $width): static { $this->formValue('width', $width); @@ -25,7 +23,7 @@ public function width(int $width): self /** * The device screen height in pixels. */ - public function height(int $height): self + public function height(int $height): static { $this->formValue('height', $height); @@ -33,10 +31,9 @@ public function height(int $height): self } /** - * Defines whether to clip the screenshot according to the device - * dimensions. + * Clips the screenshot to the device dimensions. */ - public function clip(): self + public function clip(): static { $this->formValue('clip', true); @@ -46,7 +43,7 @@ public function clip(): self /** * The device scale factor, which controls the screenshot pixel density. */ - public function deviceScaleFactor(float $factor): self + public function deviceScaleFactor(float $factor): static { $this->formValue('deviceScaleFactor', $factor); @@ -56,7 +53,7 @@ public function deviceScaleFactor(float $factor): self /** * PNG as image compression format. */ - public function png(): self + public function png(): static { $this->formValue('format', 'png'); @@ -66,7 +63,7 @@ public function png(): self /** * JPEG as image compression format. */ - public function jpeg(): self + public function jpeg(): static { $this->formValue('format', 'jpeg'); @@ -76,7 +73,7 @@ public function jpeg(): self /** * WEBP as image compression format. */ - public function webp(): self + public function webp(): static { $this->formValue('format', 'webp'); @@ -84,9 +81,9 @@ public function webp(): self } /** - * The compression quality from range 0 to 100 (jpeg only). + * The compression quality, from 1 to 100 (jpeg and webp only). */ - public function quality(int $quality): self + public function quality(int $quality): static { $this->formValue('quality', $quality); @@ -94,10 +91,9 @@ public function quality(int $quality): self } /** - * Defines whether to optimize image encoding for speed, not for resulting - * size. + * Optimizes image encoding for speed, not for resulting size. */ - public function optimizeForSpeed(): self + public function optimizeForSpeed(): static { $this->formValue('optimizeForSpeed', true); @@ -108,60 +104,16 @@ public function optimizeForSpeed(): self * Clips the screenshot to the bounding box of the first element matching * the given CSS selector. */ - public function selector(string $selector): self + public function selector(string $selector): static { $this->formValue('selector', $selector); return $this; } - /** - * Captures a screenshot of a target URL. - * - * @throws NativeFunctionErrored - */ - public function url(string $url): RequestInterface - { - $this->formValue('url', $url); - $this->endpoint = '/forms/chromium/screenshot/url'; - - return $this->request(); - } - - /** - * Captures a screenshot of an HTML document. - * - * Note: it automatically sets the index filename to "index.html", as - * required by Gotenberg. - */ - public function html(Stream|null $index): RequestInterface + private function route(string $source): RequestInterface { - if ($index !== null) { - $this->formFile('index.html', $index->getStream()); - } - - $this->endpoint = '/forms/chromium/screenshot/html'; - - return $this->request(); - } - - /** - * Captures a screenshot of one or more markdown files. - * - * Note: it automatically sets the index filename to "index.html", as - * required by Gotenberg. - */ - public function markdown(Stream|null $index, Stream ...$markdowns): RequestInterface - { - if ($index !== null) { - $this->formFile('index.html', $index->getStream()); - } - - foreach ($markdowns as $markdown) { - $this->formFile($markdown->getFilename(), $markdown->getStream()); - } - - $this->endpoint = '/forms/chromium/screenshot/markdown'; + $this->endpoint = '/forms/chromium/screenshot/' . $source; return $this->request(); } diff --git a/src/Modules/LibreOffice.php b/src/Modules/LibreOffice.php index 2f53910..9edb6c1 100644 --- a/src/Modules/LibreOffice.php +++ b/src/Modules/LibreOffice.php @@ -4,40 +4,20 @@ namespace Gotenberg\Modules; -use Gotenberg\EmbedMetadata; -use Gotenberg\Exceptions\NativeFunctionErrored; -use Gotenberg\FacturX; -use Gotenberg\HrtimeIndex; -use Gotenberg\Index; use Gotenberg\MultipartFormDataModule; -use Gotenberg\SplitMode; +use Gotenberg\StagesModule; use Gotenberg\Stream; use Psr\Http\Message\RequestInterface; -use function json_encode; - class LibreOffice { use MultipartFormDataModule; - - private Index|null $index = null; - private bool $merge = false; - - /** - * Overrides the default index generator for ordering - * files we want to merge. - */ - public function index(Index $index): self - { - $this->index = $index; - - return $this; - } + use StagesModule; /** - * Sets the password for opening the source file. + * Sets the password that opens the documents. */ - public function password(string $password): self + public function password(string $password): static { $this->formValue('password', $password); @@ -45,175 +25,67 @@ public function password(string $password): self } /** - * Sets the paper orientation to landscape. - */ - public function landscape(): self - { - $this->formValue('landscape', true); - - return $this; - } - - /** - * Sets the page ranges to print, e.g., "1-4"'. - * Empty means all pages. - * - * Note: the page ranges are applied to all files independently. - */ - public function nativePageRanges(string $ranges): self - { - $this->formValue('nativePageRanges', $ranges); - - return $this; - } - - /** - * Specifies whether to update the indexes before conversion, keeping in - * mind that doing so might result in missing links in the final PDF. + * Sets the paper size of every page: "A3", "A4", "A5", "B4", "B5", + * "Letter", "Legal" or "Tabloid". Without it, each document keeps its own + * page size. */ - public function updateIndexes(bool $update = true): self + public function paperSize(string $size): static { - $this->formValue('updateIndexes', $update ?: '0'); + $this->formValue('paperSize', $size); return $this; } /** - * Specifies whether form fields are exported as widgets or only their fixed - * print representation is exported. + * Overrides the paper width and height of every page, using various units + * such as 72pt, 96px, 1in, 25.4mm, 2.54cm, or 6pc. */ - public function exportFormFields(bool $export = true): self + public function paperDimensions(float|string $width, float|string $height): static { - $this->formValue('exportFormFields', $export ?: '0'); + $this->formValue('paperWidth', $width); + $this->formValue('paperHeight', $height); return $this; } /** - * Specifies whether multiple form fields exported are allowed to have the - * same field name. - */ - public function allowDuplicateFieldNames(): self - { - $this->formValue('allowDuplicateFieldNames', true); - - return $this; - } - - /** - * Specifies if bookmarks are exported to PDF. - */ - public function exportBookmarks(bool $export = true): self - { - $this->formValue('exportBookmarks', $export ?: '0'); - - return $this; - } - - /** - * Specifies that the bookmarks contained in the source LibreOffice file - * should be exported to the PDF file as Named Destination. - */ - public function exportBookmarksToPdfDestination(): self - { - $this->formValue('exportBookmarksToPdfDestination', true); - - return $this; - } - - /** - * Exports the placeholders fields visual markings only. The exported - * placeholder is ineffective. - */ - public function exportPlaceholders(): self - { - $this->formValue('exportPlaceholders', true); - - return $this; - } - - /** - * Specifies if notes are exported to PDF. - */ - public function exportNotes(): self - { - $this->formValue('exportNotes', true); - - return $this; - } - - /** - * Specifies if notes pages are exported to PDF. Notes pages are available - * in Impress documents only. - */ - public function exportNotesPages(): self - { - $this->formValue('exportNotesPages', true); - - return $this; - } - - /** - * Specifies, if the form field exportNotesPages is set to true, if only - * notes pages are exported to PDF. - */ - public function exportOnlyNotesPages(): self - { - $this->formValue('exportOnlyNotesPages', true); - - return $this; - } - - /** - * Specifies if notes in margin are exported to PDF. - */ - public function exportNotesInMargin(): self - { - $this->formValue('exportNotesInMargin', true); - - return $this; - } - - /** - * Specifies that the target documents with .od[tpgs] extension, will have - * that extension changed to .pdf when the link is exported to PDF. The - * source document remains untouched. + * Sets the paper orientation to landscape. */ - public function convertOooTargetToPdfTarget(): self + public function landscape(): static { - $this->formValue('convertOooTargetToPdfTarget', true); + $this->formValue('landscape', true); return $this; } /** - * Specifies that the file system related hyperlinks (file:// protocol) - * present in the document will be exported as relative to the source - * document location. + * Sets the pages to export, in LibreOffice's syntax, e.g., "1-4". The + * select stage picks pages after the conversion. + * + * Note: the pages apply to all files independently. */ - public function exportLinksRelativeFsys(): self + public function nativePages(string $pages): static { - $this->formValue('exportLinksRelativeFsys', true); + $this->formValue('nativePages', $pages); return $this; } /** - * Exports, for LibreOffice Impress, slides that are not included in slide - * shows. + * Puts each sheet of a spreadsheet on one page, hidden sheets included, + * regardless of its paper size and print ranges. */ - public function exportHiddenSlides(): self + public function singlePage(): static { - $this->formValue('exportHiddenSlides', true); + $this->formValue('singlePage', true); return $this; } /** - * Specifies that automatically inserted empty pages are suppressed. This - * option is active only if storing Writer documents. + * Suppresses automatically inserted empty pages. Writer documents only. */ - public function skipEmptyPages(): self + public function skipEmptyPages(): static { $this->formValue('skipEmptyPages', true); @@ -221,248 +93,208 @@ public function skipEmptyPages(): self } /** - * Specifies that a stream is inserted to the PDF file which contains the - * original document for archiving purposes. - */ - public function addOriginalDocumentAsStream(): self - { - $this->formValue('addOriginalDocumentAsStream', true); - - return $this; - } - - /** - * Specifies how the document shall be displayed when opened. Possible - * values are: "document", "outline", "thumbnails". - */ - public function initialView(string $view): self - { - $this->formValue('initialView', $view); - - return $this; - } - - /** - * Specifies the page on which a PDF document should be opened in the - * viewer. - */ - public function initialPage(int $page): self - { - $this->formValue('initialPage', $page); - - return $this; - } - - /** - * Specifies the magnification to use when the document is opened. - * Possible values are: "default", "fitVisible", "fitWidth", "fitPage", - * "fitActualSize". + * Specifies whether to update the indexes before conversion, keeping in + * mind that doing so might result in missing links in the final PDF. */ - public function magnification(string $magnification): self + public function updateIndexes(bool $update = true): static { - $this->formValue('magnification', $magnification); + $this->formValue('updateIndexes', $update); return $this; } /** - * Specifies the zoom value to use when the document is opened. + * Specifies whether form fields are exported as widgets or only their fixed + * print representation is exported. */ - public function zoom(int $zoom): self + public function exportFormFields(bool $export = true): static { - $this->formValue('zoom', $zoom); + $this->formValue('exportFormFields', $export); return $this; } /** - * Specifies the page layout to use when the document is opened. Possible - * values are: "default", "singlePage", "continuous", "continuousFacing". + * Allows multiple exported form fields to have the same field name. */ - public function pageLayout(string $layout): self + public function allowDuplicateFieldNames(): static { - $this->formValue('pageLayout', $layout); + $this->formValue('allowDuplicateFieldNames', true); return $this; } /** - * Specifies that the first page on the left side of the viewer should be - * displayed in a two-column layout. + * Specifies whether bookmarks are exported to PDF. */ - public function firstPageOnLeft(): self + public function nativeBookmarks(bool $export = true): static { - $this->formValue('firstPageOnLeft', true); + $this->formValue('nativeBookmarks', $export); return $this; } /** - * Specifies that the PDF viewer window should be resized to fit the size - * of the first page of the document. + * Exports the bookmarks of the document as Named Destinations. */ - public function resizeWindowToInitialPage(): self + public function exportBookmarksToPdfDestination(): static { - $this->formValue('resizeWindowToInitialPage', true); + $this->formValue('exportBookmarksToPdfDestination', true); return $this; } /** - * Specifies that the PDF viewer window should be centered on the screen. + * Exports the placeholders fields visual markings only. The exported + * placeholder is ineffective. */ - public function centerWindow(): self + public function exportPlaceholders(): static { - $this->formValue('centerWindow', true); + $this->formValue('exportPlaceholders', true); return $this; } /** - * Specifies that the PDF document should be displayed in full-screen mode. + * Exports the notes. */ - public function openInFullScreenMode(): self + public function exportNotes(): static { - $this->formValue('openInFullScreenMode', true); + $this->formValue('exportNotes', true); return $this; } /** - * Specifies that the title of the document should be displayed in the - * title bar of the viewer. + * Exports the notes in the margin. */ - public function displayPDFDocumentTitle(): self + public function exportNotesInMargin(): static { - $this->formValue('displayPDFDocumentTitle', true); + $this->formValue('exportNotesInMargin', true); return $this; } /** - * Specifies that the viewer's menu bar should be hidden. + * Exports the notes pages. Impress documents only. */ - public function hideViewerMenubar(): self + public function exportNotesPages(): static { - $this->formValue('hideViewerMenubar', true); + $this->formValue('exportNotesPages', true); return $this; } /** - * Specifies that the viewer's toolbar should be hidden. + * Exports only the notes pages, with exportNotesPages. */ - public function hideViewerToolbar(): self + public function exportOnlyNotesPages(): static { - $this->formValue('hideViewerToolbar', true); + $this->formValue('exportOnlyNotesPages', true); return $this; } /** - * Specifies that the viewer's window controls should be hidden. + * Exports, for Impress, the slides that are not included in slide shows. */ - public function hideViewerWindowControls(): self + public function exportHiddenSlides(): static { - $this->formValue('hideViewerWindowControls', true); + $this->formValue('exportHiddenSlides', true); return $this; } /** - * Specifies whether to use transition effects when displaying the - * document. + * Specifies whether to use transition effects when displaying the document. */ - public function useTransitionEffects(): self + public function useTransitionEffects(bool $use = true): static { - $this->formValue('useTransitionEffects', true); + $this->formValue('useTransitionEffects', $use); return $this; } /** - * Specifies the number of bookmark levels to display in the viewer when - * the document is opened. + * Changes the .od[tpgs] extension of link targets to .pdf. The source + * document remains untouched. */ - public function openBookmarkLevels(int $levels): self + public function convertOooTargetToPdfTarget(): static { - $this->formValue('openBookmarkLevels', $levels); + $this->formValue('convertOooTargetToPdfTarget', true); return $this; } /** - * Ignores each sheet's paper size, print ranges and shown/hidden status - * and puts every sheet (even hidden sheets) on exactly one page. + * Exports the file system hyperlinks (file:// protocol) as relative to the + * source document location. */ - public function singlePageSheets(): self + public function exportLinksRelativeFsys(): static { - $this->formValue('singlePageSheets', true); + $this->formValue('exportLinksRelativeFsys', true); return $this; } /** - * Specifies if images are exported to PDF using a lossless compression - * format like PNG or compressed using the JPEG format. + * Inserts a stream that contains the original document, for archiving + * purposes. */ - public function losslessImageCompression(): self + public function addOriginalDocumentAsStream(): static { - $this->formValue('losslessImageCompression', true); + $this->formValue('addOriginalDocumentAsStream', true); return $this; } /** - * Specifies the quality of the JPG export. A higher value produces a - * higher-quality image and a larger file. Between 1 and 100. + * Sets the quality of the JPEG export, from 1 to 100. A higher value + * produces a higher-quality image and a larger file. */ - public function quality(int $quality): self + public function nativeQuality(int $quality): static { - $this->formValue('quality', $quality); + $this->formValue('nativeQuality', $quality); return $this; } /** - * Re-encodes the images of the resulting PDF to reduce its file size. + * Exports images with a lossless compression format like PNG, instead of + * JPEG. */ - public function optimizeImages(int $quality = 80): self + public function nativeLosslessImageCompression(): static { - $this->formValue('optimizeImages', true); - $this->formValue('imageQuality', $quality); + $this->formValue('nativeLosslessImageCompression', true); return $this; } /** - * Specifies if the resolution of each image is reduced to the resolution - * specified by the form field maxImageResolution. - * FIXME: parameter not used. + * Reduces the resolution of each image to nativeMaxImageResolution. */ - public function reduceImageResolution(bool $notUsedAnymore = true): self + public function nativeReduceImageResolution(): static { - $this->formValue('reduceImageResolution', true); + $this->formValue('nativeReduceImageResolution', true); return $this; } /** - * If the form field reduceImageResolution is set to true, tells if all - * images will be reduced to the given value in DPI. Possible values are: - * 75, 150, 300, 600 and 1200. + * Sets the resolution, in DPI, nativeReduceImageResolution reduces the + * images to: 75, 150, 300, 600 or 1200. */ - public function maxImageResolution(int $dpi): self + public function nativeMaxImageResolution(int $dpi): static { - $this->formValue('maxImageResolution', $dpi); + $this->formValue('nativeMaxImageResolution', $dpi); return $this; } /** - * Sets the native watermark text. + * Sets the watermark text LibreOffice draws on each page. */ - public function nativeWatermarkText(string $text): self + public function nativeWatermarkText(string $text): static { $this->formValue('nativeWatermarkText', $text); @@ -470,9 +302,9 @@ public function nativeWatermarkText(string $text): self } /** - * Sets the native watermark color. + * Sets the watermark text color, as "#rrggbb". */ - public function nativeWatermarkColor(int $color): self + public function nativeWatermarkColor(string $color): static { $this->formValue('nativeWatermarkColor', $color); @@ -480,39 +312,39 @@ public function nativeWatermarkColor(int $color): self } /** - * Sets the native watermark font height. + * Sets the watermark font size. */ - public function nativeWatermarkFontHeight(int $height): self + public function nativeWatermarkFontSize(int $size): static { - $this->formValue('nativeWatermarkFontHeight', $height); + $this->formValue('nativeWatermarkFontSize', $size); return $this; } /** - * Sets the native watermark rotate angle. + * Sets the watermark angle, in degrees, counter-clockwise. */ - public function nativeWatermarkRotateAngle(int $angle): self + public function nativeWatermarkRotation(float $degrees): static { - $this->formValue('nativeWatermarkRotateAngle', $angle); + $this->formValue('nativeWatermarkRotation', $degrees); return $this; } /** - * Sets the native watermark font name. + * Sets the watermark font name. */ - public function nativeWatermarkFontName(string $name): self + public function nativeWatermarkFont(string $font): static { - $this->formValue('nativeWatermarkFontName', $name); + $this->formValue('nativeWatermarkFont', $font); return $this; } /** - * Sets the native tiled watermark text. + * Sets the tiled watermark text LibreOffice draws on each page. */ - public function nativeTiledWatermarkText(string $text): self + public function nativeTiledWatermarkText(string $text): static { $this->formValue('nativeTiledWatermarkText', $text); @@ -520,233 +352,179 @@ public function nativeTiledWatermarkText(string $text): self } /** - * Sets the PDF/A format of the resulting PDF. + * Exports PDF/A: "PDF/A-1b", "PDF/A-2b", "PDF/A-3b" or "PDF/A-4". The + * remediate stage reaches the other PDF/A levels. */ - public function pdfa(string $format): self + public function nativePdfa(string $profile): static { - $this->formValue('pdfa', $format); + $this->formValue('nativePdfa', $profile); return $this; } /** - * Enables PDF for Universal Access for optimal accessibility. + * Exports PDF/UA: "PDF/UA-1". */ - public function pdfua(): self + public function nativePdfua(string $profile): static { - $this->formValue('pdfua', true); + $this->formValue('nativePdfua', $profile); return $this; } /** - * Generates a tagged (accessible) PDF without declaring PDF/UA - * conformance. PDF/UA implies it. + * Generates a tagged (accessible) PDF without declaring PDF/UA conformance. */ - public function generateTaggedPdf(): self + public function nativeTaggedPdf(): static { - $this->formValue('generateTaggedPdf', true); + $this->formValue('nativeTaggedPdf', true); return $this; } /** - * Sets the metadata to write. - * - * @param array> $metadata - * - * @throws NativeFunctionErrored + * Specifies how the document shall be displayed when opened: "default", + * "outline" or "thumbnails". */ - public function metadata(array $metadata): self + public function initialView(string $view): static { - $json = json_encode($metadata); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('metadata', $json); - - return $this; - } - - /** - * Merges the resulting PDFs. - */ - public function merge(): self - { - $this->merge = true; - $this->formValue('merge', true); + $this->formValue('initialView', $view); return $this; } /** - * Splits the resulting PDFs. + * Specifies the page on which the document opens in the viewer. */ - public function split(SplitMode $mode): self + public function initialPage(int $page): static { - $this->formValue('splitMode', $mode->mode); - $this->formValue('splitSpan', $mode->span); - $this->formValue('splitUnify', $mode->unify ?: '0'); + $this->formValue('initialPage', $page); return $this; } /** - * Defines whether the resulting PDF should be flattened. + * Specifies the magnification when the document opens: "default", + * "fitVisible", "fitWidth", "fitPage" or "fitActualSize". */ - public function flatten(): self + public function magnification(string $magnification): static { - $this->formValue('flatten', true); + $this->formValue('magnification', $magnification); return $this; } /** - * Defines whether the resulting PDF should be encrypted. + * Specifies the zoom when the document opens, with the magnification + * "fitActualSize". */ - public function encrypt(string $userPassword, string $ownerPassword = ''): self + public function zoom(int $zoom): static { - $this->formValue('userPassword', $userPassword); - $this->formValue('ownerPassword', $ownerPassword); + $this->formValue('zoom', $zoom); return $this; } /** - * Specifies whether printing the resulting PDF is allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. + * Specifies the page layout when the document opens: "default", + * "singlePage", "continuous" or "continuousFacing". */ - public function allowPrinting(bool $allow = true): self + public function pageLayout(string $layout): static { - $this->formValue('allowPrinting', $allow ?: '0'); + $this->formValue('pageLayout', $layout); return $this; } /** - * Specifies whether copying text and graphics from the resulting PDF is - * allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. + * Specifies how many bookmark levels the viewer opens; -1 opens them all. */ - public function allowCopying(bool $allow = true): self + public function openBookmarkLevels(int $levels): static { - $this->formValue('allowCopying', $allow ?: '0'); + $this->formValue('openBookmarkLevels', $levels); return $this; } /** - * Specifies whether modifying the resulting PDF is allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. + * Resizes the viewer window to fit the first page. */ - public function allowModifying(bool $allow = true): self + public function resizeWindowToInitialPage(): static { - $this->formValue('allowModifying', $allow ?: '0'); + $this->formValue('resizeWindowToInitialPage', true); return $this; } /** - * Specifies whether adding or modifying annotations in the resulting PDF is - * allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. + * Centers the viewer window on the screen. */ - public function allowAnnotating(bool $allow = true): self + public function centerWindow(): static { - $this->formValue('allowAnnotating', $allow ?: '0'); + $this->formValue('centerWindow', true); return $this; } /** - * Specifies whether filling interactive form fields in the resulting PDF is - * allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. + * Opens the document in full-screen mode. */ - public function allowFillingForms(bool $allow = true): self + public function openInFullScreenMode(): static { - $this->formValue('allowFillingForms', $allow ?: '0'); + $this->formValue('openInFullScreenMode', true); return $this; } /** - * Specifies whether assembling the resulting PDF (insert, rotate, or delete - * pages and create bookmarks or thumbnails) is allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. + * Specifies whether the viewer's title bar shows the document title instead + * of the file name. */ - public function allowAssembling(bool $allow = true): self + public function displayPDFDocumentTitle(bool $display = true): static { - $this->formValue('allowAssembling', $allow ?: '0'); + $this->formValue('displayPDFDocumentTitle', $display); return $this; } /** - * Configures the embedding of a Factur-X / ZUGFeRD invoice XML into the - * resulting PDF. Requires a PDF/A-3 variant (set via the pdfa method). + * Hides the viewer's menu bar. */ - public function facturX(FacturX $facturx): self + public function hideViewerMenubar(): static { - $this->formFile($facturx->xml->getFilename(), $facturx->xml->getStream(), 'facturxXml'); - $this->formValue('facturxConformanceLevel', $facturx->conformanceLevel); - $this->formValue('facturxDocumentType', $facturx->documentType); - $this->formValue('facturxVersion', $facturx->version); + $this->formValue('hideViewerMenubar', true); return $this; } /** - * Sets the file to embed in the resulting PDF. + * Hides the viewer's toolbar. */ - public function embeds(Stream ...$embeds): self + public function hideViewerToolbar(): static { - foreach ($embeds as $embed) { - $this->formFile($embed->getFilename(), $embed->getStream(), 'embeds'); - } + $this->formValue('hideViewerToolbar', true); return $this; } /** - * Sets metadata on embedded files in the resulting PDF, required for - * Factur-X / ZUGFeRD compliance. - * - * @throws NativeFunctionErrored + * Hides the viewer's window controls. */ - public function embedsMetadata(EmbedMetadata ...$metadata): self + public function hideViewerWindowControls(): static { - $map = []; - foreach ($metadata as $entry) { - $map[$entry->filename] = $entry; - } - - $json = json_encode($map); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('embedsMetadata', $json); + $this->formValue('hideViewerWindowControls', true); return $this; } /** - * Converts the given document(s) to PDF(s). Gotenberg will return either - * a unique PDF if you request a merge or a ZIP archive with the PDFs. - * - * Note: if you requested a merge, the merging order is determined by the - * order of the arguments. + * Converts the given documents to PDFs. Gotenberg returns one PDF per + * document, in a ZIP archive when there are several, unless a merge stage + * joins them in the order of the arguments. */ public function convert(Stream ...$files): RequestInterface { - $index = $this->index ?? new HrtimeIndex(); - foreach ($files as $file) { - $filename = $this->merge ? $index->create() . '_' . $file->getFilename() : $file->getFilename(); - $this->formFile($filename, $file->getStream()); - } - + $this->inputFiles(...$files); $this->endpoint = '/forms/libreoffice/convert'; return $this->request(); diff --git a/src/Modules/Pdf.php b/src/Modules/Pdf.php new file mode 100644 index 0000000..84b1d6e --- /dev/null +++ b/src/Modules/Pdf.php @@ -0,0 +1,137 @@ +formValue('password', $password); + + return $this; + } + + /** + * Runs the stages set with stages() on the PDFs, in order. + * Gotenberg returns the PDF, or a ZIP archive when several PDFs come out. + */ + public function pipe(Stream ...$pdfs): RequestInterface + { + $this->inputFiles(...$pdfs); + $this->endpoint = '/forms/pdf/pipe'; + + return $this->request(); + } + + /** + * Runs one stage on the PDFs, through its own route, /forms/pdf/{op}. + * Gotenberg returns the PDF, or a ZIP archive when several PDFs come out. + */ + public function run(Stage $stage, Stream ...$pdfs): RequestInterface + { + foreach ($stage->formFields() as $name => $value) { + $this->formValue($name, $value); + } + + $this->stageFiles($stage); + $this->inputFiles(...$pdfs); + $this->endpoint = '/forms/pdf/' . $stage->op; + + return $this->request(); + } + + /** + * Reads the page count, the PDF version, and whether the PDFs are + * encrypted, tagged, linearized or hold form fields. Gotenberg returns + * JSON, one entry per PDF under its file name. + */ + public function info(Stream ...$pdfs): RequestInterface + { + return $this->read('info', ...$pdfs); + } + + /** + * Reads the metadata of the PDFs. Gotenberg returns JSON, one entry per + * PDF under its file name. + */ + public function metadata(Stream ...$pdfs): RequestInterface + { + return $this->read('metadata', ...$pdfs); + } + + /** + * Reads the bookmarks of the PDFs. Gotenberg returns JSON, one entry per + * PDF under its file name. + */ + public function bookmarks(Stream ...$pdfs): RequestInterface + { + return $this->read('bookmarks', ...$pdfs); + } + + /** + * Lists the files embedded in the PDFs. Gotenberg returns JSON, one entry + * per PDF under its file name. + */ + public function attachments(Stream ...$pdfs): RequestInterface + { + return $this->read('attachments', ...$pdfs); + } + + /** + * Lists the form fields of the PDFs, optionally on the given pages only. + * Gotenberg returns JSON, one entry per PDF under its file name. + */ + public function form(string|null $pages, Stream ...$pdfs): RequestInterface + { + if ($pages !== null) { + $this->formValue('pages', $pages); + } + + return $this->read('form', ...$pdfs); + } + + /** + * Checks the PDFs against a profile, such as "PDF/A-3b", "PDF/UA-1" or + * "Factur-X", or against a PDF/A profile and a PDF/UA one together. + * Gotenberg returns JSON, one entry per PDF under its file name. + * + * @param string|list $profile + */ + public function validate(string|array $profile, Stream ...$pdfs): RequestInterface + { + $this->formValue('profile', is_array($profile) ? json_encode($profile, JSON_THROW_ON_ERROR) : $profile); + + return $this->read('validate', ...$pdfs); + } + + private function read(string $route, Stream ...$pdfs): RequestInterface + { + foreach ($pdfs as $pdf) { + $this->formFile($pdf); + } + + $this->endpoint = '/forms/pdf/' . $route; + + return $this->request(); + } +} diff --git a/src/Modules/PdfEngines.php b/src/Modules/PdfEngines.php deleted file mode 100644 index ec9840b..0000000 --- a/src/Modules/PdfEngines.php +++ /dev/null @@ -1,549 +0,0 @@ -index = $index; - - return $this; - } - - /** - * Sets the PDF/A format of the resulting PDF. - */ - public function pdfa(string $format): self - { - $this->formValue('pdfa', $format); - - return $this; - } - - /** - * Enables PDF for Universal Access for optimal accessibility. - */ - public function pdfua(): self - { - $this->formValue('pdfua', true); - - return $this; - } - - /** - * Sets the metadata to write. - * - * @param array> $metadata - * - * @throws NativeFunctionErrored - */ - public function metadata(array $metadata): self - { - $json = json_encode($metadata); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('metadata', $json); - - return $this; - } - - /** - * Defines whether the resulting PDF should be flattened. - * Prefer the flatten method if you only want to flatten one or more PDFs. - */ - public function flattening(): self - { - $this->formValue('flatten', true); - - return $this; - } - - /** - * Re-encodes the images of the resulting PDF to reduce its file size. - */ - public function optimizeImages(int $quality = 80): self - { - $this->formValue('optimizeImages', true); - $this->formValue('imageQuality', $quality); - - return $this; - } - - /** - * Defines whether the resulting PDF should be encrypted. - * Prefer the encrypt method if you only want to encrypt one or more PDFs. - */ - public function encrypting(string $userPassword, string $ownerPassword = ''): self - { - $this->formValue('userPassword', $userPassword); - $this->formValue('ownerPassword', $ownerPassword); - - return $this; - } - - /** - * Specifies whether printing the resulting PDF is allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowPrinting(bool $allow = true): self - { - $this->formValue('allowPrinting', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether copying text and graphics from the resulting PDF is - * allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowCopying(bool $allow = true): self - { - $this->formValue('allowCopying', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether modifying the resulting PDF is allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowModifying(bool $allow = true): self - { - $this->formValue('allowModifying', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether adding or modifying annotations in the resulting PDF is - * allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowAnnotating(bool $allow = true): self - { - $this->formValue('allowAnnotating', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether filling interactive form fields in the resulting PDF is - * allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowFillingForms(bool $allow = true): self - { - $this->formValue('allowFillingForms', $allow ?: '0'); - - return $this; - } - - /** - * Specifies whether assembling the resulting PDF (insert, rotate, or delete - * pages and create bookmarks or thumbnails) is allowed. - * Note: restricting any permission requires a userPassword or ownerPassword. - */ - public function allowAssembling(bool $allow = true): self - { - $this->formValue('allowAssembling', $allow ?: '0'); - - return $this; - } - - /** - * Configures the embedding of a Factur-X / ZUGFeRD invoice XML into the - * resulting PDF. Requires a PDF/A-3 variant (set via the pdfa method). - */ - public function facturX(FacturX $facturx): self - { - $this->formFile($facturx->xml->getFilename(), $facturx->xml->getStream(), 'facturxXml'); - $this->formValue('facturxConformanceLevel', $facturx->conformanceLevel); - $this->formValue('facturxDocumentType', $facturx->documentType); - $this->formValue('facturxVersion', $facturx->version); - - return $this; - } - - /** - * Sets the file to embed in the resulting PDF. - */ - public function embeds(Stream ...$embeds): self - { - foreach ($embeds as $embed) { - $this->formFile($embed->getFilename(), $embed->getStream(), 'embeds'); - } - - return $this; - } - - /** - * Sets metadata on embedded files in the resulting PDF, required for - * Factur-X / ZUGFeRD compliance. - * - * @throws NativeFunctionErrored - */ - public function embedsMetadata(EmbedMetadata ...$metadata): self - { - $map = []; - foreach ($metadata as $entry) { - $map[$entry->filename] = $entry; - } - - $json = json_encode($map); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('embedsMetadata', $json); - - return $this; - } - - /** - * Merges PDFs into a unique PDF. - * - * Note: the merging order is determined by the order of the arguments. - */ - public function merge(Stream ...$pdfs): RequestInterface - { - $index = $this->index ?? new HrtimeIndex(); - - foreach ($pdfs as $pdf) { - $this->formFile($index->create() . '_' . $pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/merge'; - - return $this->request(); - } - - /** - * Splits PDF(s). - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function split(SplitMode $mode, Stream ...$pdfs): RequestInterface - { - $this->formValue('splitMode', $mode->mode); - $this->formValue('splitSpan', $mode->span); - $this->formValue('splitUnify', $mode->unify ?: '0'); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/split'; - - return $this->request(); - } - - /** - * Flatten PDF(s). - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function flatten(Stream ...$pdfs): RequestInterface - { - $this->formValue('flatten', true); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/flatten'; - - return $this->request(); - } - - /** - * Optimizes PDF(s) by re-encoding their images to reduce file size. - * $quality is the JPEG quality applied to each image, between 1 and 100. - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function optimize(int $quality = 80, Stream ...$pdfs): RequestInterface - { - $this->formValue('imageQuality', $quality); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/optimize'; - - return $this->request(); - } - - /** - * Converts PDF(s) to a specific PDF/A format. - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function convert(string $pdfa, Stream ...$pdfs): RequestInterface - { - $this->pdfa($pdfa); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/convert'; - - return $this->request(); - } - - /** - * Retrieves the metadata of specified PDFs, returning a JSON formatted - * response with the structure filename => metadata. - */ - public function readMetadata(Stream ...$pdfs): RequestInterface - { - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/metadata/read'; - - return $this->request(); - } - - /** - * Allows writing specified metadata to one or more PDF. - * - * @param array> $metadata - * - * @throws NativeFunctionErrored - */ - public function writeMetadata(array $metadata, Stream ...$pdfs): RequestInterface - { - $this->metadata($metadata); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/metadata/write'; - - return $this->request(); - } - - /** - * Allows encrypting one or more PDF. - * - * @throws NativeFunctionErrored - */ - public function encrypt(string $userPassword, string $ownerPassword = '', Stream ...$pdfs): RequestInterface - { - $this->encrypting($userPassword, $ownerPassword); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/encrypt'; - - return $this->request(); - } - - /** - * Sets the bookmarks for the merge route. - */ - public function bookmarks(string $bookmarks): self - { - $this->formValue('bookmarks', $bookmarks); - - return $this; - } - - /** - * Enables auto-indexing of bookmarks for the merge route. - */ - public function autoIndexBookmarks(): self - { - $this->formValue('autoIndexBookmarks', true); - - return $this; - } - - /** - * Adds a bookmark per merged document for the merge route, labeled by its - * title metadata and pointing to its first page. - */ - public function titleBookmarks(): self - { - $this->formValue('titleBookmarks', true); - - return $this; - } - - /** - * Watermarks PDF(s). - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function watermark(string $source, Stream ...$pdfs): RequestInterface - { - $this->formValue('watermarkSource', $source); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/watermark'; - - return $this->request(); - } - - /** - * Stamps PDF(s). - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function stamp(string $source, Stream ...$pdfs): RequestInterface - { - $this->formValue('stampSource', $source); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/stamp'; - - return $this->request(); - } - - /** - * Stamps PDF(s) with the stamps added via stamping(). - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function stamps(Stream ...$pdfs): RequestInterface - { - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/stamp'; - - return $this->request(); - } - - /** - * Watermarks PDF(s) with the watermarks added via watermarking(). - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function watermarks(Stream ...$pdfs): RequestInterface - { - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/watermark'; - - return $this->request(); - } - - /** - * Rotates PDF(s). - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function rotate(int $angle, Stream ...$pdfs): RequestInterface - { - $this->formValue('rotateAngle', $angle); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/rotate'; - - return $this->request(); - } - - /** - * Retrieves the bookmarks of specified PDFs, returning a JSON formatted - * response with the structure filename => bookmarks. - */ - public function readBookmarks(Stream ...$pdfs): RequestInterface - { - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/bookmarks/read'; - - return $this->request(); - } - - /** - * Allows writing specified bookmarks to one or more PDF. - */ - public function writeBookmarks(string $bookmarks, Stream ...$pdfs): RequestInterface - { - $this->formValue('bookmarks', $bookmarks); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/bookmarks/write'; - - return $this->request(); - } - - /** - * Allows embedding one or more files to one or more PDF. - * - * @param Stream[] $embeds - * - * @throws NativeFunctionErrored - */ - public function embed(array $embeds, Stream ...$pdfs): RequestInterface - { - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->embeds(...$embeds); - - $this->endpoint = '/forms/pdfengines/embed'; - - return $this->request(); - } - - /** - * Injects a Factur-X / ZUGFeRD invoice XML into one or more PDF. - * Gotenberg will return the PDF or a ZIP archive with the PDFs. - */ - public function injectFacturX(FacturX $facturx, Stream ...$pdfs): RequestInterface - { - $this->facturX($facturx); - - foreach ($pdfs as $pdf) { - $this->formFile($pdf->getFilename(), $pdf->getStream()); - } - - $this->endpoint = '/forms/pdfengines/factur-x'; - - return $this->request(); - } -} diff --git a/src/MultipartFormDataModule.php b/src/MultipartFormDataModule.php index 20bd0d2..2b7de3f 100644 --- a/src/MultipartFormDataModule.php +++ b/src/MultipartFormDataModule.php @@ -4,20 +4,21 @@ namespace Gotenberg; -use Gotenberg\Exceptions\NativeFunctionErrored; use GuzzleHttp\Psr7\MultipartStream; -use Http\Discovery\Psr17FactoryDiscovery; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\StreamInterface; -use function count; +use function is_bool; use function json_encode; +use const JSON_THROW_ON_ERROR; + trait MultipartFormDataModule { - use ApiModule; + use ApiModule { + request as private apiRequest; + } - /** @var array> */ + /** @var list */ private array $multipartFormData = []; /** @@ -25,7 +26,7 @@ trait MultipartFormDataModule * Note: Gotenberg adds the file extension automatically; you don't have to * set it. */ - public function outputFilename(string $filename): self + public function outputFilename(string $filename): static { $this->headers['Gotenberg-Output-Filename'] = $filename; @@ -33,217 +34,85 @@ public function outputFilename(string $filename): self } /** - * Sets the URLs to download files from. - * - * @param DownloadFrom[] $downloadFrom - * - * @throws NativeFunctionErrored + * Sets the URLs Gotenberg downloads files from, as if you had uploaded + * them. */ - public function downloadFrom(array $downloadFrom): self + public function downloadFrom(DownloadFrom ...$downloadFrom): static { - $json = json_encode($downloadFrom); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->formValue('downloadFrom', $json); + $this->formValue('downloadFrom', json_encode($downloadFrom, JSON_THROW_ON_ERROR)); return $this; } /** - * Sets the callback and error callback that Gotenberg will use to send - * respectively the output file and the error response. - * - * @param string $errorUrl Deprecated: use webhookEventsUrl() instead. - * If Gotenberg-Webhook-Events-Url is set, this - * parameter is not required. + * Adds ZIP archives that Gotenberg unpacks: each entry counts as an + * uploaded file. */ - public function webhook(string $url, string $errorUrl = ''): self + public function archives(Stream ...$archives): static { - $this->headers['Gotenberg-Webhook-Url'] = $url; - - if ($errorUrl !== '') { - $this->headers['Gotenberg-Webhook-Error-Url'] = $errorUrl; + foreach ($archives as $archive) { + $this->formFile($archive, 'archives'); } return $this; } /** - * Overrides the default HTTP method that Gotenberg will use to call the - * webhook. - * - * Either "POST", "PATCH", or "PUT" - default "POST". + * Uploads the result to $url instead of returning it, then sends a + * webhook.success or webhook.error event to $eventsUrl. */ - public function webhookMethod(string $method): self + public function webhook(string $url, string $eventsUrl): static { - $this->headers['Gotenberg-Webhook-Method'] = $method; + $this->headers['Gotenberg-Webhook-Url'] = $url; + $this->headers['Gotenberg-Webhook-Events-Url'] = $eventsUrl; return $this; } /** - * Overrides the default HTTP method that Gotenberg will use to call the - * error webhook. + * Overrides the default HTTP method that Gotenberg uses to upload the + * result to the webhook. * * Either "POST", "PATCH", or "PUT" - default "POST". - * - * @deprecated Use webhookEventsUrl() instead. */ - public function webhookErrorMethod(string $method): self + public function webhookMethod(string $method): static { - $this->headers['Gotenberg-Webhook-Error-Method'] = $method; + $this->headers['Gotenberg-Webhook-Method'] = $method; return $this; } /** - * Sets the extra HTTP headers that Gotenberg will send alongside the - * request to the webhook and error webhook. + * Sets the extra HTTP headers that Gotenberg sends alongside the upload + * and the event. * * @param array $headers - * - * @throws NativeFunctionErrored - */ - public function webhookExtraHttpHeaders(array $headers): self - { - $json = json_encode($headers); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->headers['Gotenberg-Webhook-Extra-Http-Headers'] = $json; - - return $this; - } - - /** - * Sets the URL that Gotenberg will use to send webhook event - * notifications (e.g., success/error events after webhook delivery). */ - public function webhookEventsUrl(string $url): self + public function webhookExtraHttpHeaders(array $headers): static { - $this->headers['Gotenberg-Webhook-Events-Url'] = $url; + $this->headers['Gotenberg-Webhook-Extra-Http-Headers'] = json_encode($headers, JSON_THROW_ON_ERROR); return $this; } - /** - * Configures watermarking on the resulting PDF(s). Call it once per - * watermark; watermarks are applied in the order they are added. Every field - * is sent for each watermark so the entries stay aligned by position. - * Provide $file for an "image" or "pdf" source. - * - * @param array $options - * - * @throws NativeFunctionErrored - */ - public function watermarking(string $source, string $expression = '', string $pages = '', array $options = [], Stream|null $file = null): self + protected function formValue(string $name, string|int|float|bool $value): void { - $json = ''; - if (count($options) > 0) { - $json = json_encode($options); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; } - $this->formValue('watermarkSource', $source); - $this->formValue('watermarkExpression', $expression); - $this->formValue('watermarkPages', $pages); - $this->formValue('watermarkOptions', $json); - - if ($file !== null) { - $this->formFile($file->getFilename(), $file->getStream(), 'watermark'); - } - - return $this; - } - - /** - * Adds watermark file. - */ - public function watermarkFile(Stream $file): self - { - $this->formFile($file->getFilename(), $file->getStream(), 'watermark'); - - return $this; - } - - /** - * Configures stamping on the resulting PDF(s). Call it once per stamp; - * stamps are applied in the order they are added. Every field is sent for - * each stamp so the entries stay aligned by position. Provide $file for an - * "image" or "pdf" source. - * - * @param array $options - * - * @throws NativeFunctionErrored - */ - public function stamping(string $source, string $expression = '', string $pages = '', array $options = [], Stream|null $file = null): self - { - $json = ''; - if (count($options) > 0) { - $json = json_encode($options); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - } - - $this->formValue('stampSource', $source); - $this->formValue('stampExpression', $expression); - $this->formValue('stampPages', $pages); - $this->formValue('stampOptions', $json); - - if ($file !== null) { - $this->formFile($file->getFilename(), $file->getStream(), 'stamp'); - } - - return $this; - } - - /** - * Adds stamp file. - */ - public function stampFile(Stream $file): self - { - $this->formFile($file->getFilename(), $file->getStream(), 'stamp'); - - return $this; - } - - /** - * Configures rotation on the resulting PDF(s). - * Only non-empty values are set. - */ - public function rotating(int $angle, string $pages = ''): self - { - $this->formValue('rotateAngle', $angle); - - if ($pages !== '') { - $this->formValue('rotatePages', $pages); - } - - return $this; - } - - protected function formValue(string $name, mixed $value): self - { $this->multipartFormData[] = [ 'name' => $name, - 'contents' => $value, + 'contents' => (string) $value, ]; - - return $this; } - protected function formFile(string $filename, StreamInterface $stream, string $name = 'files'): void + protected function formFile(Stream $file, string $name = 'files', string|null $filename = null): void { $this->multipartFormData[] = [ 'name' => $name, - 'filename' => $filename, - 'contents' => $stream, + 'filename' => $filename ?? $file->filename, + 'contents' => $file->stream, ]; } @@ -251,15 +120,8 @@ protected function request(string $method = 'POST'): RequestInterface { $body = new MultipartStream($this->multipartFormData); - $request = Psr17FactoryDiscovery::findRequestFactory() - ->createRequest($method, $this->url . $this->endpoint) + return $this->apiRequest($method) ->withHeader('Content-Type', 'multipart/form-data; boundary="' . $body->getBoundary() . '"') ->withBody($body); - - foreach ($this->headers as $key => $value) { - $request = $request->withHeader($key, $value); - } - - return $request; } } diff --git a/src/SplitMode.php b/src/SplitMode.php deleted file mode 100644 index 1b86637..0000000 --- a/src/SplitMode.php +++ /dev/null @@ -1,33 +0,0 @@ - + */ +final readonly class Stage implements JsonSerializable +{ + /** @param array $options */ + private function __construct(public string $op, public array $options = []) + { + } + + /** @param array $options */ + private static function of(string $op, array $options = []): self + { + return new self($op, array_filter($options, static fn (mixed $value): bool => $value !== null)); + } + + /** + * Appends the PDFs into one, in the order you pass them. + */ + public static function merge(): self + { + return self::of('merge'); + } + + /** + * Cuts each PDF every $every pages. The stages after it run on each part. + */ + public static function splitEvery(int $every): self + { + return self::of('split', ['every' => $every]); + } + + /** + * Cuts each PDF into one part per page selection, such as "1-3" or "4,6". + * The stages after it run on each part. + */ + public static function splitRanges(string ...$ranges): self + { + return self::of('split', ['ranges' => array_values($ranges)]); + } + + /** + * Keeps the pages you list, in the order you list them. + */ + public static function select(string $pages): self + { + return self::of('select', ['pages' => $pages]); + } + + /** + * Removes the pages you list. + */ + public static function delete(string $pages): self + { + return self::of('delete', ['pages' => $pages]); + } + + /** + * Inserts the pages of another PDF after page $at; 0 inserts before the + * first page. + */ + public static function insert(Stream $source, int|null $at = null, string|null $sourcePages = null): self + { + return self::of('insert', [ + 'source' => $source, + 'at' => $at, + 'sourcePages' => $sourcePages, + ]); + } + + /** + * Sets the rotation of pages, a multiple of 90. Viewers turn them + * clockwise. + */ + public static function rotate(int $angle, string|null $pages = null): self + { + return self::of('rotate', ['angle' => $angle, 'pages' => $pages]); + } + + /** + * Draws a line of text over pages. Positions are in points from the + * bottom-left corner, the color is "#rrggbb", and the font one of the 14 + * standard PDF fonts. + */ + public static function stampText( + string $text, + string|null $pages = null, + float|null $x = null, + float|null $y = null, + float|null $fontSize = null, + string|null $font = null, + string|null $color = null, + float|null $opacity = null, + float|null $rotation = null, + ): self { + return self::of('stamp-text', [ + 'text' => $text, + 'pages' => $pages, + 'x' => $x, + 'y' => $y, + 'fontSize' => $fontSize, + 'font' => $font, + 'color' => $color, + 'opacity' => $opacity, + 'rotation' => $rotation, + ]); + } + + /** + * Draws a PNG or JPEG image over pages. Positions and sizes are in points + * from the bottom-left corner. + */ + public static function stampImage( + Stream $image, + string|null $pages = null, + float|null $x = null, + float|null $y = null, + float|null $width = null, + float|null $height = null, + float|null $opacity = null, + float|null $rotation = null, + ): self { + return self::of('stamp-image', [ + 'image' => $image, + 'pages' => $pages, + 'x' => $x, + 'y' => $y, + 'width' => $width, + 'height' => $height, + 'opacity' => $opacity, + 'rotation' => $rotation, + ]); + } + + /** + * Draws a page of another PDF over pages: a letterhead, a seal. + */ + public static function stampPdf( + Stream $source, + int|null $sourcePage = null, + string|null $pages = null, + float|null $x = null, + float|null $y = null, + float|null $scale = null, + float|null $opacity = null, + float|null $rotation = null, + ): self { + return self::of('stamp-pdf', [ + 'source' => $source, + 'sourcePage' => $sourcePage, + 'pages' => $pages, + 'x' => $x, + 'y' => $y, + 'scale' => $scale, + 'opacity' => $opacity, + 'rotation' => $rotation, + ]); + } + + /** + * Writes the document information. A field left to null keeps its value. + * Dates are PDF dates, such as "D:20260101120000Z". + */ + public static function setMetadata( + string|null $title = null, + string|null $author = null, + string|null $subject = null, + string|null $keywords = null, + string|null $creator = null, + string|null $producer = null, + string|null $creationDate = null, + string|null $modDate = null, + ): self { + return self::of('set-metadata', [ + 'title' => $title, + 'author' => $author, + 'subject' => $subject, + 'keywords' => $keywords, + 'creator' => $creator, + 'producer' => $producer, + 'creationDate' => $creationDate, + 'modDate' => $modDate, + ]); + } + + /** + * Replaces the bookmarks. + * + * @param list>}> $bookmarks + */ + public static function setBookmarks(array $bookmarks): self + { + return self::of('set-bookmarks', ['bookmarks' => $bookmarks]); + } + + /** + * Fills form fields, keyed by their fully qualified names: a string, or a + * list of strings for a list that selects several. + * + * @param array> $fields + */ + public static function setForm(array $fields): self + { + return self::of('set-form', ['fields' => $fields]); + } + + /** + * Burns the form fields into the pages. + */ + public static function flattenForm(): self + { + return self::of('flatten-form'); + } + + /** + * Replaces transparent content with opaque content. + */ + public static function flattenTransparency(): self + { + return self::of('flatten-transparency'); + } + + /** + * Converts every color to "cmyk", "rgb", "gray", or "output-intent". + */ + public static function convertColors(string $target): self + { + return self::of('convert-colors', ['target' => $target]); + } + + /** + * Removes the content under each region from the file itself. Regions are + * in points from the bottom-left corner. + * + * @param list $regions + */ + public static function redact(array $regions): self + { + return self::of('redact', ['regions' => $regions]); + } + + /** + * Infers a structure tree for an untagged PDF. + */ + public static function autotag(): self + { + return self::of('autotag'); + } + + /** + * Embeds a file in the PDF. $relationship is "Source", "Data", + * "Alternative", "Supplement" or "Unspecified". + */ + public static function attach( + Stream $file, + string|null $name = null, + string|null $mimeType = null, + string|null $relationship = null, + ): self { + return self::of('attach', [ + 'file' => $file, + 'name' => $name, + 'mimeType' => $mimeType, + 'relationship' => $relationship, + ]); + } + + /** + * Removes an embedded file. + */ + public static function detach(string $name): self + { + return self::of('detach', ['name' => $name]); + } + + /** + * Rewrites the PDF to shrink it, without losing quality. Every option is + * on unless you turn it off. + */ + public static function optimize( + bool|null $objectStreams = null, + bool|null $dedup = null, + bool|null $recompressImages = null, + bool|null $subsetFonts = null, + ): self { + return self::of('optimize', [ + 'objectStreams' => $objectStreams, + 'dedup' => $dedup, + 'recompressImages' => $recompressImages, + 'subsetFonts' => $subsetFonts, + ]); + } + + /** + * Protects the PDF with passwords. $algorithm is "aes256", "aes128", + * "rc4-128" or "rc4-40". $permissions sets what a reader without the owner + * password may do: print, printHighQuality, modifyContent, extractText, + * modifyAnnotations, fillForms, assemble. + * + * @param array|null $permissions + */ + public static function encrypt( + string|null $userPassword = null, + string|null $ownerPassword = null, + string|null $algorithm = null, + array|null $permissions = null, + ): self { + return self::of('encrypt', [ + 'userPassword' => $userPassword, + 'ownerPassword' => $ownerPassword, + 'algorithm' => $algorithm, + 'permissions' => $permissions, + ]); + } + + /** + * Opens encrypted PDFs and removes their encryption. + */ + public static function decrypt(string|null $password = null): self + { + return self::of('decrypt', ['password' => $password]); + } + + /** + * Repairs the PDF until it conforms to a profile, such as "PDF/A-2b", + * "PDF/UA-1" or "Factur-X", or to a PDF/A profile and a PDF/UA one + * together. + * + * For Factur-X, $xml embeds an invoice XML, with its $level ("MINIMUM", + * "BASIC WL", "BASIC", "EN 16931", "EXTENDED" or "XRECHNUNG") and + * $documentType. + * + * @param string|list $profile + */ + public static function remediate( + string|array $profile, + string|null $language = null, + bool|null $aggressive = null, + string|null $figureAltText = null, + Stream|null $xml = null, + string|null $level = null, + string|null $documentType = null, + ): self { + return self::of('remediate', [ + 'profile' => $profile, + 'language' => $language, + 'aggressive' => $aggressive, + 'figureAltText' => $figureAltText, + 'xml' => $xml, + 'level' => $level, + 'documentType' => $documentType, + ]); + } + + /** + * Signs the PDF with a PKCS#12 certificate, or a PEM certificate and its + * $key. + */ + public static function sign( + Stream $certificate, + Stream|null $key = null, + string|null $certificatePassword = null, + string|null $reason = null, + string|null $location = null, + string|null $contactInfo = null, + string|null $name = null, + bool|null $pades = null, + string|null $timestampUrl = null, + ): self { + return self::of('sign', [ + 'certificate' => $certificate, + 'key' => $key, + 'certificatePassword' => $certificatePassword, + 'reason' => $reason, + 'location' => $location, + 'contactInfo' => $contactInfo, + 'name' => $name, + 'pades' => $pades, + 'timestampUrl' => $timestampUrl, + ]); + } + + /** + * Adds a document timestamp from an RFC 3161 timestamp authority. + */ + public static function timestamp(string $timestampUrl): self + { + return self::of('timestamp', ['timestampUrl' => $timestampUrl]); + } + + /** + * Draws pages as "png" or "jpeg" images. + */ + public static function render( + string|null $pages = null, + int|null $dpi = null, + string|null $format = null, + int|null $quality = null, + ): self { + return self::of('render', [ + 'pages' => $pages, + 'dpi' => $dpi, + 'format' => $format, + 'quality' => $quality, + ]); + } + + /** + * Returns the files the stage reads. Gotenberg expects them among the + * uploaded files. + * + * @return list + */ + public function files(): array + { + return array_values(array_filter($this->options, static fn (mixed $value): bool => $value instanceof Stream)); + } + + /** + * Returns the options as the form fields of the stage's own route: + * strings and numbers as is, booleans as true or false, lists and objects + * as JSON, and files by their names. + * + * @return array + */ + public function formFields(): array + { + return array_map(static function (mixed $value): string { + return match (true) { + $value instanceof Stream => $value->filename, + is_bool($value) => $value ? 'true' : 'false', + is_array($value) => json_encode($value, JSON_THROW_ON_ERROR), + default => (string) $value, + }; + }, $this->options); + } + + /** @return array */ + public function jsonSerialize(): array + { + $options = array_map( + static fn (mixed $value): mixed => $value instanceof Stream ? $value->filename : $value, + $this->options, + ); + + return ['op' => $this->op] + $options; + } +} diff --git a/src/StagesModule.php b/src/StagesModule.php new file mode 100644 index 0000000..931e561 --- /dev/null +++ b/src/StagesModule.php @@ -0,0 +1,78 @@ +index = $index; + + return $this; + } + + /** + * Sets the stages Gotenberg runs, in order, and uploads the files they + * read. + */ + public function stages(Stage ...$stages): static + { + $this->formValue('stages', json_encode(array_values($stages), JSON_THROW_ON_ERROR)); + + foreach ($stages as $stage) { + $this->stageFiles($stage); + } + + return $this; + } + + /** + * Uploads the files a stage reads, and records whether it merges. + */ + private function stageFiles(Stage $stage): void + { + if ($stage->op === 'merge') { + $this->merge = true; + } + + foreach ($stage->files() as $file) { + $this->formFile($file); + } + } + + /** + * Uploads the input files. With a merge stage, each name gets an index + * prefix, so that Gotenberg merges them in the order you pass them. + */ + private function inputFiles(Stream ...$files): void + { + $index = $this->index ?? new HrtimeIndex(); + + foreach ($files as $file) { + $this->formFile($file, filename: $this->merge ? $index->create() . '_' . $file->filename : null); + } + } + + abstract protected function formValue(string $name, string|int|float|bool $value): void; + + abstract protected function formFile(Stream $file, string $name = 'files', string|null $filename = null): void; +} diff --git a/src/Stream.php b/src/Stream.php index 1903dbd..f2486c3 100644 --- a/src/Stream.php +++ b/src/Stream.php @@ -4,50 +4,25 @@ namespace Gotenberg; -use Gotenberg\Exceptions\NativeFunctionErrored; use GuzzleHttp\Psr7\LazyOpenStream; use GuzzleHttp\Psr7\Utils; use Psr\Http\Message\StreamInterface; use function basename; -use function fopen; -use function fwrite; -class Stream +final readonly class Stream { public static function path(string $path, string|null $filename = null): self { - $filename ??= basename($path); - - return new self($filename, new LazyOpenStream($path, 'r')); + return new self($filename ?? basename($path), new LazyOpenStream($path, 'r')); } public static function string(string $filename, string $str): self { - $inmemory = fopen('php://memory', 'rb+'); - - if ($inmemory === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - if (fwrite($inmemory, $str) === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - return new self($filename, Utils::streamFor($inmemory)); - } - - public function __construct(private string $filename, private StreamInterface $stream) - { - } - - public function getFilename(): string - { - return $this->filename; + return new self($filename, Utils::streamFor($str)); } - public function getStream(): StreamInterface + public function __construct(public string $filename, public StreamInterface $stream) { - return $this->stream; } } diff --git a/tests/ApiModuleTest.php b/tests/ApiModuleTest.php index 413bef6..b5e7008 100644 --- a/tests/ApiModuleTest.php +++ b/tests/ApiModuleTest.php @@ -15,20 +15,12 @@ public function it_creates_a_valid_request_with_a_correlation_id_header(): void $dummy = new DummyApiModule('https://my.url/'); $request = $dummy ->correlationId('debug') + ->correlationId('debug', 'Request-Id') ->build(); $this->assertSame('https://my.url', $dummy->getUrl()); + $this->assertSame('https://my.url/foo', (string) $request->getUri()); $this->assertSame(['debug'], $request->getHeader('Gotenberg-Trace')); - } - - #[Test] - public function it_creates_a_valid_request_with_a_deprecated_trace_header(): void - { - $dummy = new DummyApiModule('https://my.url/'); - $request = $dummy - ->trace('debug') - ->build(); - - $this->assertSame(['debug'], $request->getHeader('Gotenberg-Trace')); + $this->assertSame(['debug'], $request->getHeader('Request-Id')); } } diff --git a/tests/EmbedMetadataTest.php b/tests/EmbedMetadataTest.php deleted file mode 100644 index 5720015..0000000 --- a/tests/EmbedMetadataTest.php +++ /dev/null @@ -1,64 +0,0 @@ -assertSame( - '{"mimeType":"text\/xml","relationship":"Data"}', - json_encode($metadata), - ); - } - - #[Test] - public function it_serializes_only_mime_type(): void - { - $metadata = new EmbedMetadata('embed_1.xml', 'text/xml'); - - $this->assertSame('{"mimeType":"text\/xml"}', json_encode($metadata)); - } - - #[Test] - public function it_serializes_only_relationship(): void - { - $metadata = new EmbedMetadata( - 'embed_1.xml', - null, - EmbedMetadata::RELATIONSHIP_ALTERNATIVE, - ); - - $this->assertSame('{"relationship":"Alternative"}', json_encode($metadata)); - } - - #[Test] - public function it_serializes_to_empty_when_no_optional_fields(): void - { - $metadata = new EmbedMetadata('embed_1.xml'); - - $this->assertSame('[]', json_encode($metadata)); - } - - #[Test] - public function it_exposes_filename_as_a_public_property(): void - { - $metadata = new EmbedMetadata('embed_1.xml', 'text/xml'); - - $this->assertSame('embed_1.xml', $metadata->filename); - } -} diff --git a/tests/FacturXTest.php b/tests/FacturXTest.php deleted file mode 100644 index 6211745..0000000 --- a/tests/FacturXTest.php +++ /dev/null @@ -1,42 +0,0 @@ -assertSame($xml, $facturX->xml); - $this->assertSame('EN 16931', $facturX->conformanceLevel); - $this->assertSame('ORDER', $facturX->documentType); - $this->assertSame('1.0', $facturX->version); - } - - #[Test] - public function it_defaults_document_type_and_version(): void - { - $facturX = new FacturX( - Stream::string('factur-x.xml', 'XML content'), - FacturX::CONFORMANCE_BASIC, - ); - - $this->assertSame('BASIC', $facturX->conformanceLevel); - $this->assertSame('INVOICE', $facturX->documentType); - $this->assertSame('1.0', $facturX->version); - } -} diff --git a/tests/GotenbergTest.php b/tests/GotenbergTest.php index bf7e6af..13b03f9 100644 --- a/tests/GotenbergTest.php +++ b/tests/GotenbergTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use function file_get_contents; use function sys_get_temp_dir; use function unlink; @@ -26,9 +27,7 @@ public function it_sends_a_request(): void $response = new Response(200, ['Gotenberg-Trace' => 'debug']); $client = new DummyClient($response); - $response = Gotenberg::send(new Request('POST', 'https://my.url'), $client); - - $this->assertNotNull($response); + $this->assertSame($response, Gotenberg::send(new Request('POST', 'https://my.url'), $client)); } #[Test] @@ -59,19 +58,30 @@ public static function provideTraceData(): array } #[Test] - public function it_saves_the_output_file(): void + #[DataProvider('provideFilenameData')] + public function it_saves_the_output_file(string $contentDisposition, string $expectedFilename): void { - $response = new Response(200, ['Content-Disposition' => 'attachment; filename=my.pdf']); + $response = new Response(200, ['Content-Disposition' => $contentDisposition], 'PDF content'); $client = new DummyClient($response); $tempDir = sys_get_temp_dir(); $filename = Gotenberg::save(new Request('POST', 'https://my.url'), $tempDir, $client); + $filePath = $tempDir . DIRECTORY_SEPARATOR . $expectedFilename; - $filePath = $tempDir . DIRECTORY_SEPARATOR . 'my.pdf'; - - // verify file exists and delete it + $this->assertSame($expectedFilename, $filename); + $this->assertSame('PDF content', file_get_contents($filePath)); $this->assertTrue(unlink($filePath)); - $this->assertSame('my.pdf', $filename); + } + + /** @return array */ + public static function provideFilenameData(): array + { + return [ + 'unquoted' => ['attachment; filename=my.pdf', 'my.pdf'], + 'quoted' => ['attachment; filename="my file.pdf"', 'my file.pdf'], + 'rfc 6266 preferred' => ["attachment; filename=\"facture-ete.pdf\"; filename*=UTF-8''facture-%C3%A9t%C3%A9.pdf", 'facture-été.pdf'], + 'directory dropped' => ['attachment; filename="../../my.pdf"', 'my.pdf'], + ]; } #[Test] @@ -92,6 +102,7 @@ public static function provideContentDispositionData(): array return [ 'without content disposition' => [null], 'with content disposition' => ['no attachment'], + 'with a dot dot filename' => ['attachment; filename=..'], ]; } } diff --git a/tests/Helpers/Dummies/DummyIndex.php b/tests/Helpers/Dummies/DummyIndex.php index 9b2d216..5a47ba7 100644 --- a/tests/Helpers/Dummies/DummyIndex.php +++ b/tests/Helpers/Dummies/DummyIndex.php @@ -8,8 +8,10 @@ final class DummyIndex implements Index { + private int $next = 0; + public function create(): string { - return 'foo'; + return (string) $this->next++; } } diff --git a/tests/Modules/ChromiumPdfTest.php b/tests/Modules/ChromiumPdfTest.php index b512dcd..c4f5361 100644 --- a/tests/Modules/ChromiumPdfTest.php +++ b/tests/Modules/ChromiumPdfTest.php @@ -4,1669 +4,156 @@ namespace Gotenberg\Test\Modules; -use Gotenberg\EmbedMetadata; -use Gotenberg\Exceptions\NativeFunctionErrored; -use Gotenberg\FacturX; use Gotenberg\Gotenberg; use Gotenberg\Modules\ChromiumCookie; -use Gotenberg\Modules\ChromiumEmulatedMediaFeatures; -use Gotenberg\Modules\ChromiumPdf; -use Gotenberg\SplitMode; +use Gotenberg\Stage; use Gotenberg\Stream; use Gotenberg\Test\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; - -use function count; -use function json_encode; +use Psr\Http\Message\RequestInterface; final class ChromiumPdfTest extends TestCase { /** - * @param ChromiumCookie[] $cookies - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains - * @param array> $metadata - * @param Stream[] $embeds - * @param EmbedMetadata[] $embedsMetadata - * @param Stream[] $assets - * @param array $watermarkOptions - * @param array $stampOptions + * @param list $values + * @param list $files */ #[Test] - #[DataProvider('provideUrlData')] - public function it_creates_a_valid_request_for_the_forms_chromium_convert_url_endpoint( - string $url, - bool $singlePage = false, - float|string|null $paperWidth = null, - float|string $paperHeight = 0, - float|string|null $marginTop = null, - float|string $marginBottom = 0, - float|string $marginLeft = 0, - float|string $marginRight = 0, - bool $preferCssPageSize = false, - bool $generateDocumentOutline = false, - bool $generateTaggedPdf = false, - bool $printBackground = false, - bool $omitBackground = false, - bool $landscape = false, - float|null $scale = null, - string|null $nativePageRanges = null, - Stream|null $header = null, - Stream|null $footer = null, - string|null $waitDelay = null, - string|null $waitForExpression = null, - string|null $waitForSelector = null, - string|null $emulatedMediaType = null, - array $emulatedMediaFeatures = [], - array $cookies = [], - string|null $userAgent = null, - array $extraHttpHeaders = [], - array $failOnHttpStatusCodes = [], - array $failOnResourceHttpStatusCodes = [], - array $ignoreResourceHttpStatusDomains = [], - bool $failOnResourceLoadingFailed = false, - bool $failOnConsoleExceptions = false, - bool|null $skipNetworkIdleEvent = null, - bool|null $skipNetworkAlmostIdleEvent = null, - SplitMode|null $splitMode = null, - string|null $pdfa = null, - bool $pdfua = false, - array $metadata = [], - bool $flatten = false, - string $userPassword = '', - string $ownerPassword = '', - array $embeds = [], - array $embedsMetadata = [], - array $assets = [], - string $watermarkSource = '', - string $watermarkExpression = '', - string $watermarkPages = '', - array $watermarkOptions = [], - string $stampSource = '', - string $stampExpression = '', - string $stampPages = '', - array $stampOptions = [], - int $rotateAngle = 0, - string $rotatePages = '', - bool $optimizeImages = false, - int|null $imageQuality = null, - ): void { - $chromium = Gotenberg::chromium('')->pdf(); - $chromium = $this->hydrateChromiumPdfFormData( - $chromium, - $singlePage, - $paperWidth, - $paperHeight, - $marginTop, - $marginBottom, - $marginLeft, - $marginRight, - $preferCssPageSize, - $generateDocumentOutline, - $generateTaggedPdf, - $printBackground, - $omitBackground, - $landscape, - $scale, - $nativePageRanges, - $header, - $footer, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $splitMode, - $pdfa, - $pdfua, - $metadata, - $flatten, - $userPassword, - $ownerPassword, - $embeds, - $embedsMetadata, - $assets, - $watermarkSource, - $watermarkExpression, - $watermarkPages, - $watermarkOptions, - $stampSource, - $stampExpression, - $stampPages, - $stampOptions, - $rotateAngle, - $rotatePages, - $optimizeImages, - $imageQuality, - ); - - $request = $chromium->url($url); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/chromium/convert/url', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'url', $url); - - $this->assertChromiumPdfOptions( - $body, - $singlePage, - $paperWidth, - $paperHeight, - $marginTop, - $marginBottom, - $marginLeft, - $marginRight, - $preferCssPageSize, - $generateDocumentOutline, - $generateTaggedPdf, - $printBackground, - $omitBackground, - $landscape, - $scale, - $nativePageRanges, - $header, - $footer, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $splitMode, - $pdfa, - $pdfua, - $metadata, - $flatten, - $userPassword, - $ownerPassword, - $embeds, - $embedsMetadata, - $assets, - $watermarkSource, - $watermarkExpression, - $watermarkPages, - $watermarkOptions, - $stampSource, - $stampExpression, - $stampPages, - $stampOptions, - $rotateAngle, - $rotatePages, - $optimizeImages, - $imageQuality, - ); + #[DataProvider('provideRequestData')] + public function it_creates_a_valid_request(RequestInterface $request, string $endpoint, array $values, array $files = []): void + { + $this->assertRequest($request, $endpoint, $values, $files); } - /** - * @return array, - * array, - * string|null, - * array, - * array, - * array, - * array, - * bool, - * bool, - * bool|null, - * bool|null, - * SplitMode|null, - * string|null, - * bool, - * array>, - * bool, - * string, - * string, - * array, - * array, - * array, - * string, - * string, - * string, - * array, - * string, - * string, - * string, - * array, - * bool, - * int|null - * }> - */ - public static function provideUrlData(): array + /** @return iterable, 3?: list}> */ + public static function provideRequestData(): iterable { - return [ - 'simple_url' => ['https://my.url'], - 'full_options' => [ - 'https://my.url', - true, - 8.27, - 11.7, - '192px', - 2, - 2, - 2, - true, - true, - true, - true, - true, - true, - 1.0, - '1-2', - Stream::string('my_header.html', 'Header content'), - Stream::string('my_footer.html', 'Footer content'), - '1s', - "window.status === 'ready'", - '#id', - 'print', - [ - new ChromiumEmulatedMediaFeatures('prefers-color-scheme', 'dark'), - new ChromiumEmulatedMediaFeatures('prefers-reduced-motion', 'reduce'), - ], - [ - new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com'), - new ChromiumCookie('vanilla_cookie', 'vanilla', 'theyummycookie.com', '/', true, true, 'Lax'), - ], - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)', - [ - 'My-Http-Header' => 'HTTP Header content', - 'My-Second-Http-Header' => 'Second HTTP Header content', - ], - [499, 599], - [499, 599], - ['my.com'], - true, - true, - true, - true, - SplitMode::intervals(1), - 'PDF/A-1a', - true, - ['Producer' => 'Gotenberg'], - true, - 'my_user_password', - 'my_owner_password', - [ - Stream::string('my.xml', 'XML content'), - Stream::string('my_second.xml', 'Second XML content'), - ], - [ - new EmbedMetadata('my.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_DATA), - new EmbedMetadata('my_second.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_ALTERNATIVE), - ], - [ - Stream::string('my.jpg', 'Image content'), - ], - 'my_watermark_source', - 'my_watermark_expression', - '1-2', - ['key' => 'value'], - 'my_stamp_source', - 'my_stamp_expression', - '3-4', - ['key' => 'value'], - 90, - '1-3', - true, - 60, + yield 'url with every option' => [ + Gotenberg::chromium('https://my.url/') + ->pdf() + ->singlePage() + ->paperSize('A4') + ->paperDimensions(8.27, '11.7in') + ->margins(1, 2, '3mm', 4) + ->preferCssPageSize() + ->nativeBookmarks() + ->nativeTaggedPdf() + ->printBackground() + ->landscape() + ->scale(1.5) + ->nativePages('1-5, 8') + ->header(Stream::string('my_header.html', 'Header content')) + ->footer(Stream::string('my_footer.html', 'Footer content')) + ->omitBackground() + ->waitDelay('3s') + ->waitForExpression("window.status === 'ready'") + ->waitForSelector('#id') + ->waitForWindowStatus('ready') + ->emulatePrintMediaType() + ->emulatedMediaFeatures(['prefers-color-scheme' => 'dark']) + ->cookies(new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com', secure: true, sameSite: 'Lax')) + ->userAgent('Gotenberg') + ->extraHttpHeaders(['My-Http-Header' => 'HTTP header content']) + ->failOnHttpStatusCodes(499, 599) + ->failOnResourceHttpStatusCodes(404) + ->ignoreResourceHttpStatusDomains('example.com') + ->failOnResourceLoadingFailed() + ->failOnConsoleExceptions() + ->failOnConsoleMessageTypes('error', 'warning') + ->skipNetworkIdleEvent(false) + ->skipNetworkAlmostIdleEvent() + ->assets(Stream::string('my.jpg', 'Image content')) + ->url('https://my.url'), + '/forms/chromium/convert/url', + [ + ['url', 'https://my.url'], + ['singlePage', 'true'], + ['paperSize', 'A4'], + ['paperWidth', '8.27'], + ['paperHeight', '11.7in'], + ['marginTop', '1'], + ['marginBottom', '2'], + ['marginLeft', '3mm'], + ['marginRight', '4'], + ['preferCssPageSize', 'true'], + ['nativeBookmarks', 'true'], + ['nativeTaggedPdf', 'true'], + ['printBackground', 'true'], + ['landscape', 'true'], + ['scale', '1.5'], + ['nativePages', '1-5, 8'], + ['omitBackground', 'true'], + ['waitDelay', '3s'], + ['waitForExpression', "window.status === 'ready'"], + ['waitForSelector', '#id'], + ['waitForWindowStatus', 'ready'], + ['emulatedMediaType', 'print'], + ['emulatedMediaFeatures', '{"prefers-color-scheme":"dark"}'], + ['cookies', '[{"name":"yummy_cookie","value":"choco","domain":"theyummycookie.com","secure":true,"sameSite":"Lax"}]'], + ['userAgent', 'Gotenberg'], + ['extraHttpHeaders', '{"My-Http-Header":"HTTP header content"}'], + ['failOnHttpStatusCodes', '[499,599]'], + ['failOnResourceHttpStatusCodes', '[404]'], + ['ignoreResourceHttpStatusDomains', '["example.com"]'], + ['failOnResourceLoadingFailed', 'true'], + ['failOnConsoleExceptions', 'true'], + ['failOnConsoleMessageTypes', '["error","warning"]'], + ['skipNetworkIdleEvent', 'false'], + ['skipNetworkAlmostIdleEvent', 'true'], + ], + [ + ['files', 'header.html', 'Header content', 'text/html'], + ['files', 'footer.html', 'Footer content', 'text/html'], + ['files', 'my.jpg', 'Image content', 'image/jpeg'], ], ]; - } - /** - * @param ChromiumCookie[] $cookies - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains - * @param array> $metadata - * @param Stream[] $embeds - * @param EmbedMetadata[] $embedsMetadata - * @param Stream[] $assets - * @param array $watermarkOptions - * @param array $stampOptions - */ - #[Test] - #[DataProvider('provideHtmlData')] - public function it_creates_a_valid_request_for_the_forms_chromium_convert_html_endpoint( - Stream $index, - bool $singlePage = false, - float|string|null $paperWidth = null, - float|string $paperHeight = 0, - float|string|null $marginTop = null, - float|string $marginBottom = 0, - float|string $marginLeft = 0, - float|string $marginRight = 0, - bool $preferCssPageSize = false, - bool $generateDocumentOutline = false, - bool $generateTaggedPdf = false, - bool $printBackground = false, - bool $omitBackground = false, - bool $landscape = false, - float|null $scale = null, - string|null $nativePageRanges = null, - Stream|null $header = null, - Stream|null $footer = null, - string|null $waitDelay = null, - string|null $waitForExpression = null, - string|null $waitForSelector = null, - string|null $emulatedMediaType = null, - array $emulatedMediaFeatures = [], - array $cookies = [], - string|null $userAgent = null, - array $extraHttpHeaders = [], - array $failOnHttpStatusCodes = [], - array $failOnResourceHttpStatusCodes = [], - array $ignoreResourceHttpStatusDomains = [], - bool $failOnResourceLoadingFailed = false, - bool $failOnConsoleExceptions = false, - bool|null $skipNetworkIdleEvent = null, - bool|null $skipNetworkAlmostIdleEvent = null, - SplitMode|null $splitMode = null, - string|null $pdfa = null, - bool $pdfua = false, - array $metadata = [], - bool $flatten = false, - string $userPassword = '', - string $ownerPassword = '', - array $embeds = [], - array $embedsMetadata = [], - array $assets = [], - string $watermarkSource = '', - string $watermarkExpression = '', - string $watermarkPages = '', - array $watermarkOptions = [], - string $stampSource = '', - string $stampExpression = '', - string $stampPages = '', - array $stampOptions = [], - int $rotateAngle = 0, - string $rotatePages = '', - bool $optimizeImages = false, - int|null $imageQuality = null, - ): void { - $chromium = Gotenberg::chromium('')->pdf(); - $chromium = $this->hydrateChromiumPdfFormData( - $chromium, - $singlePage, - $paperWidth, - $paperHeight, - $marginTop, - $marginBottom, - $marginLeft, - $marginRight, - $preferCssPageSize, - $generateDocumentOutline, - $generateTaggedPdf, - $printBackground, - $omitBackground, - $landscape, - $scale, - $nativePageRanges, - $header, - $footer, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $splitMode, - $pdfa, - $pdfua, - $metadata, - $flatten, - $userPassword, - $ownerPassword, - $embeds, - $embedsMetadata, - $assets, - $watermarkSource, - $watermarkExpression, - $watermarkPages, - $watermarkOptions, - $stampSource, - $stampExpression, - $stampPages, - $stampOptions, - $rotateAngle, - $rotatePages, - $optimizeImages, - $imageQuality, - ); - - $request = $chromium->html($index); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/chromium/convert/html', $request->getUri()->getPath()); - - $index->getStream()->rewind(); - $this->assertContainsFormFile($body, 'index.html', $index->getStream()->getContents(), 'text/html'); - - $this->assertChromiumPdfOptions( - $body, - $singlePage, - $paperWidth, - $paperHeight, - $marginTop, - $marginBottom, - $marginLeft, - $marginRight, - $preferCssPageSize, - $generateDocumentOutline, - $generateTaggedPdf, - $printBackground, - $omitBackground, - $landscape, - $scale, - $nativePageRanges, - $header, - $footer, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $splitMode, - $pdfa, - $pdfua, - $metadata, - $flatten, - $userPassword, - $ownerPassword, - $embeds, - $embedsMetadata, - $assets, - $watermarkSource, - $watermarkExpression, - $watermarkPages, - $watermarkOptions, - $stampSource, - $stampExpression, - $stampPages, - $stampOptions, - $rotateAngle, - $rotatePages, - $optimizeImages, - $imageQuality, - ); - } - - /** - * @return array, - * array, - * string|null, - * array, - * array, - * array, - * array, - * bool, - * bool, - * bool|null, - * bool|null, - * SplitMode|null, - * string|null, - * bool, - * array>, - * bool, - * string, - * string, - * array, - * array, - * array, - * string, - * string, - * string, - * array, - * string, - * string, - * string, - * array, - * bool, - * int|null - * }> - */ - public static function provideHtmlData(): array - { - return [ - 'simple_html' => [Stream::string('my.html', 'HTML content')], - 'full_options' => [ - Stream::string('my.html', 'HTML content'), - true, - 8.27, - 11.7, - '192px', - 2, - 2, - 2, - true, - true, - true, - true, - true, - true, - 1.0, - '1-2', - Stream::string('my_header.html', 'Header content'), - Stream::string('my_footer.html', 'Footer content'), - '1s', - "window.status === 'ready'", - '#id', - 'screen', - [ - new ChromiumEmulatedMediaFeatures('prefers-color-scheme', 'dark'), - new ChromiumEmulatedMediaFeatures('prefers-reduced-motion', 'reduce'), - ], - [ - new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com'), - new ChromiumCookie('vanilla_cookie', 'vanilla', 'theyummycookie.com', '/', true, true, 'Lax'), - ], - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)', - [ - 'My-Http-Header' => 'Http Header content', - 'My-Second-Http-Header' => 'Second Http Header content', - ], - [499, 599], - [499, 599], - ['my.com'], - true, - true, - true, - true, - SplitMode::intervals(1), - 'PDF/A-1a', - true, - ['Producer' => 'Gotenberg'], - true, - 'my_user_password', - 'my_owner_password', - [ - Stream::string('my.xml', 'XML content'), - Stream::string('my_second.xml', 'Second XML content'), - ], - [ - new EmbedMetadata('my.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_DATA), - new EmbedMetadata('my_second.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_ALTERNATIVE), - ], - [ - Stream::string('my.jpg', 'Image content'), - ], - 'my_watermark_source', - 'my_watermark_expression', - '1-2', - ['key' => 'value'], - 'my_stamp_source', - 'my_stamp_expression', - '3-4', - ['key' => 'value'], - 180, - '1-2', - true, - 60, + yield 'html with stages' => [ + Gotenberg::chromium('https://my.url') + ->pdf() + ->emulateScreenMediaType() + ->stages( + Stage::stampImage(Stream::string('logo.png', 'Logo content'), x: 72, y: 72), + Stage::optimize(), + ) + ->html(Stream::string('my.html', 'HTML content')), + '/forms/chromium/convert/html', + [ + ['emulatedMediaType', 'screen'], + ['stages', '[{"op":"stamp-image","image":"logo.png","x":72,"y":72},{"op":"optimize"}]'], + ], + [ + ['files', 'index.html', 'HTML content', 'text/html'], + ['files', 'logo.png', 'Logo content', 'image/png'], ], ]; - } - - /** - * @param ChromiumCookie[] $cookies - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains - * @param Stream[] $markdowns - * @param array> $metadata - * @param Stream[] $embeds - * @param EmbedMetadata[] $embedsMetadata - * @param Stream[] $assets - * @param array $watermarkOptions - * @param array $stampOptions - */ - #[Test] - #[DataProvider('provideMarkdownData')] - public function it_creates_a_valid_request_for_the_forms_chromium_convert_markdown_endpoint( - Stream $index, - array $markdowns, - bool $singlePage = false, - float|string|null $paperWidth = null, - float|string $paperHeight = 0, - float|string|null $marginTop = null, - float|string $marginBottom = 0, - float|string $marginLeft = 0, - float|string $marginRight = 0, - bool $preferCssPageSize = false, - bool $generateDocumentOutline = false, - bool $generateTaggedPdf = false, - bool $printBackground = false, - bool $omitBackground = false, - bool $landscape = false, - float|null $scale = null, - string|null $nativePageRanges = null, - Stream|null $header = null, - Stream|null $footer = null, - string|null $waitDelay = null, - string|null $waitForExpression = null, - string|null $waitForSelector = null, - string|null $emulatedMediaType = null, - array $emulatedMediaFeatures = [], - array $cookies = [], - string|null $userAgent = null, - array $extraHttpHeaders = [], - array $failOnHttpStatusCodes = [], - array $failOnResourceHttpStatusCodes = [], - array $ignoreResourceHttpStatusDomains = [], - bool $failOnResourceLoadingFailed = false, - bool $failOnConsoleExceptions = false, - bool|null $skipNetworkIdleEvent = null, - bool|null $skipNetworkAlmostIdleEvent = null, - SplitMode|null $splitMode = null, - string|null $pdfa = null, - bool $pdfua = false, - array $metadata = [], - bool $flatten = false, - string $userPassword = '', - string $ownerPassword = '', - array $embeds = [], - array $embedsMetadata = [], - array $assets = [], - string $watermarkSource = '', - string $watermarkExpression = '', - string $watermarkPages = '', - array $watermarkOptions = [], - string $stampSource = '', - string $stampExpression = '', - string $stampPages = '', - array $stampOptions = [], - int $rotateAngle = 0, - string $rotatePages = '', - bool $optimizeImages = false, - int|null $imageQuality = null, - ): void { - $chromium = Gotenberg::chromium('')->pdf(); - $chromium = $this->hydrateChromiumPdfFormData( - $chromium, - $singlePage, - $paperWidth, - $paperHeight, - $marginTop, - $marginBottom, - $marginLeft, - $marginRight, - $preferCssPageSize, - $generateDocumentOutline, - $generateTaggedPdf, - $printBackground, - $omitBackground, - $landscape, - $scale, - $nativePageRanges, - $header, - $footer, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $splitMode, - $pdfa, - $pdfua, - $metadata, - $flatten, - $userPassword, - $ownerPassword, - $embeds, - $embedsMetadata, - $assets, - $watermarkSource, - $watermarkExpression, - $watermarkPages, - $watermarkOptions, - $stampSource, - $stampExpression, - $stampPages, - $stampOptions, - $rotateAngle, - $rotatePages, - $optimizeImages, - $imageQuality, - ); - - $request = $chromium->markdown($index, ...$markdowns); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/chromium/convert/markdown', $request->getUri()->getPath()); - - $index->getStream()->rewind(); - $this->assertContainsFormFile($body, 'index.html', $index->getStream()->getContents(), 'text/html'); - - foreach ($markdowns as $markdown) { - $markdown->getStream()->rewind(); - $this->assertContainsFormFile($body, $markdown->getFilename(), $markdown->getStream()->getContents()); - } - $this->assertChromiumPdfOptions( - $body, - $singlePage, - $paperWidth, - $paperHeight, - $marginTop, - $marginBottom, - $marginLeft, - $marginRight, - $preferCssPageSize, - $generateDocumentOutline, - $generateTaggedPdf, - $printBackground, - $omitBackground, - $landscape, - $scale, - $nativePageRanges, - $header, - $footer, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $splitMode, - $pdfa, - $pdfua, - $metadata, - $flatten, - $userPassword, - $ownerPassword, - $embeds, - $embedsMetadata, - $assets, - $watermarkSource, - $watermarkExpression, - $watermarkPages, - $watermarkOptions, - $stampSource, - $stampExpression, - $stampPages, - $stampOptions, - $rotateAngle, - $rotatePages, - $optimizeImages, - $imageQuality, - ); - } + yield 'html from an archive' => [ + Gotenberg::chromium('https://my.url') + ->pdf() + ->archives(Stream::string('site.zip', 'ZIP content')) + ->html(null), + '/forms/chromium/convert/html', + [], + [['archives', 'site.zip', 'ZIP content']], + ]; - /** - * @return array, - * bool, - * float|string|null, - * float|string, - * float|string|null, - * float|string, - * float|string, - * float|string, - * bool, - * bool, - * bool, - * bool, - * bool, - * bool, - * float|null, - * string|null, - * Stream|null, - * Stream|null, - * string|null, - * string|null, - * string|null, - * string|null, - * array, - * array, - * string|null, - * array, - * array, - * array, - * array, - * bool, - * bool, - * bool|null, - * bool|null, - * SplitMode|null, - * string|null, - * bool, - * array>, - * bool, - * string, - * string, - * array, - * array, - * array, - * string, - * string, - * string, - * array, - * string, - * string, - * string, - * array, - * bool, - * int|null - * }> - */ - public static function provideMarkdownData(): array - { - return [ - 'simple_markdown' => [ - Stream::string('my.html', 'HTML content'), - [ + yield 'markdown' => [ + Gotenberg::chromium('https://my.url') + ->pdf() + ->markdown( + Stream::string('my.html', 'HTML content'), Stream::string('my.md', 'Markdown content'), - ], - ], - 'full_options' => [ - Stream::string('my.html', 'HTML content'), - [ - Stream::string('my.md', 'Markdown content'), - Stream::string('my_second.md', 'Second Markdown content'), - ], - true, - 8.27, - 11.7, - '192px', - 2, - 2, - 2, - true, - true, - true, - true, - true, - true, - 1.0, - '1-2', - Stream::string('my_header.html', 'Header content'), - Stream::string('my_footer.html', 'Footer content'), - '1s', - "window.status === 'ready'", - '#id', - 'screen', - [ - new ChromiumEmulatedMediaFeatures('prefers-color-scheme', 'dark'), - new ChromiumEmulatedMediaFeatures('prefers-reduced-motion', 'reduce'), - ], - [ - new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com'), - new ChromiumCookie('vanilla_cookie', 'vanilla', 'theyummycookie.com', '/', true, true, 'Lax'), - ], - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)', - [ - 'My-Http-Header' => 'Http Header content', - 'My-Second-Http-Header' => 'Second Http Header content', - ], - [499, 599], - [499, 599], - ['my.com'], - true, - true, - true, - true, - SplitMode::intervals(1), - 'PDF/A-1a', - true, - ['Producer' => 'Gotenberg'], - true, - 'my_user_password', - 'my_owner_password', - [ - Stream::string('my.xml', 'XML content'), - Stream::string('my_second.xml', 'Second XML content'), - ], - [ - new EmbedMetadata('my.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_DATA), - new EmbedMetadata('my_second.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_ALTERNATIVE), - ], - [ - Stream::string('my.jpg', 'Image content'), - ], - 'my_watermark_source', - 'my_watermark_expression', - '1-2', - ['key' => 'value'], - 'my_stamp_source', - 'my_stamp_expression', - '3-4', - ['key' => 'value'], - 270, - '1-5', - true, - 60, + Stream::string('my_second.md', 'Second markdown content'), + ), + '/forms/chromium/convert/markdown', + [], + [ + ['files', 'index.html', 'HTML content', 'text/html'], + ['files', 'my.md', 'Markdown content'], + ['files', 'my_second.md', 'Second markdown content'], ], ]; } - - /** - * @param ChromiumCookie[] $cookies - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains - * @param array> $metadata - * @param Stream[] $embeds - * @param EmbedMetadata[] $embedsMetadata - * @param Stream[] $assets - * @param array $watermarkOptions - * @param array $stampOptions - */ - private function hydrateChromiumPdfFormData( - ChromiumPdf $chromium, - bool $singlePage = false, - float|string|null $paperWidth = null, - float|string $paperHeight = 0, - float|string|null $marginTop = null, - float|string $marginBottom = 0, - float|string $marginLeft = 0, - float|string $marginRight = 0, - bool $preferCssPageSize = false, - bool $generateDocumentOutline = false, - bool $generateTaggedPdf = false, - bool $printBackground = false, - bool $omitBackground = false, - bool $landscape = false, - float|null $scale = null, - string|null $nativePageRanges = null, - Stream|null $header = null, - Stream|null $footer = null, - string|null $waitDelay = null, - string|null $waitForExpression = null, - string|null $waitForSelector = null, - string|null $emulatedMediaType = null, - array $emulatedMediaFeatures = [], - array $cookies = [], - string|null $userAgent = null, - array $extraHttpHeaders = [], - array $failOnHttpStatusCodes = [], - array $failOnResourceHttpStatusCodes = [], - array $ignoreResourceHttpStatusDomains = [], - bool $failOnResourceLoadingFailed = false, - bool $failOnConsoleExceptions = false, - bool|null $skipNetworkIdleEvent = null, - bool|null $skipNetworkAlmostIdleEvent = null, - SplitMode|null $splitMode = null, - string|null $pdfa = null, - bool $pdfua = false, - array $metadata = [], - bool $flatten = false, - string $userPassword = '', - string $ownerPassword = '', - array $embeds = [], - array $embedsMetadata = [], - array $assets = [], - string $watermarkSource = '', - string $watermarkExpression = '', - string $watermarkPages = '', - array $watermarkOptions = [], - string $stampSource = '', - string $stampExpression = '', - string $stampPages = '', - array $stampOptions = [], - int $rotateAngle = 0, - string $rotatePages = '', - bool $optimizeImages = false, - int|null $imageQuality = null, - ): ChromiumPdf { - if ($singlePage) { - $chromium->singlePage(); - } - - if ($paperWidth !== null) { - $chromium->paperSize($paperWidth, $paperHeight); - } - - if ($marginTop !== null) { - $chromium->margins($marginTop, $marginBottom, $marginLeft, $marginRight); - } - - if ($preferCssPageSize) { - $chromium->preferCssPageSize(); - } - - if ($generateDocumentOutline) { - $chromium->generateDocumentOutline(); - } - - if ($generateTaggedPdf) { - $chromium->generateTaggedPdf(); - } - - if ($printBackground) { - $chromium->printBackground(); - } - - if ($omitBackground) { - $chromium->omitBackground(); - } - - if ($landscape) { - $chromium->landscape(); - } - - if ($scale !== null) { - $chromium->scale($scale); - } - - if ($nativePageRanges !== null) { - $chromium->nativePageRanges($nativePageRanges); - } - - if ($header !== null) { - $chromium->header($header); - } - - if ($footer !== null) { - $chromium->footer($footer); - } - - if ($waitDelay !== null) { - $chromium->waitDelay($waitDelay); - } - - if ($waitForExpression !== null) { - $chromium->waitForExpression($waitForExpression); - } - - if ($waitForSelector !== null) { - $chromium->waitForSelector($waitForSelector); - } - - if ($emulatedMediaType === 'print') { - $chromium->emulatePrintMediaType(); - } - - if ($emulatedMediaType === 'screen') { - $chromium->emulateScreenMediaType(); - } - - if (count($emulatedMediaFeatures) > 0) { - $chromium->emulatedMediaFeatures($emulatedMediaFeatures); - } - - if (count($cookies) > 0) { - $chromium->cookies($cookies); - } - - if ($userAgent !== null) { - $chromium->userAgent($userAgent); - } - - if (count($extraHttpHeaders) > 0) { - $chromium->extraHttpHeaders($extraHttpHeaders); - } - - if (count($failOnHttpStatusCodes) > 0) { - $chromium->failOnHttpStatusCodes($failOnHttpStatusCodes); - } - - if (count($failOnResourceHttpStatusCodes) > 0) { - $chromium->failOnResourceHttpStatusCodes($failOnResourceHttpStatusCodes); - } - - if (count($ignoreResourceHttpStatusDomains) > 0) { - $chromium->ignoreResourceHttpStatusDomains($ignoreResourceHttpStatusDomains); - } - - if ($failOnResourceLoadingFailed) { - $chromium->failOnResourceLoadingFailed(); - } - - if ($failOnConsoleExceptions) { - $chromium->failOnConsoleExceptions(); - } - - if ($skipNetworkIdleEvent !== null) { - $chromium->skipNetworkIdleEvent($skipNetworkIdleEvent); - } - - if ($skipNetworkAlmostIdleEvent !== null) { - $chromium->skipNetworkAlmostIdleEvent($skipNetworkAlmostIdleEvent); - } - - if ($splitMode !== null) { - $chromium->split($splitMode); - } - - if ($pdfa !== null) { - $chromium->pdfa($pdfa); - } - - if ($pdfua) { - $chromium->pdfua(); - } - - if (count($metadata) > 0) { - $chromium->metadata($metadata); - } - - if ($flatten) { - $chromium->flatten(); - } - - if ($userPassword !== '') { - $chromium->encrypt($userPassword, $ownerPassword); - } - - if (count($embeds) > 0) { - $chromium->embeds(...$embeds); - } - - if (count($embedsMetadata) > 0) { - $chromium->embedsMetadata(...$embedsMetadata); - } - - if (count($assets) > 0) { - $chromium->assets(...$assets); - } - - if ($watermarkSource !== '') { - $chromium->watermarking($watermarkSource, $watermarkExpression, $watermarkPages, $watermarkOptions); - } - - if ($stampSource !== '') { - $chromium->stamping($stampSource, $stampExpression, $stampPages, $stampOptions); - } - - if ($rotateAngle !== 0) { - $chromium->rotating($rotateAngle, $rotatePages); - } - - if ($optimizeImages) { - $chromium->optimizeImages($imageQuality ?? 80); - } - - return $chromium; - } - - /** - * @param ChromiumCookie[] $cookies - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains - * @param array> $metadata - * @param Stream[] $embeds - * @param EmbedMetadata[] $embedsMetadata - * @param Stream[] $assets - * @param array $watermarkOptions - * @param array $stampOptions - */ - private function assertChromiumPdfOptions( - string $body, - bool $singlePage, - float|string|null $paperWidth, - float|string $paperHeight, - float|string|null $marginTop, - float|string $marginBottom, - float|string $marginLeft, - float|string $marginRight, - bool $preferCssPageSize, - bool $generateDocumentOutline, - bool $generateTaggedPdf, - bool $printBackground, - bool $omitBackground, - bool $landscape, - float|null $scale, - string|null $nativePageRanges, - Stream|null $header, - Stream|null $footer, - string|null $waitDelay, - string|null $waitForExpression, - string|null $waitForSelector, - string|null $emulatedMediaType, - array $emulatedMediaFeatures, - array $cookies, - string|null $userAgent, - array $extraHttpHeaders, - array $failOnHttpStatusCodes, - array $failOnResourceHttpStatusCodes, - array $ignoreResourceHttpStatusDomains, - bool $failOnResourceLoadingFailed, - bool $failOnConsoleExceptions, - bool|null $skipNetworkIdleEvent, - bool|null $skipNetworkAlmostIdleEvent, - SplitMode|null $splitMode, - string|null $pdfa, - bool $pdfua, - array $metadata, - bool $flatten, - string $userPassword, - string $ownerPassword, - array $embeds, - array $embedsMetadata, - array $assets, - string $watermarkSource = '', - string $watermarkExpression = '', - string $watermarkPages = '', - array $watermarkOptions = [], - string $stampSource = '', - string $stampExpression = '', - string $stampPages = '', - array $stampOptions = [], - int $rotateAngle = 0, - string $rotatePages = '', - bool $optimizeImages = false, - int|null $imageQuality = null, - ): void { - if ($singlePage) { - $this->assertContainsFormValue($body, 'singlePage', '1'); - } - - if ($paperWidth !== null) { - $this->assertContainsFormValue($body, 'paperWidth', (string) $paperWidth); - $this->assertContainsFormValue($body, 'paperHeight', (string) $paperHeight); - } - - if ($marginTop !== null) { - $this->assertContainsFormValue($body, 'marginTop', (string) $marginTop); - $this->assertContainsFormValue($body, 'marginBottom', (string) $marginBottom); - $this->assertContainsFormValue($body, 'marginLeft', (string) $marginLeft); - $this->assertContainsFormValue($body, 'marginRight', (string) $marginRight); - } - - if ($preferCssPageSize) { - $this->assertContainsFormValue($body, 'preferCssPageSize', '1'); - } - - if ($generateDocumentOutline) { - $this->assertContainsFormValue($body, 'generateDocumentOutline', '1'); - } - - if ($generateTaggedPdf) { - $this->assertContainsFormValue($body, 'generateTaggedPdf', '1'); - } - - if ($printBackground) { - $this->assertContainsFormValue($body, 'printBackground', '1'); - } - - if ($omitBackground) { - $this->assertContainsFormValue($body, 'omitBackground', '1'); - } - - if ($landscape) { - $this->assertContainsFormValue($body, 'landscape', '1'); - } - - if ($scale !== null) { - $this->assertContainsFormValue($body, 'scale', (string) $scale); - } - - if ($nativePageRanges !== null) { - $this->assertContainsFormValue($body, 'nativePageRanges', $nativePageRanges); - } - - if ($header !== null) { - $header->getStream()->rewind(); - $this->assertContainsFormFile($body, 'header.html', $header->getStream()->getContents(), 'text/html'); - } - - if ($footer !== null) { - $footer->getStream()->rewind(); - $this->assertContainsFormFile($body, 'footer.html', $footer->getStream()->getContents(), 'text/html'); - } - - if ($waitDelay !== null) { - $this->assertContainsFormValue($body, 'waitDelay', $waitDelay); - } - - if ($waitForExpression !== null) { - $this->assertContainsFormValue($body, 'waitForExpression', $waitForExpression); - } - - if ($waitForSelector !== null) { - $this->assertContainsFormValue($body, 'waitForSelector', $waitForSelector); - } - - if ($emulatedMediaType !== null) { - $this->assertContainsFormValue($body, 'emulatedMediaType', $emulatedMediaType); - } - - if (count($emulatedMediaFeatures) > 0) { - $json = json_encode($emulatedMediaFeatures); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'emulatedMediaFeatures', $json); - } - - if (count($cookies) > 0) { - $json = json_encode($cookies); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'cookies', $json); - } - - if ($userAgent !== null) { - $this->assertContainsFormValue($body, 'userAgent', $userAgent); - } - - if (count($extraHttpHeaders) > 0) { - $json = json_encode($extraHttpHeaders); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'extraHttpHeaders', $json); - } - - if (count($failOnHttpStatusCodes) > 0) { - $json = json_encode($failOnHttpStatusCodes); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'failOnHttpStatusCodes', $json); - } - - if (count($failOnResourceHttpStatusCodes) > 0) { - $json = json_encode($failOnResourceHttpStatusCodes); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'failOnResourceHttpStatusCodes', $json); - } - - if (count($ignoreResourceHttpStatusDomains) > 0) { - $json = json_encode($ignoreResourceHttpStatusDomains); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'ignoreResourceHttpStatusDomains', $json); - } - - if ($failOnResourceLoadingFailed) { - $this->assertContainsFormValue($body, 'failOnResourceLoadingFailed', '1'); - } - - if ($failOnConsoleExceptions) { - $this->assertContainsFormValue($body, 'failOnConsoleExceptions', '1'); - } - - if ($skipNetworkIdleEvent !== null) { - $this->assertContainsFormValue($body, 'skipNetworkIdleEvent', $skipNetworkIdleEvent ? '1' : '0'); - } - - if ($skipNetworkAlmostIdleEvent !== null) { - $this->assertContainsFormValue($body, 'skipNetworkAlmostIdleEvent', $skipNetworkAlmostIdleEvent ? '1' : '0'); - } - - if ($splitMode !== null) { - $this->assertContainsFormValue($body, 'splitMode', $splitMode->mode); - $this->assertContainsFormValue($body, 'splitSpan', $splitMode->span); - $this->assertContainsFormValue($body, 'splitUnify', $splitMode->unify ? '1' : '0'); - } - - if ($pdfa !== null) { - $this->assertContainsFormValue($body, 'pdfa', $pdfa); - } - - if ($pdfua) { - $this->assertContainsFormValue($body, 'pdfua', '1'); - } - - if (count($metadata) > 0) { - $json = json_encode($metadata); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'metadata', $json); - } - - if ($flatten) { - $this->assertContainsFormValue($body, 'flatten', '1'); - } - - if ($userPassword !== '') { - $this->assertContainsFormValue($body, 'userPassword', $userPassword); - $this->assertContainsFormValue($body, 'ownerPassword', $ownerPassword); - } - - foreach ($embeds as $embed) { - $embed->getStream()->rewind(); - $this->assertContainsFormFile($body, $embed->getFilename(), $embed->getStream()->getContents(), 'application/xml', 'embeds'); - } - - if (count($embedsMetadata) > 0) { - $map = []; - foreach ($embedsMetadata as $entry) { - $map[$entry->filename] = $entry; - } - - $json = json_encode($map); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'embedsMetadata', $json); - } - - foreach ($assets as $asset) { - $asset->getStream()->rewind(); - $this->assertContainsFormFile($body, $asset->getFilename(), $asset->getStream()->getContents(), 'image/jpeg'); - } - - if ($watermarkSource !== '') { - $this->assertContainsFormValue($body, 'watermarkSource', $watermarkSource); - } - - if ($watermarkExpression !== '') { - $this->assertContainsFormValue($body, 'watermarkExpression', $watermarkExpression); - } - - if ($watermarkPages !== '') { - $this->assertContainsFormValue($body, 'watermarkPages', $watermarkPages); - } - - if (count($watermarkOptions) > 0) { - $json = json_encode($watermarkOptions); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'watermarkOptions', $json); - } - - if ($stampSource !== '') { - $this->assertContainsFormValue($body, 'stampSource', $stampSource); - } - - if ($stampExpression !== '') { - $this->assertContainsFormValue($body, 'stampExpression', $stampExpression); - } - - if ($stampPages !== '') { - $this->assertContainsFormValue($body, 'stampPages', $stampPages); - } - - if (count($stampOptions) > 0) { - $json = json_encode($stampOptions); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'stampOptions', $json); - } - - if ($rotateAngle !== 0) { - $this->assertContainsFormValue($body, 'rotateAngle', (string) $rotateAngle); - } - - if ($optimizeImages) { - $this->assertContainsFormValue($body, 'optimizeImages', '1'); - $this->assertContainsFormValue($body, 'imageQuality', (string) ($imageQuality ?? 80)); - } - - if ($rotatePages === '') { - return; - } - - $this->assertContainsFormValue($body, 'rotatePages', $rotatePages); - } - - #[Test] - public function it_creates_a_valid_request_with_factur_x(): void - { - $facturX = new FacturX( - Stream::string('factur-x.xml', 'XML content'), - FacturX::CONFORMANCE_XRECHNUNG, - ); - - $request = Gotenberg::chromium('')->pdf() - ->pdfa('PDF/A-3b') - ->facturX($facturX) - ->url('https://example.com'); - $body = $this->sanitize($request->getBody()->getContents()); - - $facturX->xml->getStream()->rewind(); - $this->assertContainsFormFile($body, 'factur-x.xml', 'XML content', 'application/xml', 'facturxXml'); - $this->assertContainsFormValue($body, 'facturxConformanceLevel', 'XRECHNUNG'); - $this->assertContainsFormValue($body, 'facturxDocumentType', 'INVOICE'); - $this->assertContainsFormValue($body, 'facturxVersion', '1.0'); - } - - #[Test] - public function it_creates_a_valid_request_with_encryption_permissions(): void - { - $request = Gotenberg::chromium('')->pdf() - ->encrypt('', 'my_owner_password') - ->allowPrinting(false) - ->allowCopying(false) - ->url('https://example.com'); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertContainsFormValue($body, 'allowPrinting', '0'); - $this->assertContainsFormValue($body, 'allowCopying', '0'); - $this->assertContainsFormValue($body, 'ownerPassword', 'my_owner_password'); - } - - #[Test] - public function it_creates_a_valid_request_with_console_message_types(): void - { - $request = Gotenberg::chromium('')->pdf() - ->failOnConsoleMessageTypes(['warning', 'error']) - ->url('https://example.com'); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertContainsFormValue($body, 'failOnConsoleMessageTypes', '["warning","error"]'); - } } diff --git a/tests/Modules/ChromiumScreenshotTest.php b/tests/Modules/ChromiumScreenshotTest.php index c0d2e77..82e9b4e 100644 --- a/tests/Modules/ChromiumScreenshotTest.php +++ b/tests/Modules/ChromiumScreenshotTest.php @@ -4,917 +4,78 @@ namespace Gotenberg\Test\Modules; -use Gotenberg\Exceptions\NativeFunctionErrored; use Gotenberg\Gotenberg; -use Gotenberg\Modules\ChromiumCookie; -use Gotenberg\Modules\ChromiumEmulatedMediaFeatures; -use Gotenberg\Modules\ChromiumScreenshot; use Gotenberg\Stream; use Gotenberg\Test\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; - -use function count; -use function json_encode; +use Psr\Http\Message\RequestInterface; final class ChromiumScreenshotTest extends TestCase { /** - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param ChromiumCookie[] $cookies - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains - * @param Stream[] $assets + * @param list $values + * @param list $files */ #[Test] - #[DataProvider('provideUrlData')] - public function it_creates_a_valid_request_for_the_forms_chromium_screenshot_url_endpoint( - string $url, - int|null $width = null, - int|null $height = null, - bool $clip = false, - float|null $deviceScaleFactor = null, - string|null $format = null, - int|null $quality = null, - bool $optimizeForSpeed = false, - bool $omitBackground = false, - string|null $waitDelay = null, - string|null $waitForExpression = null, - string|null $waitForSelector = null, - string|null $emulatedMediaType = null, - array $emulatedMediaFeatures = [], - array $cookies = [], - string|null $userAgent = null, - array $extraHttpHeaders = [], - array $failOnHttpStatusCodes = [], - array $failOnResourceHttpStatusCodes = [], - array $ignoreResourceHttpStatusDomains = [], - bool $failOnResourceLoadingFailed = false, - bool $failOnConsoleExceptions = false, - bool|null $skipNetworkIdleEvent = null, - bool|null $skipNetworkAlmostIdleEvent = null, - array $assets = [], - string|null $selector = null, - ): void { - $chromium = Gotenberg::chromium('')->screenshot(); - $chromium = $this->hydrateChromiumScreenshotFormData( - $chromium, - $width, - $height, - $clip, - $deviceScaleFactor, - $format, - $quality, - $optimizeForSpeed, - $omitBackground, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $assets, - $selector, - ); - - $request = $chromium->url($url); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/chromium/screenshot/url', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'url', $url); - - $this->assertChromiumScreenshotOptions( - $body, - $width, - $height, - $clip, - $deviceScaleFactor, - $format, - $quality, - $optimizeForSpeed, - $omitBackground, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $assets, - $selector, - ); - } - - /** - * @return array, - * array, - * string|null, - * array, - * array, - * array, - * array, - * bool, - * bool, - * bool|null, - * bool|null, - * array, - * string|null - * }> - */ - public static function provideUrlData(): array + #[DataProvider('provideRequestData')] + public function it_creates_a_valid_request(RequestInterface $request, string $endpoint, array $values, array $files = []): void { - return [ - 'simple_url' => ['https://my.url'], - 'full_options' => [ - 'https://my.url', - 1280, - 800, - true, - 2.0, - 'png', - 100, - true, - true, - '1s', - "window.status === 'ready'", - '#id', - 'print', - [ - new ChromiumEmulatedMediaFeatures('prefers-color-scheme', 'dark'), - new ChromiumEmulatedMediaFeatures('prefers-reduced-motion', 'reduce'), - ], - [ - new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com'), - new ChromiumCookie('vanilla_cookie', 'vanilla', 'theyummycookie.com', '/', true, true, 'Lax'), - ], - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)', - [ - 'My-Http-Header' => 'HTTP Header content', - 'My-Second-Http-Header' => 'Second HTTP Header content', - ], - [499, 599], - [499, 599], - ['my.com'], - true, - true, - true, - true, - [ - Stream::string('my.jpg', 'Image content'), - ], - '#content', - ], - ]; + $this->assertRequest($request, $endpoint, $values, $files); } - /** - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param ChromiumCookie[] $cookies - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains - * @param Stream[] $assets - */ - #[Test] - #[DataProvider('provideHtmlData')] - public function it_creates_a_valid_request_for_the_forms_chromium_screenshot_html_endpoint( - Stream $index, - int|null $width = null, - int|null $height = null, - bool $clip = false, - float|null $deviceScaleFactor = null, - string|null $format = null, - int|null $quality = null, - bool $optimizeForSpeed = false, - bool $omitBackground = false, - string|null $waitDelay = null, - string|null $waitForExpression = null, - string|null $waitForSelector = null, - string|null $emulatedMediaType = null, - array $emulatedMediaFeatures = [], - array $cookies = [], - string|null $userAgent = null, - array $extraHttpHeaders = [], - array $failOnHttpStatusCodes = [], - array $failOnResourceHttpStatusCodes = [], - array $ignoreResourceHttpStatusDomains = [], - bool $failOnResourceLoadingFailed = false, - bool $failOnConsoleExceptions = false, - bool|null $skipNetworkIdleEvent = null, - bool|null $skipNetworkAlmostIdleEvent = null, - array $assets = [], - string|null $selector = null, - ): void { - $chromium = Gotenberg::chromium('')->screenshot(); - $chromium = $this->hydrateChromiumScreenshotFormData( - $chromium, - $width, - $height, - $clip, - $deviceScaleFactor, - $format, - $quality, - $optimizeForSpeed, - $omitBackground, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $assets, - $selector, - ); - - $request = $chromium->html($index); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/chromium/screenshot/html', $request->getUri()->getPath()); - - $index->getStream()->rewind(); - $this->assertContainsFormFile($body, 'index.html', $index->getStream()->getContents(), 'text/html'); - - $this->assertChromiumScreenshotOptions( - $body, - $width, - $height, - $clip, - $deviceScaleFactor, - $format, - $quality, - $optimizeForSpeed, - $omitBackground, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $assets, - $selector, - ); - } - - /** - * @return array, - * array, - * string|null, - * array, - * array, - * array, - * array, - * bool, - * bool, - * bool|null, - * bool|null, - * array, - * string|null - * }> - */ - public static function provideHtmlData(): array + /** @return iterable, 3?: list}> */ + public static function provideRequestData(): iterable { - return [ - 'simple_html' => [Stream::string('my.html', 'HTML content')], - 'full_options' => [ - Stream::string('my.html', 'HTML content'), - 1280, - 800, - true, - 2.0, - 'jpeg', - 100, - true, - true, - '1s', - "window.status === 'ready'", - '#id', - 'screen', - [ - new ChromiumEmulatedMediaFeatures('prefers-color-scheme', 'light'), - new ChromiumEmulatedMediaFeatures('forced-colors', 'none'), - ], - [ - new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com'), - new ChromiumCookie('vanilla_cookie', 'vanilla', 'theyummycookie.com', '/', true, true, 'Lax'), - ], - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)', - [ - 'My-Http-Header' => 'Http Header content', - 'My-Second-Http-Header' => 'Second Http Header content', - ], - [499, 599], - [499, 599], - ['my.com'], - true, - true, - true, - true, - [ - Stream::string('my.jpg', 'Image content'), - ], - '#content', + yield 'url' => [ + Gotenberg::chromium('https://my.url') + ->screenshot() + ->width(1280) + ->height(720) + ->clip() + ->deviceScaleFactor(2.0) + ->jpeg() + ->quality(80) + ->optimizeForSpeed() + ->selector('#main') + ->waitDelay('1s') + ->url('https://my.url'), + '/forms/chromium/screenshot/url', + [ + ['url', 'https://my.url'], + ['width', '1280'], + ['height', '720'], + ['clip', 'true'], + ['deviceScaleFactor', '2'], + ['format', 'jpeg'], + ['quality', '80'], + ['optimizeForSpeed', 'true'], + ['selector', '#main'], + ['waitDelay', '1s'], ], ]; - } - - /** - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param ChromiumCookie[] $cookies - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains, - * @param Stream[] $markdowns - * @param Stream[] $assets - */ - #[Test] - #[DataProvider('provideMarkdownData')] - public function it_creates_a_valid_request_for_the_forms_chromium_screenshot_markdown_endpoint( - Stream $index, - array $markdowns, - int|null $width = null, - int|null $height = null, - bool $clip = false, - float|null $deviceScaleFactor = null, - string|null $format = null, - int|null $quality = null, - bool $optimizeForSpeed = false, - bool $omitBackground = false, - string|null $waitDelay = null, - string|null $waitForExpression = null, - string|null $waitForSelector = null, - string|null $emulatedMediaType = null, - array $emulatedMediaFeatures = [], - array $cookies = [], - string|null $userAgent = null, - array $extraHttpHeaders = [], - array $failOnHttpStatusCodes = [], - array $failOnResourceHttpStatusCodes = [], - array $ignoreResourceHttpStatusDomains = [], - bool $failOnResourceLoadingFailed = false, - bool $failOnConsoleExceptions = false, - bool|null $skipNetworkIdleEvent = null, - bool|null $skipNetworkAlmostIdleEvent = null, - array $assets = [], - string|null $selector = null, - ): void { - $chromium = Gotenberg::chromium('')->screenshot(); - $chromium = $this->hydrateChromiumScreenshotFormData( - $chromium, - $width, - $height, - $clip, - $deviceScaleFactor, - $format, - $quality, - $optimizeForSpeed, - $omitBackground, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $assets, - $selector, - ); - - $request = $chromium->markdown($index, ...$markdowns); - $body = $this->sanitize($request->getBody()->getContents()); - $this->assertSame('/forms/chromium/screenshot/markdown', $request->getUri()->getPath()); - - $index->getStream()->rewind(); - $this->assertContainsFormFile($body, 'index.html', $index->getStream()->getContents(), 'text/html'); - - foreach ($markdowns as $markdown) { - $markdown->getStream()->rewind(); - $this->assertContainsFormFile($body, $markdown->getFilename(), $markdown->getStream()->getContents()); - } - - $this->assertChromiumScreenshotOptions( - $body, - $width, - $height, - $clip, - $deviceScaleFactor, - $format, - $quality, - $optimizeForSpeed, - $omitBackground, - $waitDelay, - $waitForExpression, - $waitForSelector, - $emulatedMediaType, - $emulatedMediaFeatures, - $cookies, - $userAgent, - $extraHttpHeaders, - $failOnHttpStatusCodes, - $failOnResourceHttpStatusCodes, - $ignoreResourceHttpStatusDomains, - $failOnResourceLoadingFailed, - $failOnConsoleExceptions, - $skipNetworkIdleEvent, - $skipNetworkAlmostIdleEvent, - $assets, - $selector, - ); - } + yield 'html' => [ + Gotenberg::chromium('https://my.url') + ->screenshot() + ->png() + ->html(Stream::string('my.html', 'HTML content')), + '/forms/chromium/screenshot/html', + [['format', 'png']], + [['files', 'index.html', 'HTML content', 'text/html']], + ]; - /** - * @return array, - * int|null, - * int|null, - * bool, - * float|null, - * string|null, - * int|null, - * bool, - * bool, - * string|null, - * string|null, - * string|null, - * string|null, - * array, - * array, - * string|null, - * array, - * array, - * array, - * array, - * bool, - * bool, - * bool|null, - * bool|null, - * array, - * string|null - * }> - */ - public static function provideMarkdownData(): array - { - return [ - 'simple_markdown' => [ - Stream::string('my.html', 'HTML content'), - [ - Stream::string('my.md', 'Markdown content'), - ], - ], - 'full_options' => [ - Stream::string('my.html', 'HTML content'), - [ - Stream::string('my.md', 'Markdown content'), - Stream::string('my_second.md', 'Second Markdown content'), - ], - 1280, - 800, - true, - 2.0, - 'webp', - 100, - true, - true, - '1s', - "window.status === 'ready'", - '#id', - 'screen', - [ - new ChromiumEmulatedMediaFeatures('prefers-reduced-motion', 'reduce'), - ], - [ - new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com'), - new ChromiumCookie('vanilla_cookie', 'vanilla', 'theyummycookie.com', '/', true, true, 'Lax'), - ], - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)', - [ - 'My-Http-Header' => 'Http Header content', - 'My-Second-Http-Header' => 'Second Http Header content', - ], - [499, 599], - [499, 599], - ['my.com'], - true, - true, - true, - true, - [ - Stream::string('my.jpg', 'Image content'), - ], - '#content', + yield 'markdown' => [ + Gotenberg::chromium('https://my.url') + ->screenshot() + ->webp() + ->markdown(Stream::string('my.html', 'HTML content'), Stream::string('my.md', 'Markdown content')), + '/forms/chromium/screenshot/markdown', + [['format', 'webp']], + [ + ['files', 'index.html', 'HTML content', 'text/html'], + ['files', 'my.md', 'Markdown content'], ], ]; } - - /** - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param ChromiumCookie[] $cookies - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains - * @param Stream[] $assets - */ - private function hydrateChromiumScreenshotFormData( - ChromiumScreenshot $chromium, - int|null $width, - int|null $height, - bool $clip, - float|null $deviceScaleFactor = null, - string|null $format = null, - int|null $quality = null, - bool $optimizeForSpeed = false, - bool $omitBackground = false, - string|null $waitDelay = null, - string|null $waitForExpression = null, - string|null $waitForSelector = null, - string|null $emulatedMediaType = null, - array $emulatedMediaFeatures = [], - array $cookies = [], - string|null $userAgent = null, - array $extraHttpHeaders = [], - array $failOnHttpStatusCodes = [], - array $failOnResourceHttpStatusCodes = [], - array $ignoreResourceHttpStatusDomains = [], - bool $failOnResourceLoadingFailed = false, - bool $failOnConsoleExceptions = false, - bool|null $skipNetworkIdleEvent = null, - bool|null $skipNetworkAlmostIdleEvent = null, - array $assets = [], - string|null $selector = null, - ): ChromiumScreenshot { - if ($width !== null) { - $chromium->width($width); - } - - if ($height !== null) { - $chromium->height($height); - } - - if ($clip) { - $chromium->clip(); - } - - if ($deviceScaleFactor !== null) { - $chromium->deviceScaleFactor($deviceScaleFactor); - } - - if ($format === 'png') { - $chromium->png(); - } - - if ($format === 'jpeg') { - $chromium->jpeg(); - } - - if ($format === 'webp') { - $chromium->webp(); - } - - if ($quality !== null) { - $chromium->quality($quality); - } - - if ($optimizeForSpeed) { - $chromium->optimizeForSpeed(); - } - - if ($omitBackground) { - $chromium->omitBackground(); - } - - if ($waitDelay !== null) { - $chromium->waitDelay($waitDelay); - } - - if ($waitForExpression !== null) { - $chromium->waitForExpression($waitForExpression); - } - - if ($waitForSelector !== null) { - $chromium->waitForSelector($waitForSelector); - } - - if ($emulatedMediaType === 'print') { - $chromium->emulatePrintMediaType(); - } - - if ($emulatedMediaType === 'screen') { - $chromium->emulateScreenMediaType(); - } - - if (count($emulatedMediaFeatures) > 0) { - $chromium->emulatedMediaFeatures($emulatedMediaFeatures); - } - - if (count($cookies) > 0) { - $chromium->cookies($cookies); - } - - if ($userAgent !== null) { - $chromium->userAgent($userAgent); - } - - if (count($extraHttpHeaders) > 0) { - $chromium->extraHttpHeaders($extraHttpHeaders); - } - - if (count($failOnHttpStatusCodes) > 0) { - $chromium->failOnHttpStatusCodes($failOnHttpStatusCodes); - } - - if (count($failOnResourceHttpStatusCodes) > 0) { - $chromium->failOnResourceHttpStatusCodes($failOnResourceHttpStatusCodes); - } - - if (count($ignoreResourceHttpStatusDomains) > 0) { - $chromium->ignoreResourceHttpStatusDomains($ignoreResourceHttpStatusDomains); - } - - if ($failOnResourceLoadingFailed) { - $chromium->failOnResourceLoadingFailed(); - } - - if ($failOnConsoleExceptions) { - $chromium->failOnConsoleExceptions(); - } - - if ($skipNetworkIdleEvent !== null) { - $chromium->skipNetworkIdleEvent($skipNetworkIdleEvent); - } - - if ($skipNetworkAlmostIdleEvent !== null) { - $chromium->skipNetworkAlmostIdleEvent($skipNetworkAlmostIdleEvent); - } - - if (count($assets) > 0) { - $chromium->assets(...$assets); - } - - if ($selector !== null) { - $chromium->selector($selector); - } - - return $chromium; - } - - /** - * @param ChromiumEmulatedMediaFeatures[] $emulatedMediaFeatures - * @param ChromiumCookie[] $cookies - * @param array $extraHttpHeaders - * @param int[] $failOnHttpStatusCodes - * @param int[] $failOnResourceHttpStatusCodes - * @param string[] $ignoreResourceHttpStatusDomains - * @param Stream[] $assets - */ - private function assertChromiumScreenshotOptions( - string $body, - int|null $width, - int|null $height, - bool $clip, - float|null $deviceScaleFactor, - string|null $format, - int|null $quality, - bool $optimizeForSpeed, - bool $omitBackground, - string|null $waitDelay, - string|null $waitForExpression, - string|null $waitForSelector, - string|null $emulatedMediaType, - array $emulatedMediaFeatures, - array $cookies, - string|null $userAgent, - array $extraHttpHeaders, - array $failOnHttpStatusCodes, - array $failOnResourceHttpStatusCodes, - array $ignoreResourceHttpStatusDomains, - bool $failOnResourceLoadingFailed, - bool $failOnConsoleExceptions, - bool|null $skipNetworkIdleEvent, - bool|null $skipNetworkAlmostIdleEvent, - array $assets, - string|null $selector = null, - ): void { - if ($width !== null) { - $this->assertContainsFormValue($body, 'width', (string) $width); - } - - if ($height !== null) { - $this->assertContainsFormValue($body, 'height', (string) $height); - } - - if ($clip) { - $this->assertContainsFormValue($body, 'clip', '1'); - } - - if ($deviceScaleFactor !== null) { - $this->assertContainsFormValue($body, 'deviceScaleFactor', (string) $deviceScaleFactor); - } - - if ($format !== null) { - $this->assertContainsFormValue($body, 'format', $format); - } - - if ($quality !== null) { - $this->assertContainsFormValue($body, 'quality', (string) $quality); - } - - if ($optimizeForSpeed) { - $this->assertContainsFormValue($body, 'optimizeForSpeed', '1'); - } - - if ($omitBackground) { - $this->assertContainsFormValue($body, 'omitBackground', '1'); - } - - if ($waitDelay !== null) { - $this->assertContainsFormValue($body, 'waitDelay', $waitDelay); - } - - if ($waitForExpression !== null) { - $this->assertContainsFormValue($body, 'waitForExpression', $waitForExpression); - } - - if ($waitForSelector !== null) { - $this->assertContainsFormValue($body, 'waitForSelector', $waitForSelector); - } - - if ($emulatedMediaType !== null) { - $this->assertContainsFormValue($body, 'emulatedMediaType', $emulatedMediaType); - } - - if (count($emulatedMediaFeatures) > 0) { - $json = json_encode($emulatedMediaFeatures); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'emulatedMediaFeatures', $json); - } - - if (count($cookies) > 0) { - $json = json_encode($cookies); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'cookies', $json); - } - - if ($userAgent !== null) { - $this->assertContainsFormValue($body, 'userAgent', $userAgent); - } - - if (count($extraHttpHeaders) > 0) { - $json = json_encode($extraHttpHeaders); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'extraHttpHeaders', $json); - } - - if (count($failOnHttpStatusCodes) > 0) { - $json = json_encode($failOnHttpStatusCodes); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'failOnHttpStatusCodes', $json); - } - - if (count($failOnResourceHttpStatusCodes) > 0) { - $json = json_encode($failOnResourceHttpStatusCodes); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'failOnResourceHttpStatusCodes', $json); - } - - if (count($ignoreResourceHttpStatusDomains) > 0) { - $json = json_encode($ignoreResourceHttpStatusDomains); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'ignoreResourceHttpStatusDomains', $json); - } - - if ($failOnResourceLoadingFailed) { - $this->assertContainsFormValue($body, 'failOnResourceLoadingFailed', '1'); - } - - if ($failOnConsoleExceptions) { - $this->assertContainsFormValue($body, 'failOnConsoleExceptions', '1'); - } - - if ($skipNetworkIdleEvent !== null) { - $this->assertContainsFormValue($body, 'skipNetworkIdleEvent', $skipNetworkIdleEvent ? '1' : '0'); - } - - if ($skipNetworkAlmostIdleEvent !== null) { - $this->assertContainsFormValue($body, 'skipNetworkAlmostIdleEvent', $skipNetworkAlmostIdleEvent ? '1' : '0'); - } - - if ($selector !== null) { - $this->assertContainsFormValue($body, 'selector', $selector); - } - - if (count($assets) <= 0) { - return; - } - - foreach ($assets as $asset) { - $asset->getStream()->rewind(); - $this->assertContainsFormFile($body, $asset->getFilename(), $asset->getStream()->getContents(), 'image/jpeg'); - } - } - - #[Test] - public function it_creates_a_valid_request_with_console_message_types(): void - { - $request = Gotenberg::chromium('')->screenshot() - ->failOnConsoleMessageTypes(['warning', 'error']) - ->url('https://example.com'); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertContainsFormValue($body, 'failOnConsoleMessageTypes', '["warning","error"]'); - } } diff --git a/tests/Modules/LibreOfficeTest.php b/tests/Modules/LibreOfficeTest.php index 4427fda..eff6725 100644 --- a/tests/Modules/LibreOfficeTest.php +++ b/tests/Modules/LibreOfficeTest.php @@ -4,851 +4,149 @@ namespace Gotenberg\Test\Modules; -use Gotenberg\EmbedMetadata; -use Gotenberg\Exceptions\NativeFunctionErrored; -use Gotenberg\FacturX; use Gotenberg\Gotenberg; -use Gotenberg\SplitMode; +use Gotenberg\Stage; use Gotenberg\Stream; use Gotenberg\Test\Helpers\Dummies\DummyIndex; use Gotenberg\Test\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; - -use function count; -use function json_encode; +use Psr\Http\Message\RequestInterface; final class LibreOfficeTest extends TestCase { /** - * @param Stream[] $files - * @param array> $metadata - * @param Stream[] $embeds - * @param EmbedMetadata[] $embedsMetadata - * @param array $watermarkOptions - * @param array $stampOptions + * @param list $values + * @param list $files */ #[Test] - #[DataProvider('provideConvertData')] - public function it_creates_a_valid_request_for_the_forms_libreoffice_convert_endpoint( - array $files, - string|null $password = null, - bool $landscape = false, - string|null $nativePageRanges = null, - bool|null $updateIndexes = null, - bool|null $exportFormFields = null, - bool $allowDuplicateFieldNames = false, - bool|null $exportBookmarks = null, - bool $exportBookmarksToPdfDestination = false, - bool $exportPlaceholders = false, - bool $exportNotes = false, - bool $exportNotesPages = false, - bool $exportOnlyNotesPages = false, - bool $exportNotesInMargin = false, - bool $convertOooTargetToPdfTarget = false, - bool $exportLinksRelativeFsys = false, - bool $exportHiddenSlides = false, - bool $skipEmptyPages = false, - bool $addOriginalDocumentAsStream = false, - string|null $initialView = null, - int|null $initialPage = null, - string|null $magnification = null, - int|null $zoom = null, - string|null $pageLayout = null, - bool $firstPageOnLeft = false, - bool $resizeWindowToInitialPage = false, - bool $centerWindow = false, - bool $openInFullScreenMode = false, - bool $displayPDFDocumentTitle = false, - bool $hideViewerMenubar = false, - bool $hideViewerToolbar = false, - bool $hideViewerWindowControls = false, - bool $useTransitionEffects = false, - int|null $openBookmarkLevels = null, - bool $singlePageSheets = false, - bool $losslessImageCompression = false, - int|null $quality = null, - bool $reduceImageResolution = false, - int|null $maxImageResolution = null, - SplitMode|null $splitMode = null, - string|null $pdfa = null, - bool $pdfua = false, - array $metadata = [], - bool $merge = false, - string|null $nativeWatermarkText = null, - int|null $nativeWatermarkColor = null, - int|null $nativeWatermarkFontHeight = null, - int|null $nativeWatermarkRotateAngle = null, - string|null $nativeWatermarkFontName = null, - string|null $nativeTiledWatermarkText = null, - bool $flatten = false, - string $userPassword = '', - string $ownerPassword = '', - array $embeds = [], - array $embedsMetadata = [], - string $watermarkSource = '', - string $watermarkExpression = '', - string $watermarkPages = '', - array $watermarkOptions = [], - string $stampSource = '', - string $stampExpression = '', - string $stampPages = '', - array $stampOptions = [], - int $rotateAngle = 0, - string $rotatePages = '', - bool $optimizeImages = false, - int|null $imageQuality = null, - ): void { - $libreOffice = Gotenberg::libreOffice(''); - - if ($password !== null) { - $libreOffice->password($password); - } - - if ($landscape) { - $libreOffice->landscape(); - } - - if ($nativePageRanges !== null) { - $libreOffice->nativePageRanges($nativePageRanges); - } - - if ($updateIndexes !== null) { - $libreOffice->updateIndexes($updateIndexes); - } - - if ($exportFormFields !== null) { - $libreOffice->exportFormFields($exportFormFields); - } - - if ($allowDuplicateFieldNames) { - $libreOffice->allowDuplicateFieldNames(); - } - - if ($exportBookmarks !== null) { - $libreOffice->exportBookmarks($exportBookmarks); - } - - if ($exportBookmarksToPdfDestination) { - $libreOffice->exportBookmarksToPdfDestination(); - } - - if ($exportPlaceholders) { - $libreOffice->exportPlaceholders(); - } - - if ($exportNotes) { - $libreOffice->exportNotes(); - } - - if ($exportNotesPages) { - $libreOffice->exportNotesPages(); - } - - if ($exportOnlyNotesPages) { - $libreOffice->exportOnlyNotesPages(); - } - - if ($exportNotesInMargin) { - $libreOffice->exportNotesInMargin(); - } - - if ($convertOooTargetToPdfTarget) { - $libreOffice->convertOooTargetToPdfTarget(); - } - - if ($exportLinksRelativeFsys) { - $libreOffice->exportLinksRelativeFsys(); - } - - if ($exportHiddenSlides) { - $libreOffice->exportHiddenSlides(); - } - - if ($skipEmptyPages) { - $libreOffice->skipEmptyPages(); - } - - if ($addOriginalDocumentAsStream) { - $libreOffice->addOriginalDocumentAsStream(); - } - - if ($initialView !== null) { - $libreOffice->initialView($initialView); - } - - if ($initialPage !== null) { - $libreOffice->initialPage($initialPage); - } - - if ($magnification !== null) { - $libreOffice->magnification($magnification); - } - - if ($zoom !== null) { - $libreOffice->zoom($zoom); - } - - if ($pageLayout !== null) { - $libreOffice->pageLayout($pageLayout); - } - - if ($firstPageOnLeft) { - $libreOffice->firstPageOnLeft(); - } - - if ($resizeWindowToInitialPage) { - $libreOffice->resizeWindowToInitialPage(); - } - - if ($centerWindow) { - $libreOffice->centerWindow(); - } - - if ($openInFullScreenMode) { - $libreOffice->openInFullScreenMode(); - } - - if ($displayPDFDocumentTitle) { - $libreOffice->displayPDFDocumentTitle(); - } - - if ($hideViewerMenubar) { - $libreOffice->hideViewerMenubar(); - } - - if ($hideViewerToolbar) { - $libreOffice->hideViewerToolbar(); - } - - if ($hideViewerWindowControls) { - $libreOffice->hideViewerWindowControls(); - } - - if ($useTransitionEffects) { - $libreOffice->useTransitionEffects(); - } - - if ($openBookmarkLevels !== null) { - $libreOffice->openBookmarkLevels($openBookmarkLevels); - } - - if ($singlePageSheets) { - $libreOffice->singlePageSheets(); - } - - if ($losslessImageCompression) { - $libreOffice->losslessImageCompression(); - } - - if ($quality !== null) { - $libreOffice->quality($quality); - } - - if ($reduceImageResolution) { - $libreOffice->reduceImageResolution(); - } - - if ($maxImageResolution !== null) { - $libreOffice->maxImageResolution($maxImageResolution); - } - - if ($nativeWatermarkText !== null) { - $libreOffice->nativeWatermarkText($nativeWatermarkText); - } - - if ($nativeWatermarkColor !== null) { - $libreOffice->nativeWatermarkColor($nativeWatermarkColor); - } - - if ($nativeWatermarkFontHeight !== null) { - $libreOffice->nativeWatermarkFontHeight($nativeWatermarkFontHeight); - } - - if ($nativeWatermarkRotateAngle !== null) { - $libreOffice->nativeWatermarkRotateAngle($nativeWatermarkRotateAngle); - } - - if ($nativeWatermarkFontName !== null) { - $libreOffice->nativeWatermarkFontName($nativeWatermarkFontName); - } - - if ($nativeTiledWatermarkText !== null) { - $libreOffice->nativeTiledWatermarkText($nativeTiledWatermarkText); - } - - if ($pdfa !== null) { - $libreOffice->pdfa($pdfa); - } - - if ($splitMode !== null) { - $libreOffice->split($splitMode); - } - - if ($pdfua) { - $libreOffice->pdfua(); - } - - if (count($metadata) > 0) { - $libreOffice->metadata($metadata); - } - - if ($merge) { - $libreOffice - ->index(new DummyIndex()) - ->merge(); - } - - if ($flatten) { - $libreOffice->flatten(); - } - - if ($userPassword !== '') { - $libreOffice->encrypt($userPassword, $ownerPassword); - } - - if (count($embeds) > 0) { - $libreOffice->embeds(...$embeds); - } - - if (count($embedsMetadata) > 0) { - $libreOffice->embedsMetadata(...$embedsMetadata); - } - - if ($watermarkSource !== '') { - $libreOffice->watermarking($watermarkSource, $watermarkExpression, $watermarkPages, $watermarkOptions); - } - - if ($stampSource !== '') { - $libreOffice->stamping($stampSource, $stampExpression, $stampPages, $stampOptions); - } - - if ($rotateAngle !== 0) { - $libreOffice->rotating($rotateAngle, $rotatePages); - } - - if ($optimizeImages) { - $libreOffice->optimizeImages($imageQuality ?? 80); - } - - $request = $libreOffice->convert(...$files); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/libreoffice/convert', $request->getUri()->getPath()); - - if ($password !== null) { - $this->assertContainsFormValue($body, 'password', $password); - } - - if ($landscape) { - $this->assertContainsFormValue($body, 'landscape', '1'); - } - - if ($nativePageRanges !== null) { - $this->assertContainsFormValue($body, 'nativePageRanges', $nativePageRanges); - } - - if ($updateIndexes !== null) { - $this->assertContainsFormValue($body, 'updateIndexes', $updateIndexes ? '1' : '0'); - } - - if ($exportFormFields !== null) { - $this->assertContainsFormValue($body, 'exportFormFields', $exportFormFields ? '1' : '0'); - } - - if ($allowDuplicateFieldNames) { - $this->assertContainsFormValue($body, 'allowDuplicateFieldNames', '1'); - } - - if ($exportBookmarks !== null) { - $this->assertContainsFormValue($body, 'exportBookmarks', $exportBookmarks ? '1' : '0'); - } - - if ($exportBookmarksToPdfDestination) { - $this->assertContainsFormValue($body, 'exportBookmarksToPdfDestination', '1'); - } - - if ($exportPlaceholders) { - $this->assertContainsFormValue($body, 'exportPlaceholders', '1'); - } - - if ($exportNotes) { - $this->assertContainsFormValue($body, 'exportNotes', '1'); - } - - if ($exportNotesPages) { - $this->assertContainsFormValue($body, 'exportNotesPages', '1'); - } - - if ($exportOnlyNotesPages) { - $this->assertContainsFormValue($body, 'exportOnlyNotesPages', '1'); - } - - if ($exportNotesInMargin) { - $this->assertContainsFormValue($body, 'exportNotesInMargin', '1'); - } - - if ($convertOooTargetToPdfTarget) { - $this->assertContainsFormValue($body, 'convertOooTargetToPdfTarget', '1'); - } - - if ($exportLinksRelativeFsys) { - $this->assertContainsFormValue($body, 'exportLinksRelativeFsys', '1'); - } - - if ($exportHiddenSlides) { - $this->assertContainsFormValue($body, 'exportHiddenSlides', '1'); - } - - if ($skipEmptyPages) { - $this->assertContainsFormValue($body, 'skipEmptyPages', '1'); - } - - if ($addOriginalDocumentAsStream) { - $this->assertContainsFormValue($body, 'addOriginalDocumentAsStream', '1'); - } - - if ($initialView !== null) { - $this->assertContainsFormValue($body, 'initialView', $initialView); - } - - if ($initialPage !== null) { - $this->assertContainsFormValue($body, 'initialPage', (string) $initialPage); - } - - if ($magnification !== null) { - $this->assertContainsFormValue($body, 'magnification', $magnification); - } - - if ($zoom !== null) { - $this->assertContainsFormValue($body, 'zoom', (string) $zoom); - } - - if ($pageLayout !== null) { - $this->assertContainsFormValue($body, 'pageLayout', $pageLayout); - } - - if ($firstPageOnLeft) { - $this->assertContainsFormValue($body, 'firstPageOnLeft', '1'); - } - - if ($resizeWindowToInitialPage) { - $this->assertContainsFormValue($body, 'resizeWindowToInitialPage', '1'); - } - - if ($centerWindow) { - $this->assertContainsFormValue($body, 'centerWindow', '1'); - } - - if ($openInFullScreenMode) { - $this->assertContainsFormValue($body, 'openInFullScreenMode', '1'); - } - - if ($displayPDFDocumentTitle) { - $this->assertContainsFormValue($body, 'displayPDFDocumentTitle', '1'); - } - - if ($hideViewerMenubar) { - $this->assertContainsFormValue($body, 'hideViewerMenubar', '1'); - } - - if ($hideViewerToolbar) { - $this->assertContainsFormValue($body, 'hideViewerToolbar', '1'); - } - - if ($hideViewerWindowControls) { - $this->assertContainsFormValue($body, 'hideViewerWindowControls', '1'); - } - - if ($useTransitionEffects) { - $this->assertContainsFormValue($body, 'useTransitionEffects', '1'); - } - - if ($openBookmarkLevels !== null) { - $this->assertContainsFormValue($body, 'openBookmarkLevels', (string) $openBookmarkLevels); - } - - if ($singlePageSheets) { - $this->assertContainsFormValue($body, 'singlePageSheets', '1'); - } - - if ($losslessImageCompression) { - $this->assertContainsFormValue($body, 'losslessImageCompression', '1'); - } - - if ($quality !== null) { - $this->assertContainsFormValue($body, 'quality', (string) $quality); - } - - if ($reduceImageResolution) { - $this->assertContainsFormValue($body, 'reduceImageResolution', '1'); - } - - if ($maxImageResolution !== null) { - $this->assertContainsFormValue($body, 'maxImageResolution', (string) $maxImageResolution); - } - - if ($nativeWatermarkText !== null) { - $this->assertContainsFormValue($body, 'nativeWatermarkText', $nativeWatermarkText); - } - - if ($nativeWatermarkColor !== null) { - $this->assertContainsFormValue($body, 'nativeWatermarkColor', (string) $nativeWatermarkColor); - } - - if ($nativeWatermarkFontHeight !== null) { - $this->assertContainsFormValue($body, 'nativeWatermarkFontHeight', (string) $nativeWatermarkFontHeight); - } - - if ($nativeWatermarkRotateAngle !== null) { - $this->assertContainsFormValue($body, 'nativeWatermarkRotateAngle', (string) $nativeWatermarkRotateAngle); - } - - if ($nativeWatermarkFontName !== null) { - $this->assertContainsFormValue($body, 'nativeWatermarkFontName', $nativeWatermarkFontName); - } - - if ($nativeTiledWatermarkText !== null) { - $this->assertContainsFormValue($body, 'nativeTiledWatermarkText', $nativeTiledWatermarkText); - } - - if ($splitMode !== null) { - $this->assertContainsFormValue($body, 'splitMode', $splitMode->mode); - $this->assertContainsFormValue($body, 'splitSpan', $splitMode->span); - $this->assertContainsFormValue($body, 'splitUnify', $splitMode->unify ? '1' : '0'); - } - - if ($pdfa !== null) { - $this->assertContainsFormValue($body, 'pdfa', $pdfa); - } - - if ($pdfua) { - $this->assertContainsFormValue($body, 'pdfua', '1'); - } - - if ($merge) { - $this->assertContainsFormValue($body, 'merge', '1'); - } - - if ($flatten) { - $this->assertContainsFormValue($body, 'flatten', '1'); - } - - if ($userPassword !== '') { - $this->assertContainsFormValue($body, 'userPassword', $userPassword); - $this->assertContainsFormValue($body, 'ownerPassword', $ownerPassword); - } - - if (count($metadata) > 0) { - $json = json_encode($metadata); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'metadata', $json); - } - - foreach ($files as $file) { - $filename = $merge ? 'foo_' . $file->getFilename() : $file->getFilename(); - $file->getStream()->rewind(); - - $this->assertContainsFormFile( - $body, - $filename, - $file->getStream()->getContents(), - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - ); - } - - foreach ($embeds as $embed) { - $embed->getStream()->rewind(); - $this->assertContainsFormFile( - $body, - $embed->getFilename(), - $embed->getStream()->getContents(), - 'application/xml', - 'embeds', - ); - } - - if (count($embedsMetadata) > 0) { - $map = []; - foreach ($embedsMetadata as $entry) { - $map[$entry->filename] = $entry; - } - - $json = json_encode($map); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'embedsMetadata', $json); - } - - if ($watermarkSource !== '') { - $this->assertContainsFormValue($body, 'watermarkSource', $watermarkSource); - } - - if ($watermarkExpression !== '') { - $this->assertContainsFormValue($body, 'watermarkExpression', $watermarkExpression); - } - - if ($watermarkPages !== '') { - $this->assertContainsFormValue($body, 'watermarkPages', $watermarkPages); - } - - if (count($watermarkOptions) > 0) { - $json = json_encode($watermarkOptions); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'watermarkOptions', $json); - } - - if ($stampSource !== '') { - $this->assertContainsFormValue($body, 'stampSource', $stampSource); - } - - if ($stampExpression !== '') { - $this->assertContainsFormValue($body, 'stampExpression', $stampExpression); - } - - if ($stampPages !== '') { - $this->assertContainsFormValue($body, 'stampPages', $stampPages); - } - - if (count($stampOptions) > 0) { - $json = json_encode($stampOptions); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'stampOptions', $json); - } - - if ($rotateAngle !== 0) { - $this->assertContainsFormValue($body, 'rotateAngle', (string) $rotateAngle); - } - - if ($optimizeImages) { - $this->assertContainsFormValue($body, 'optimizeImages', '1'); - $this->assertContainsFormValue($body, 'imageQuality', (string) ($imageQuality ?? 80)); - } - - if ($rotatePages === '') { - return; - } - - $this->assertContainsFormValue($body, 'rotatePages', $rotatePages); + #[DataProvider('provideRequestData')] + public function it_creates_a_valid_request(RequestInterface $request, array $values, array $files = []): void + { + $this->assertRequest($request, '/forms/libreoffice/convert', $values, $files); } - /** - * @return array, - * password?: string|null, - * landscape?: bool, - * nativePageRanges?: string|null, - * updateIndexes?: bool|null, - * exportFormFields?: bool|null, - * allowDuplicateFieldNames?: bool, - * exportBookmarks?: bool|null, - * exportBookmarksToPdfDestination?: bool, - * exportPlaceholders?: bool, - * exportNotes?: bool, - * exportNotesPages?: bool, - * exportOnlyNotesPages?: bool, - * exportNotesInMargin?: bool, - * convertOooTargetToPdfTarget?: bool, - * exportLinksRelativeFsys?: bool, - * exportHiddenSlides?: bool, - * skipEmptyPages?: bool, - * addOriginalDocumentAsStream?: bool, - * initialView?: string|null, - * initialPage?: int|null, - * magnification?: string|null, - * zoom?: int|null, - * pageLayout?: string|null, - * firstPageOnLeft?: bool, - * resizeWindowToInitialPage?: bool, - * centerWindow?: bool, - * openInFullScreenMode?: bool, - * displayPDFDocumentTitle?: bool, - * hideViewerMenubar?: bool, - * hideViewerToolbar?: bool, - * hideViewerWindowControls?: bool, - * useTransitionEffects?: bool, - * openBookmarkLevels?: int|null, - * singlePageSheets?: bool, - * losslessImageCompression?: bool, - * quality?: int|null, - * reduceImageResolution?: bool, - * maxImageResolution?: int|null, - * splitMode?: SplitMode|null, - * pdfa?: string|null, - * pdfua?: bool, - * metadata?: array>, - * merge?: bool, - * nativeWatermarkText?: string|null, - * nativeWatermarkColor?: int|null, - * nativeWatermarkFontHeight?: int|null, - * nativeWatermarkRotateAngle?: int|null, - * nativeWatermarkFontName?: string|null, - * nativeTiledWatermarkText?: string|null, - * flatten?: bool, - * userPassword?: string, - * ownerPassword?: string, - * embeds?: array, - * embedsMetadata?: array, - * watermarkSource?: string, - * watermarkExpression?: string, - * watermarkPages?: string, - * watermarkOptions?: array, - * stampSource?: string, - * stampExpression?: string, - * stampPages?: string, - * stampOptions?: array, - * rotateAngle?: int, - * rotatePages?: string, - * optimizeImages?: bool, - * imageQuality?: int|null - * }> - */ - public static function provideConvertData(): array + /** @return iterable, 2?: list}> */ + public static function provideRequestData(): iterable { - return [ - 'minimal' => [ - 'files' => [ - Stream::string('my.docx', 'Word content'), - ], + yield 'every option' => [ + Gotenberg::libreOffice('https://my.url') + ->password('my_password') + ->paperSize('A4') + ->paperDimensions('21cm', 29.7) + ->landscape() + ->nativePages('my_nativePages') + ->singlePage() + ->skipEmptyPages() + ->updateIndexes(false) + ->exportFormFields(false) + ->allowDuplicateFieldNames() + ->nativeBookmarks(false) + ->exportBookmarksToPdfDestination() + ->exportPlaceholders() + ->exportNotes() + ->exportNotesInMargin() + ->exportNotesPages() + ->exportOnlyNotesPages() + ->exportHiddenSlides() + ->useTransitionEffects(false) + ->convertOooTargetToPdfTarget() + ->exportLinksRelativeFsys() + ->addOriginalDocumentAsStream() + ->nativeQuality(2) + ->nativeLosslessImageCompression() + ->nativeReduceImageResolution() + ->nativeMaxImageResolution(2) + ->nativeWatermarkText('my_nativeWatermarkText') + ->nativeWatermarkColor('#7ffe7f') + ->nativeWatermarkFontSize(2) + ->nativeWatermarkRotation(45.5) + ->nativeWatermarkFont('my_nativeWatermarkFont') + ->nativeTiledWatermarkText('my_nativeTiledWatermarkText') + ->nativePdfa('PDF/A-2b') + ->nativePdfua('PDF/UA-1') + ->nativeTaggedPdf() + ->initialView('my_initialView') + ->initialPage(2) + ->magnification('my_magnification') + ->zoom(2) + ->pageLayout('my_pageLayout') + ->openBookmarkLevels(2) + ->resizeWindowToInitialPage() + ->centerWindow() + ->openInFullScreenMode() + ->displayPDFDocumentTitle(false) + ->hideViewerMenubar() + ->hideViewerToolbar() + ->hideViewerWindowControls() + ->convert(Stream::string('my.docx', 'Word content'), Stream::string('my.xlsx', 'Excel content')), + [ + ['password', 'my_password'], + ['paperSize', 'A4'], + ['paperWidth', '21cm'], + ['paperHeight', '29.7'], + ['landscape', 'true'], + ['nativePages', 'my_nativePages'], + ['singlePage', 'true'], + ['skipEmptyPages', 'true'], + ['updateIndexes', 'false'], + ['exportFormFields', 'false'], + ['allowDuplicateFieldNames', 'true'], + ['nativeBookmarks', 'false'], + ['exportBookmarksToPdfDestination', 'true'], + ['exportPlaceholders', 'true'], + ['exportNotes', 'true'], + ['exportNotesInMargin', 'true'], + ['exportNotesPages', 'true'], + ['exportOnlyNotesPages', 'true'], + ['exportHiddenSlides', 'true'], + ['useTransitionEffects', 'false'], + ['convertOooTargetToPdfTarget', 'true'], + ['exportLinksRelativeFsys', 'true'], + ['addOriginalDocumentAsStream', 'true'], + ['nativeQuality', '2'], + ['nativeLosslessImageCompression', 'true'], + ['nativeReduceImageResolution', 'true'], + ['nativeMaxImageResolution', '2'], + ['nativeWatermarkText', 'my_nativeWatermarkText'], + ['nativeWatermarkColor', '#7ffe7f'], + ['nativeWatermarkFontSize', '2'], + ['nativeWatermarkRotation', '45.5'], + ['nativeWatermarkFont', 'my_nativeWatermarkFont'], + ['nativeTiledWatermarkText', 'my_nativeTiledWatermarkText'], + ['nativePdfa', 'PDF/A-2b'], + ['nativePdfua', 'PDF/UA-1'], + ['nativeTaggedPdf', 'true'], + ['initialView', 'my_initialView'], + ['initialPage', '2'], + ['magnification', 'my_magnification'], + ['zoom', '2'], + ['pageLayout', 'my_pageLayout'], + ['openBookmarkLevels', '2'], + ['resizeWindowToInitialPage', 'true'], + ['centerWindow', 'true'], + ['openInFullScreenMode', 'true'], + ['displayPDFDocumentTitle', 'false'], + ['hideViewerMenubar', 'true'], + ['hideViewerToolbar', 'true'], + ['hideViewerWindowControls', 'true'], ], - 'full_options' => [ - 'files' => [ - Stream::string('my.docx', 'Word content'), - Stream::string('my_second.docx', 'Second Word content'), - ], - 'password' => 'foo', - 'landscape' => true, - 'nativePageRanges' => '1-2', - 'updateIndexes' => false, - 'exportFormFields' => false, - 'allowDuplicateFieldNames' => true, - 'exportBookmarks' => false, - 'exportBookmarksToPdfDestination' => true, - 'exportPlaceholders' => true, - 'exportNotes' => true, - 'exportNotesPages' => true, - 'exportOnlyNotesPages' => true, - 'exportNotesInMargin' => true, - 'convertOooTargetToPdfTarget' => true, - 'exportLinksRelativeFsys' => true, - 'exportHiddenSlides' => true, - 'skipEmptyPages' => true, - 'addOriginalDocumentAsStream' => true, - 'initialView' => 'outline', - 'initialPage' => 2, - 'magnification' => 'fitWidth', - 'zoom' => 150, - 'pageLayout' => 'continuous', - 'firstPageOnLeft' => true, - 'resizeWindowToInitialPage' => true, - 'centerWindow' => true, - 'openInFullScreenMode' => true, - 'displayPDFDocumentTitle' => true, - 'hideViewerMenubar' => true, - 'hideViewerToolbar' => true, - 'hideViewerWindowControls' => true, - 'useTransitionEffects' => true, - 'openBookmarkLevels' => 3, - 'singlePageSheets' => true, - 'losslessImageCompression' => true, - 'quality' => 100, - 'reduceImageResolution' => true, - 'maxImageResolution' => 150, - 'splitMode' => SplitMode::intervals(1), - 'pdfa' => 'PDF/A-1a', - 'pdfua' => true, - 'metadata' => ['Producer' => 'Gotenberg'], - 'merge' => true, - 'nativeWatermarkText' => 'DRAFT', - 'nativeWatermarkColor' => 16711680, - 'nativeWatermarkFontHeight' => 72, - 'nativeWatermarkRotateAngle' => 45, - 'nativeWatermarkFontName' => 'Arial', - 'nativeTiledWatermarkText' => 'CONFIDENTIAL', - 'flatten' => true, - 'userPassword' => 'my_user_password', - 'ownerPassword' => 'my_owner_password', - 'embeds' => [ - Stream::string('my.xml', 'XML content'), - Stream::string('my_second.xml', 'Second XML content'), - ], - 'embedsMetadata' => [ - new EmbedMetadata('my.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_DATA), - new EmbedMetadata('my_second.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_ALTERNATIVE), - ], - 'watermarkSource' => 'my_watermark_source', - 'watermarkExpression' => 'my_watermark_expression', - 'watermarkPages' => '1-2', - 'watermarkOptions' => ['key' => 'value'], - 'stampSource' => 'my_stamp_source', - 'stampExpression' => 'my_stamp_expression', - 'stampPages' => '3-4', - 'stampOptions' => ['key' => 'value'], - 'rotateAngle' => 90, - 'rotatePages' => '1-3', - 'optimizeImages' => true, - 'imageQuality' => 60, + [ + ['files', 'my.docx', 'Word content'], + ['files', 'my.xlsx', 'Excel content'], ], ]; - } - - #[Test] - public function it_creates_a_valid_request_with_factur_x(): void - { - $facturX = new FacturX( - Stream::string('factur-x.xml', 'XML content'), - FacturX::CONFORMANCE_EN_16931, - FacturX::DOCUMENT_TYPE_INVOICE, - '1.0', - ); - $request = Gotenberg::libreOffice('') - ->pdfa('PDF/A-3b') - ->facturX($facturX) - ->convert(Stream::string('my.docx', 'DOCX content')); - $body = $this->sanitize($request->getBody()->getContents()); - - $facturX->xml->getStream()->rewind(); - $this->assertContainsFormFile($body, 'factur-x.xml', 'XML content', 'application/xml', 'facturxXml'); - $this->assertContainsFormValue($body, 'facturxConformanceLevel', 'EN 16931'); - $this->assertContainsFormValue($body, 'facturxDocumentType', 'INVOICE'); - $this->assertContainsFormValue($body, 'facturxVersion', '1.0'); - } - - #[Test] - public function it_creates_a_valid_request_with_encryption_permissions(): void - { - $request = Gotenberg::libreOffice('') - ->encrypt('', 'my_owner_password') - ->allowPrinting(false) - ->allowAssembling(false) - ->convert(Stream::string('my.docx', 'DOCX content')); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertContainsFormValue($body, 'allowPrinting', '0'); - $this->assertContainsFormValue($body, 'allowAssembling', '0'); - $this->assertContainsFormValue($body, 'ownerPassword', 'my_owner_password'); - } - - #[Test] - public function it_creates_a_valid_request_with_a_tagged_pdf(): void - { - $request = Gotenberg::libreOffice('') - ->generateTaggedPdf() - ->convert(Stream::string('my.docx', 'DOCX content')); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertContainsFormValue($body, 'generateTaggedPdf', '1'); + yield 'merge stage keeps the order of the arguments' => [ + Gotenberg::libreOffice('https://my.url') + ->index(new DummyIndex()) + ->stages(Stage::merge(), Stage::remediate('PDF/A-2b')) + ->convert(Stream::string('b.docx', 'Word content'), Stream::string('a.xlsx', 'Excel content')), + [['stages', '[{"op":"merge"},{"op":"remediate","profile":"PDF\\/A-2b"}]']], + [ + ['files', '0_b.docx', 'Word content'], + ['files', '1_a.xlsx', 'Excel content'], + ], + ]; } } diff --git a/tests/Modules/PdfEnginesTest.php b/tests/Modules/PdfEnginesTest.php deleted file mode 100644 index 743ba3f..0000000 --- a/tests/Modules/PdfEnginesTest.php +++ /dev/null @@ -1,1146 +0,0 @@ -> $metadata - * @param Stream[] $embeds - */ - #[Test] - #[DataProvider('provideMergeData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_merge_endpoint( - array $pdfs, - string|null $pdfa = null, - bool $pdfua = false, - array $metadata = [], - bool $flatten = false, - string $userPassword = '', - string $ownerPassword = '', - array $embeds = [], - string $bookmarks = '', - bool $autoIndexBookmarks = false, - int $rotateAngle = 0, - string $rotatePages = '', - bool $optimizeImages = false, - int|null $imageQuality = null, - bool $titleBookmarks = false, - ): void { - $pdfEngines = Gotenberg::pdfEngines('')->index(new DummyIndex()); - - if ($pdfa !== null) { - $pdfEngines->pdfa($pdfa); - } - - if ($pdfua) { - $pdfEngines->pdfua(); - } - - if (count($metadata) > 0) { - $pdfEngines->metadata($metadata); - } - - if ($flatten) { - $pdfEngines->flattening(); - } - - if ($userPassword !== '') { - $pdfEngines->encrypting($userPassword, $ownerPassword); - } - - if (count($embeds) > 0) { - $pdfEngines->embeds(...$embeds); - } - - if ($bookmarks !== '') { - $pdfEngines->bookmarks($bookmarks); - } - - if ($autoIndexBookmarks) { - $pdfEngines->autoIndexBookmarks(); - } - - if ($rotateAngle !== 0) { - $pdfEngines->rotating($rotateAngle, $rotatePages); - } - - if ($optimizeImages) { - $pdfEngines->optimizeImages($imageQuality ?? 80); - } - - if ($titleBookmarks) { - $pdfEngines->titleBookmarks(); - } - - $request = $pdfEngines->merge(...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/merge', $request->getUri()->getPath()); - - if ($pdfa !== null) { - $this->assertContainsFormValue($body, 'pdfa', $pdfa); - } - - if ($pdfua) { - $this->assertContainsFormValue($body, 'pdfua', '1'); - } - - if (count($metadata) > 0) { - $json = json_encode($metadata); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'metadata', $json); - } - - if ($flatten) { - $this->assertContainsFormValue($body, 'flatten', '1'); - } - - if ($userPassword !== '') { - $this->assertContainsFormValue($body, 'userPassword', $userPassword); - $this->assertContainsFormValue($body, 'ownerPassword', $ownerPassword); - } - - if ($bookmarks !== '') { - $this->assertContainsFormValue($body, 'bookmarks', $bookmarks); - } - - if ($autoIndexBookmarks) { - $this->assertContainsFormValue($body, 'autoIndexBookmarks', '1'); - } - - if ($rotateAngle !== 0) { - $this->assertContainsFormValue($body, 'rotateAngle', (string) $rotateAngle); - } - - if ($rotatePages !== '') { - $this->assertContainsFormValue($body, 'rotatePages', $rotatePages); - } - - if ($optimizeImages) { - $this->assertContainsFormValue($body, 'optimizeImages', '1'); - $this->assertContainsFormValue($body, 'imageQuality', (string) ($imageQuality ?? 80)); - } - - if ($titleBookmarks) { - $this->assertContainsFormValue($body, 'titleBookmarks', '1'); - } - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, 'foo_' . $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - - foreach ($embeds as $embed) { - $embed->getStream()->rewind(); - $this->assertContainsFormFile($body, $embed->getFilename(), $embed->getStream()->getContents(), 'application/xml', 'embeds'); - } - } - - /** @return array, string|null, bool, array|bool|float|int|string>, bool, string, string, array, string, bool, int, string, bool, int|null, bool}> */ - public static function provideMergeData(): array - { - return [ - 'basic' => [ - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ], - 'full_options' => [ - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - Stream::string('my_third.pdf', 'Third PDF content'), - ], - 'PDF/A-1a', - true, - ['Producer' => 'Gotenberg'], - true, - 'my_user_password', - 'my_owner_password', - [ - Stream::string('my.xml', 'XML content'), - Stream::string('my_second.xml', 'Second XML content'), - ], - '[{"title":"Chapter 1","page":1}]', - true, - 90, - '1-3,5', - true, - 60, - true, - ], - ]; - } - - /** - * @param Stream[] $pdfs - * @param array> $metadata - * @param Stream[] $embeds - * @param array $watermarkOptions - * @param array $stampOptions - */ - #[Test] - #[DataProvider('provideSplitData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_split_endpoint( - array $pdfs, - SplitMode $mode, - string|null $pdfa = null, - bool $pdfua = false, - array $metadata = [], - bool $flatten = false, - string $userPassword = '', - string $ownerPassword = '', - array $embeds = [], - string $watermarkSource = '', - string $watermarkExpression = '', - string $watermarkPages = '', - array $watermarkOptions = [], - Stream|null $watermarkFile = null, - string $stampSource = '', - string $stampExpression = '', - string $stampPages = '', - array $stampOptions = [], - Stream|null $stampFile = null, - int $rotateAngle = 0, - string $rotatePages = '', - bool $optimizeImages = false, - int|null $imageQuality = null, - ): void { - $pdfEngines = Gotenberg::pdfEngines(''); - - if ($pdfa !== null) { - $pdfEngines->pdfa($pdfa); - } - - if ($pdfua) { - $pdfEngines->pdfua(); - } - - if (count($metadata) > 0) { - $pdfEngines->metadata($metadata); - } - - if ($flatten) { - $pdfEngines->flattening(); - } - - if ($userPassword !== '') { - $pdfEngines->encrypting($userPassword, $ownerPassword); - } - - if (count($embeds) > 0) { - $pdfEngines->embeds(...$embeds); - } - - if ($watermarkSource !== '') { - $pdfEngines->watermarking($watermarkSource, $watermarkExpression, $watermarkPages, $watermarkOptions); - } - - if ($watermarkFile !== null) { - $pdfEngines->watermarkFile($watermarkFile); - } - - if ($stampSource !== '') { - $pdfEngines->stamping($stampSource, $stampExpression, $stampPages, $stampOptions); - } - - if ($stampFile !== null) { - $pdfEngines->stampFile($stampFile); - } - - if ($rotateAngle !== 0) { - $pdfEngines->rotating($rotateAngle, $rotatePages); - } - - if ($optimizeImages) { - $pdfEngines->optimizeImages($imageQuality ?? 80); - } - - $request = $pdfEngines->split($mode, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/split', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'splitMode', $mode->mode); - $this->assertContainsFormValue($body, 'splitSpan', $mode->span); - $this->assertContainsFormValue($body, 'splitUnify', $mode->unify ? '1' : '0'); - - if ($pdfa !== null) { - $this->assertContainsFormValue($body, 'pdfa', $pdfa); - } - - if ($pdfua) { - $this->assertContainsFormValue($body, 'pdfua', '1'); - } - - if (count($metadata) > 0) { - $json = json_encode($metadata); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'metadata', $json); - } - - if ($flatten) { - $this->assertContainsFormValue($body, 'flatten', '1'); - } - - if ($userPassword !== '') { - $this->assertContainsFormValue($body, 'userPassword', $userPassword); - $this->assertContainsFormValue($body, 'ownerPassword', $ownerPassword); - } - - if ($watermarkSource !== '') { - $this->assertContainsFormValue($body, 'watermarkSource', $watermarkSource); - } - - if ($watermarkExpression !== '') { - $this->assertContainsFormValue($body, 'watermarkExpression', $watermarkExpression); - } - - if ($watermarkPages !== '') { - $this->assertContainsFormValue($body, 'watermarkPages', $watermarkPages); - } - - if (count($watermarkOptions) > 0) { - $json = json_encode($watermarkOptions); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'watermarkOptions', $json); - } - - if ($stampSource !== '') { - $this->assertContainsFormValue($body, 'stampSource', $stampSource); - } - - if ($stampExpression !== '') { - $this->assertContainsFormValue($body, 'stampExpression', $stampExpression); - } - - if ($stampPages !== '') { - $this->assertContainsFormValue($body, 'stampPages', $stampPages); - } - - if (count($stampOptions) > 0) { - $json = json_encode($stampOptions); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'stampOptions', $json); - } - - if ($rotateAngle !== 0) { - $this->assertContainsFormValue($body, 'rotateAngle', (string) $rotateAngle); - } - - if ($rotatePages !== '') { - $this->assertContainsFormValue($body, 'rotatePages', $rotatePages); - } - - if ($optimizeImages) { - $this->assertContainsFormValue($body, 'optimizeImages', '1'); - $this->assertContainsFormValue($body, 'imageQuality', (string) ($imageQuality ?? 80)); - } - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - - foreach ($embeds as $embed) { - $embed->getStream()->rewind(); - $this->assertContainsFormFile($body, $embed->getFilename(), $embed->getStream()->getContents(), 'application/xml', 'embeds'); - } - - if ($watermarkFile !== null) { - $watermarkFile->getStream()->rewind(); - $this->assertContainsFormFile($body, $watermarkFile->getFilename(), $watermarkFile->getStream()->getContents(), 'application/pdf', 'watermark'); - } - - if ($stampFile === null) { - return; - } - - $stampFile->getStream()->rewind(); - $this->assertContainsFormFile($body, $stampFile->getFilename(), $stampFile->getStream()->getContents(), 'application/pdf', 'stamp'); - } - - /** - * @return array, - * SplitMode, - * string|null, - * bool, - * array>, - * bool, - * string, - * string, - * array, - * string, - * string, - * string, - * array, - * Stream|null, - * string, - * string, - * string, - * array, - * Stream|null, - * int, - * string, - * bool, - * int|null - * }> - */ - public static function provideSplitData(): array - { - return [ - 'intervals' => [ - [ - Stream::string('my.pdf', 'PDF content'), - ], - SplitMode::intervals(1), - ], - 'pages_full_options' => [ - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - Stream::string('my_third.pdf', 'Third PDF content'), - ], - SplitMode::pages('1-2', true), - 'PDF/A-1a', - true, - ['Producer' => 'Gotenberg'], - true, - 'my_user_password', - 'my_owner_password', - [ - Stream::string('my.xml', 'XML content'), - Stream::string('my_second.xml', 'Second XML content'), - ], - 'my_watermark_source', - 'my_watermark_expression', - '1-2', - ['key' => 'value'], - Stream::string('my_watermark.pdf', 'Watermark content'), - 'my_stamp_source', - 'my_stamp_expression', - '3-4', - ['key' => 'value'], - Stream::string('my_stamp.pdf', 'Stamp content'), - 180, - '1-3', - true, - 60, - ], - ]; - } - - #[Test] - #[DataProvider('provideConvertData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_convert_endpoint(string $pdfa, bool $pdfua, Stream ...$pdfs): void - { - $pdfEngines = Gotenberg::pdfEngines(''); - - if ($pdfua) { - $pdfEngines->pdfua(); - } - - $request = $pdfEngines->convert($pdfa, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/convert', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'pdfa', $pdfa); - - if ($pdfua) { - $this->assertContainsFormValue($body, 'pdfua', '1'); - } - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - } - - /** @return array */ - public static function provideConvertData(): array - { - return [ - 'basic' => [ - 'PDF/A-1a', - false, - Stream::string('my.pdf', 'PDF content'), - ], - 'with_pdfua' => [ - 'PDF/A-1a', - true, - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ]; - } - - /** @param Stream[] $pdfs */ - #[Test] - #[DataProvider('provideFlattenData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_flatten_endpoint(array $pdfs): void - { - $pdfEngines = Gotenberg::pdfEngines(''); - - $request = $pdfEngines->flatten(...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/flatten', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'flatten', '1'); - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - } - - /** @return array}> */ - public static function provideFlattenData(): array - { - return [ - 'basic' => [ - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ], - ]; - } - - /** @param Stream[] $pdfs */ - #[Test] - #[DataProvider('provideReadMetadataData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_metadata_read_endpoint(array $pdfs): void - { - $pdfEngines = Gotenberg::pdfEngines(''); - - $request = $pdfEngines->readMetadata(...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/metadata/read', $request->getUri()->getPath()); - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - } - - /** @return array}> */ - public static function provideReadMetadataData(): array - { - return [ - 'basic' => [ - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ], - ]; - } - - /** - * @param array> $metadata - * @param Stream[] $pdfs - */ - #[Test] - #[DataProvider('provideWriteMetadataData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_metadata_write_endpoint(array $metadata, array $pdfs): void - { - $pdfEngines = Gotenberg::pdfEngines(''); - - $request = $pdfEngines->writeMetadata($metadata, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/metadata/write', $request->getUri()->getPath()); - - $json = json_encode($metadata); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'metadata', $json); - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - } - - /** @return array>, array}> */ - public static function provideWriteMetadataData(): array - { - return [ - 'basic' => [ - ['Producer' => 'Gotenberg'], - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ], - ]; - } - - /** @param Stream[] $pdfs */ - #[Test] - #[DataProvider('provideEncryptData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_encrypt_endpoint(string $userPassword, string $ownerPassword, array $pdfs): void - { - $pdfEngines = Gotenberg::pdfEngines(''); - - $request = $pdfEngines->encrypt($userPassword, $ownerPassword, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/encrypt', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'userPassword', $userPassword); - $this->assertContainsFormValue($body, 'ownerPassword', $ownerPassword); - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - } - - /** @return array}> */ - public static function provideEncryptData(): array - { - return [ - 'basic' => [ - 'my_user_password', - 'my_owner_password', - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ], - ]; - } - - /** - * @param Stream[] $pdfs - * @param Stream[] $embeds - * @param EmbedMetadata[] $embedsMetadata - */ - #[Test] - #[DataProvider('provideEmbedData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_embed_endpoint( - array $embeds, - array $pdfs, - array $embedsMetadata = [], - ): void { - $pdfEngines = Gotenberg::pdfEngines(''); - - if (count($embedsMetadata) > 0) { - $pdfEngines->embedsMetadata(...$embedsMetadata); - } - - $request = $pdfEngines->embed($embeds, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/embed', $request->getUri()->getPath()); - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - - foreach ($embeds as $embed) { - $embed->getStream()->rewind(); - $this->assertContainsFormFile($body, $embed->getFilename(), $embed->getStream()->getContents(), 'application/xml', 'embeds'); - } - - if (count($embedsMetadata) === 0) { - return; - } - - $map = []; - foreach ($embedsMetadata as $entry) { - $map[$entry->filename] = $entry; - } - - $json = json_encode($map); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'embedsMetadata', $json); - } - - /** @return array, 1: array, 2?: array}> */ - public static function provideEmbedData(): array - { - return [ - 'basic' => [ - [ - Stream::string('my.xml', 'XML content'), - Stream::string('my_second.xml', 'Second XML content'), - ], - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ], - 'with_metadata' => [ - [ - Stream::string('my.xml', 'XML content'), - Stream::string('my_second.xml', 'Second XML content'), - ], - [ - Stream::string('my.pdf', 'PDF content'), - ], - [ - new EmbedMetadata('my.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_DATA), - new EmbedMetadata('my_second.xml', 'text/xml', EmbedMetadata::RELATIONSHIP_ALTERNATIVE), - ], - ], - ]; - } - - /** - * @param Stream[] $pdfs - * @param array $options - */ - #[Test] - #[DataProvider('provideWatermarkData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_watermark_endpoint( - string $source, - array $pdfs, - string $expression = '', - string $pages = '', - array $options = [], - Stream|null $watermarkFile = null, - ): void { - $pdfEngines = Gotenberg::pdfEngines(''); - - if ($expression !== '') { - $pdfEngines->watermarking($source, $expression, $pages, $options); - } - - if ($watermarkFile !== null) { - $pdfEngines->watermarkFile($watermarkFile); - } - - $request = $pdfEngines->watermark($source, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/watermark', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'watermarkSource', $source); - - if ($expression !== '') { - $this->assertContainsFormValue($body, 'watermarkExpression', $expression); - } - - if ($pages !== '') { - $this->assertContainsFormValue($body, 'watermarkPages', $pages); - } - - if (count($options) > 0) { - $json = json_encode($options); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'watermarkOptions', $json); - } - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - - if ($watermarkFile === null) { - return; - } - - $watermarkFile->getStream()->rewind(); - $this->assertContainsFormFile($body, $watermarkFile->getFilename(), $watermarkFile->getStream()->getContents(), 'application/pdf', 'watermark'); - } - - /** @return array, 2?: string, 3?: string, 4?: array, 5?: Stream}> */ - public static function provideWatermarkData(): array - { - return [ - 'basic' => [ - 'my_source', - [ - Stream::string('my.pdf', 'PDF content'), - ], - ], - 'full_options' => [ - 'my_source', - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - 'my_expression', - '1-2', - ['key' => 'value'], - Stream::string('my_watermark.pdf', 'Watermark content'), - ], - ]; - } - - /** - * @param Stream[] $pdfs - * @param array $options - */ - #[Test] - #[DataProvider('provideStampData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_stamp_endpoint( - string $source, - array $pdfs, - string $expression = '', - string $pages = '', - array $options = [], - Stream|null $stampFile = null, - ): void { - $pdfEngines = Gotenberg::pdfEngines(''); - - if ($expression !== '') { - $pdfEngines->stamping($source, $expression, $pages, $options); - } - - if ($stampFile !== null) { - $pdfEngines->stampFile($stampFile); - } - - $request = $pdfEngines->stamp($source, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/stamp', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'stampSource', $source); - - if ($expression !== '') { - $this->assertContainsFormValue($body, 'stampExpression', $expression); - } - - if ($pages !== '') { - $this->assertContainsFormValue($body, 'stampPages', $pages); - } - - if (count($options) > 0) { - $json = json_encode($options); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertContainsFormValue($body, 'stampOptions', $json); - } - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - - if ($stampFile === null) { - return; - } - - $stampFile->getStream()->rewind(); - $this->assertContainsFormFile($body, $stampFile->getFilename(), $stampFile->getStream()->getContents(), 'application/pdf', 'stamp'); - } - - /** @return array, 2?: string, 3?: string, 4?: array, 5?: Stream}> */ - public static function provideStampData(): array - { - return [ - 'basic' => [ - 'my_source', - [ - Stream::string('my.pdf', 'PDF content'), - ], - ], - 'full_options' => [ - 'my_source', - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - 'my_expression', - '1-2', - ['key' => 'value'], - Stream::string('my_stamp.pdf', 'Stamp content'), - ], - ]; - } - - /** @param Stream[] $pdfs */ - #[Test] - #[DataProvider('provideRotateData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_rotate_endpoint( - int $angle, - array $pdfs, - string $pages = '', - ): void { - $pdfEngines = Gotenberg::pdfEngines(''); - - if ($pages !== '') { - $pdfEngines->rotating($angle, $pages); - } - - $request = $pdfEngines->rotate($angle, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/rotate', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'rotateAngle', (string) $angle); - - if ($pages !== '') { - $this->assertContainsFormValue($body, 'rotatePages', $pages); - } - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - } - - /** @return array, 2?: string}> */ - public static function provideRotateData(): array - { - return [ - 'basic' => [ - 90, - [ - Stream::string('my.pdf', 'PDF content'), - ], - ], - 'full_options' => [ - 270, - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - '1-3,5', - ], - ]; - } - - /** @param Stream[] $pdfs */ - #[Test] - #[DataProvider('provideReadBookmarksData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_bookmarks_read_endpoint(array $pdfs): void - { - $pdfEngines = Gotenberg::pdfEngines(''); - - $request = $pdfEngines->readBookmarks(...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/bookmarks/read', $request->getUri()->getPath()); - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - } - - /** @return array}> */ - public static function provideReadBookmarksData(): array - { - return [ - 'basic' => [ - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ], - ]; - } - - /** @param Stream[] $pdfs */ - #[Test] - #[DataProvider('provideWriteBookmarksData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_bookmarks_write_endpoint(string $bookmarks, array $pdfs): void - { - $pdfEngines = Gotenberg::pdfEngines(''); - - $request = $pdfEngines->writeBookmarks($bookmarks, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/bookmarks/write', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'bookmarks', $bookmarks); - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - } - - /** @return array}> */ - public static function provideWriteBookmarksData(): array - { - return [ - 'basic' => [ - '[{"title":"Chapter 1","page":1}]', - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ], - ]; - } - - /** @param Stream[] $pdfs */ - #[Test] - #[DataProvider('provideFacturXData')] - public function it_creates_a_valid_request_for_the_forms_pdfengines_factur_x_endpoint( - FacturX $facturX, - array $pdfs, - ): void { - $pdfEngines = Gotenberg::pdfEngines(''); - - $request = $pdfEngines->injectFacturX($facturX, ...$pdfs); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/factur-x', $request->getUri()->getPath()); - - $facturX->xml->getStream()->rewind(); - $this->assertContainsFormFile($body, $facturX->xml->getFilename(), $facturX->xml->getStream()->getContents(), 'application/xml', 'facturxXml'); - $this->assertContainsFormValue($body, 'facturxConformanceLevel', $facturX->conformanceLevel); - $this->assertContainsFormValue($body, 'facturxDocumentType', $facturX->documentType); - $this->assertContainsFormValue($body, 'facturxVersion', $facturX->version); - - foreach ($pdfs as $pdf) { - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - } - - /** @return array}> */ - public static function provideFacturXData(): array - { - return [ - 'basic' => [ - new FacturX( - Stream::string('factur-x.xml', 'XML content'), - FacturX::CONFORMANCE_EN_16931, - ), - [ - Stream::string('my.pdf', 'PDF content'), - Stream::string('my_second.pdf', 'Second PDF content'), - ], - ], - ]; - } - - #[Test] - public function it_creates_a_valid_request_with_encryption_permissions(): void - { - $pdf = Stream::string('my.pdf', 'PDF content'); - - $request = Gotenberg::pdfEngines('') - ->allowPrinting(false) - ->allowCopying(false) - ->allowModifying(false) - ->allowAnnotating() - ->allowFillingForms(false) - ->allowAssembling(false) - ->encrypt('', 'my_owner_password', $pdf); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/encrypt', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'allowPrinting', '0'); - $this->assertContainsFormValue($body, 'allowCopying', '0'); - $this->assertContainsFormValue($body, 'allowModifying', '0'); - $this->assertContainsFormValue($body, 'allowAnnotating', '1'); - $this->assertContainsFormValue($body, 'allowFillingForms', '0'); - $this->assertContainsFormValue($body, 'allowAssembling', '0'); - $this->assertContainsFormValue($body, 'ownerPassword', 'my_owner_password'); - } - - #[Test] - public function it_creates_a_valid_request_for_the_forms_pdfengines_optimize_endpoint(): void - { - $pdf = Stream::string('my.pdf', 'PDF content'); - - $request = Gotenberg::pdfEngines('') - ->optimize(70, $pdf); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/optimize', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'imageQuality', '70'); - - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); - } - - #[Test] - public function it_creates_a_valid_request_with_multiple_stamps(): void - { - $logo = Stream::string('logo.png', 'PNG content'); - $pdf = Stream::string('my.pdf', 'PDF content'); - - $request = Gotenberg::pdfEngines('') - ->stamping('text', 'CONFIDENTIAL', '1-2', ['rot' => '45']) - ->stamping('image', file: $logo) - ->stamps($pdf); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/stamp', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'stampSource', 'text'); - $this->assertContainsFormValue($body, 'stampSource', 'image'); - $this->assertContainsFormValue($body, 'stampExpression', 'CONFIDENTIAL'); - $this->assertContainsFormValue($body, 'stampPages', '1-2'); - $this->assertContainsFormValue($body, 'stampOptions', '{"rot":"45"}'); - - $logo->getStream()->rewind(); - $this->assertContainsFormFile($body, 'logo.png', 'PNG content', null, 'stamp'); - - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, 'my.pdf', 'PDF content', 'application/pdf'); - } - - #[Test] - public function it_creates_a_valid_request_with_multiple_watermarks(): void - { - $logo = Stream::string('logo.png', 'PNG content'); - $pdf = Stream::string('my.pdf', 'PDF content'); - - $request = Gotenberg::pdfEngines('') - ->watermarking('text', 'DRAFT', '1-2', ['rot' => '45']) - ->watermarking('image', file: $logo) - ->watermarks($pdf); - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertSame('/forms/pdfengines/watermark', $request->getUri()->getPath()); - $this->assertContainsFormValue($body, 'watermarkSource', 'text'); - $this->assertContainsFormValue($body, 'watermarkSource', 'image'); - $this->assertContainsFormValue($body, 'watermarkExpression', 'DRAFT'); - $this->assertContainsFormValue($body, 'watermarkPages', '1-2'); - $this->assertContainsFormValue($body, 'watermarkOptions', '{"rot":"45"}'); - - $logo->getStream()->rewind(); - $this->assertContainsFormFile($body, 'logo.png', 'PNG content', null, 'watermark'); - - $pdf->getStream()->rewind(); - $this->assertContainsFormFile($body, 'my.pdf', 'PDF content', 'application/pdf'); - } -} diff --git a/tests/Modules/PdfTest.php b/tests/Modules/PdfTest.php new file mode 100644 index 0000000..ce2c42b --- /dev/null +++ b/tests/Modules/PdfTest.php @@ -0,0 +1,132 @@ + $values + * @param list $files + */ + #[Test] + #[DataProvider('provideRequestData')] + public function it_creates_a_valid_request(RequestInterface $request, string $endpoint, array $values, array $files = []): void + { + $this->assertRequest($request, $endpoint, $values, $files); + } + + /** @return iterable, 3?: list}> */ + public static function provideRequestData(): iterable + { + yield 'pipe' => [ + Gotenberg::pdf('https://my.url') + ->index(new DummyIndex()) + ->stages( + Stage::merge(), + Stage::stampText('DRAFT'), + Stage::stampPdf(Stream::string('letterhead.pdf', 'Letterhead content')), + ) + ->pipe(Stream::string('report.pdf', 'Report content'), Stream::string('annex.pdf', 'Annex content')), + '/forms/pdf/pipe', + [['stages', '[{"op":"merge"},{"op":"stamp-text","text":"DRAFT"},{"op":"stamp-pdf","source":"letterhead.pdf"}]']], + [ + ['files', 'letterhead.pdf', 'Letterhead content', 'application/pdf'], + ['files', '0_report.pdf', 'Report content', 'application/pdf'], + ['files', '1_annex.pdf', 'Annex content', 'application/pdf'], + ], + ]; + + yield 'pipe without merge keeps the names' => [ + Gotenberg::pdf('https://my.url') + ->stages(Stage::splitEvery(1)) + ->pipe(Stream::string('report.pdf', 'Report content')), + '/forms/pdf/pipe', + [['stages', '[{"op":"split","every":1}]']], + [['files', 'report.pdf', 'Report content', 'application/pdf']], + ]; + + yield 'run merge' => [ + Gotenberg::pdf('https://my.url') + ->index(new DummyIndex()) + ->run(Stage::merge(), Stream::string('b.pdf', 'B content'), Stream::string('a.pdf', 'A content')), + '/forms/pdf/merge', + [], + [ + ['files', '0_b.pdf', 'B content', 'application/pdf'], + ['files', '1_a.pdf', 'A content', 'application/pdf'], + ], + ]; + + yield 'run stamp image' => [ + Gotenberg::pdf('https://my.url') + ->run( + Stage::stampImage(Stream::string('logo.png', 'Logo content'), pages: '1', opacity: 0.5), + Stream::string('report.pdf', 'Report content'), + ), + '/forms/pdf/stamp-image', + [ + ['image', 'logo.png'], + ['pages', '1'], + ['opacity', '0.5'], + ], + [ + ['files', 'logo.png', 'Logo content', 'image/png'], + ['files', 'report.pdf', 'Report content', 'application/pdf'], + ], + ]; + + yield 'run encrypt' => [ + Gotenberg::pdf('https://my.url') + ->run(Stage::encrypt('user', permissions: ['print' => false]), Stream::string('report.pdf', 'Report content')), + '/forms/pdf/encrypt', + [ + ['userPassword', 'user'], + ['permissions', '{"print":false}'], + ], + [['files', 'report.pdf', 'Report content', 'application/pdf']], + ]; + + foreach (['info', 'metadata', 'bookmarks', 'attachments'] as $route) { + yield $route => [ + Gotenberg::pdf('https://my.url') + ->password('secret') + ->{$route}(Stream::string('report.pdf', 'Report content')), + '/forms/pdf/' . $route, + [['password', 'secret']], + [['files', 'report.pdf', 'Report content', 'application/pdf']], + ]; + } + + yield 'form' => [ + Gotenberg::pdf('https://my.url')->form('1-2', Stream::string('fields.pdf', 'Form content')), + '/forms/pdf/form', + [['pages', '1-2']], + [['files', 'fields.pdf', 'Form content', 'application/pdf']], + ]; + + yield 'validate a profile' => [ + Gotenberg::pdf('https://my.url')->validate('PDF/A-3b', Stream::string('invoice.pdf', 'Invoice content')), + '/forms/pdf/validate', + [['profile', 'PDF/A-3b']], + [['files', 'invoice.pdf', 'Invoice content', 'application/pdf']], + ]; + + yield 'validate a pair of profiles' => [ + Gotenberg::pdf('https://my.url')->validate(['PDF/A-2b', 'PDF/UA-1'], Stream::string('archive.pdf', 'Archive content')), + '/forms/pdf/validate', + [['profile', '["PDF\/A-2b","PDF\/UA-1"]']], + [['files', 'archive.pdf', 'Archive content', 'application/pdf']], + ]; + } +} diff --git a/tests/MultipartFormDataModuleTest.php b/tests/MultipartFormDataModuleTest.php index a68fb86..71ce0d6 100644 --- a/tests/MultipartFormDataModuleTest.php +++ b/tests/MultipartFormDataModuleTest.php @@ -5,141 +5,40 @@ namespace Gotenberg\Test; use Gotenberg\DownloadFrom; -use Gotenberg\Exceptions\NativeFunctionErrored; use Gotenberg\Stream; use Gotenberg\Test\Helpers\Dummies\DummyMultipartFormDataModule; use PHPUnit\Framework\Attributes\Test; -use function json_encode; - final class MultipartFormDataModuleTest extends TestCase { #[Test] - public function it_creates_a_valid_request_with_given_http_headers(): void + public function it_creates_a_valid_request_with_the_common_fields_and_headers(): void { $dummy = new DummyMultipartFormDataModule('https://my.url/'); $request = $dummy ->outputFilename('my_filename') - ->downloadFrom([ + ->downloadFrom( new DownloadFrom('https://my.url/my_filename'), - new DownloadFrom('https://my.url/my_filename_2', ['X-Header' => 'value'], true), - new DownloadFrom('https://my.url/my_filename_3', null, false, DownloadFrom::FIELD_WATERMARK), - ]) - ->webhook('https://my.webhook.url', 'https://my.webhook.error.url') - ->webhookMethod('POST') - ->webhookErrorMethod('PUT') - ->webhookExtraHttpHeaders([ - 'My-Webhook-Http-Header' => 'HTTP header content', - 'My-Second-Webhook-Http-Header' => 'Second HTTP header content', - ]) - ->webhookEventsUrl('https://my.webhook.events.url') + new DownloadFrom('https://my.url/my_filename_2', ['X-Header' => 'value']), + ) + ->archives(Stream::string('site.zip', 'zip content')) + ->webhook('https://my.webhook.url', 'https://my.webhook.events.url') + ->webhookMethod('PUT') + ->webhookExtraHttpHeaders(['My-Webhook-Http-Header' => 'HTTP header content']) ->build(); - // Assert Output Filename Header + $this->assertStringStartsWith('multipart/form-data; boundary=', $request->getHeaderLine('Content-Type')); $this->assertSame(['my_filename'], $request->getHeader('Gotenberg-Output-Filename')); - - // Assert DownloadFrom Form Value - $body = $this->sanitize($request->getBody()->getContents()); - $expectedJson = '[{"url":"https:\/\/my.url\/my_filename","embedded":false},{"url":"https:\/\/my.url\/my_filename_2","embedded":true,"extraHttpHeaders":{"X-Header":"value"}},{"url":"https:\/\/my.url\/my_filename_3","embedded":false,"field":"watermark"}]'; - - $this->assertContainsFormValue($body, 'downloadFrom', $expectedJson); - - // Assert Webhook Headers $this->assertSame(['https://my.webhook.url'], $request->getHeader('Gotenberg-Webhook-Url')); - $this->assertSame(['https://my.webhook.error.url'], $request->getHeader('Gotenberg-Webhook-Error-Url')); - $this->assertSame(['POST'], $request->getHeader('Gotenberg-Webhook-Method')); - $this->assertSame(['PUT'], $request->getHeader('Gotenberg-Webhook-Error-Method')); - - // Assert Webhook Extra Http Headers - $webhookHeaders = [ - 'My-Webhook-Http-Header' => 'HTTP header content', - 'My-Second-Webhook-Http-Header' => 'Second HTTP header content', - ]; - - $json = json_encode($webhookHeaders); - if ($json === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $this->assertSame([$json], $request->getHeader('Gotenberg-Webhook-Extra-Http-Headers')); - - // Assert Webhook Events Url Header $this->assertSame(['https://my.webhook.events.url'], $request->getHeader('Gotenberg-Webhook-Events-Url')); - } - - #[Test] - public function it_creates_a_valid_request_with_webhook_without_error_url(): void - { - $dummy = new DummyMultipartFormDataModule('https://my.url/'); - $request = $dummy - ->webhook('https://my.webhook.url') - ->webhookEventsUrl('https://my.webhook.events.url') - ->build(); - - $this->assertSame(['https://my.webhook.url'], $request->getHeader('Gotenberg-Webhook-Url')); - $this->assertEmpty($request->getHeader('Gotenberg-Webhook-Error-Url')); - $this->assertSame(['https://my.webhook.events.url'], $request->getHeader('Gotenberg-Webhook-Events-Url')); - } - - #[Test] - public function it_creates_a_valid_request_with_watermarking(): void - { - $dummy = new DummyMultipartFormDataModule('https://my.url/'); - $request = $dummy - ->watermarking('my_source', 'my_expression', '1-2', ['key' => 'value']) - ->watermarkFile(Stream::string('my_watermark.pdf', 'Watermark content')) - ->build(); - - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertContainsFormValue($body, 'watermarkSource', 'my_source'); - $this->assertContainsFormValue($body, 'watermarkExpression', 'my_expression'); - $this->assertContainsFormValue($body, 'watermarkPages', '1-2'); - $this->assertContainsFormValue($body, 'watermarkOptions', '{"key":"value"}'); - $this->assertContainsFormFile($body, 'my_watermark.pdf', 'Watermark content', 'application/pdf', 'watermark'); - } - - #[Test] - public function it_creates_a_valid_request_with_watermarking_source_only(): void - { - $dummy = new DummyMultipartFormDataModule('https://my.url/'); - $request = $dummy - ->watermarking('my_source') - ->build(); - - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertContainsFormValue($body, 'watermarkSource', 'my_source'); - } - - #[Test] - public function it_creates_a_valid_request_with_stamping(): void - { - $dummy = new DummyMultipartFormDataModule('https://my.url/'); - $request = $dummy - ->stamping('my_source', 'my_expression', '1-2', ['key' => 'value']) - ->stampFile(Stream::string('my_stamp.pdf', 'Stamp content')) - ->build(); - - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertContainsFormValue($body, 'stampSource', 'my_source'); - $this->assertContainsFormValue($body, 'stampExpression', 'my_expression'); - $this->assertContainsFormValue($body, 'stampPages', '1-2'); - $this->assertContainsFormValue($body, 'stampOptions', '{"key":"value"}'); - $this->assertContainsFormFile($body, 'my_stamp.pdf', 'Stamp content', 'application/pdf', 'stamp'); - } - - #[Test] - public function it_creates_a_valid_request_with_stamping_source_only(): void - { - $dummy = new DummyMultipartFormDataModule('https://my.url/'); - $request = $dummy - ->stamping('my_source') - ->build(); - - $body = $this->sanitize($request->getBody()->getContents()); - - $this->assertContainsFormValue($body, 'stampSource', 'my_source'); + $this->assertSame(['PUT'], $request->getHeader('Gotenberg-Webhook-Method')); + $this->assertSame(['{"My-Webhook-Http-Header":"HTTP header content"}'], $request->getHeader('Gotenberg-Webhook-Extra-Http-Headers')); + + $this->assertRequest( + $request, + '/foo', + [['downloadFrom', '[{"url":"https:\/\/my.url\/my_filename"},{"url":"https:\/\/my.url\/my_filename_2","extraHttpHeaders":{"X-Header":"value"}}]']], + [['archives', 'site.zip', 'zip content']], + ); } } diff --git a/tests/SplitModeTest.php b/tests/SplitModeTest.php deleted file mode 100644 index 722e359..0000000 --- a/tests/SplitModeTest.php +++ /dev/null @@ -1,31 +0,0 @@ -assertSame('intervals', $mode->mode); - $this->assertSame('1', $mode->span); - $this->assertFalse($mode->unify); - } - - #[Test] - public function it_creates_a_pages_split_mode(): void - { - $mode = SplitMode::pages('1-2', true); - - $this->assertSame('pages', $mode->mode); - $this->assertSame('1-2', $mode->span); - $this->assertTrue($mode->unify); - } -} diff --git a/tests/StageTest.php b/tests/StageTest.php new file mode 100644 index 0000000..348583c --- /dev/null +++ b/tests/StageTest.php @@ -0,0 +1,159 @@ + $formFields + * @param list $files + */ + #[Test] + #[DataProvider('provideStageData')] + public function it_serializes_a_stage(Stage $stage, string $json, array $formFields, array $files = []): void + { + $this->assertSame($json, json_encode($stage, JSON_THROW_ON_ERROR)); + $this->assertSame($formFields, $stage->formFields()); + $this->assertSame($files, array_map(static fn (Stream $file): string => $file->filename, $stage->files())); + } + + /** @return iterable, 3?: list}> */ + public static function provideStageData(): iterable + { + $pdf = Stream::string('letterhead.pdf', 'PDF'); + $image = Stream::string('logo.png', 'PNG'); + $xml = Stream::string('invoice.xml', 'XML'); + $cert = Stream::string('cert.pem', 'CERT'); + $key = Stream::string('key.pem', 'KEY'); + $file = Stream::string('data.csv', 'CSV'); + + yield 'merge' => [Stage::merge(), '{"op":"merge"}', []]; + yield 'split every' => [Stage::splitEvery(2), '{"op":"split","every":2}', ['every' => '2']]; + yield 'split ranges' => [Stage::splitRanges('1', '2-3'), '{"op":"split","ranges":["1","2-3"]}', ['ranges' => '["1","2-3"]']]; + yield 'select' => [Stage::select('1-2'), '{"op":"select","pages":"1-2"}', ['pages' => '1-2']]; + yield 'delete' => [Stage::delete('last'), '{"op":"delete","pages":"last"}', ['pages' => 'last']]; + yield 'insert' => [ + Stage::insert($pdf, 1, '2'), + '{"op":"insert","source":"letterhead.pdf","at":1,"sourcePages":"2"}', + ['source' => 'letterhead.pdf', 'at' => '1', 'sourcePages' => '2'], + ['letterhead.pdf'], + ]; + + yield 'rotate' => [Stage::rotate(90), '{"op":"rotate","angle":90}', ['angle' => '90']]; + yield 'stamp text' => [ + Stage::stampText('DRAFT', 'all', 72, 72.5, 24, 'Helvetica-Bold', '#ff0000', 0.5, 45), + '{"op":"stamp-text","text":"DRAFT","pages":"all","x":72,"y":72.5,"fontSize":24,"font":"Helvetica-Bold","color":"#ff0000","opacity":0.5,"rotation":45}', + ['text' => 'DRAFT', 'pages' => 'all', 'x' => '72', 'y' => '72.5', 'fontSize' => '24', 'font' => 'Helvetica-Bold', 'color' => '#ff0000', 'opacity' => '0.5', 'rotation' => '45'], + ]; + + yield 'stamp image' => [ + Stage::stampImage($image, width: 100), + '{"op":"stamp-image","image":"logo.png","width":100}', + ['image' => 'logo.png', 'width' => '100'], + ['logo.png'], + ]; + + yield 'stamp pdf' => [ + Stage::stampPdf($pdf, 1, '1', 0, 0, 1, 1, 0), + '{"op":"stamp-pdf","source":"letterhead.pdf","sourcePage":1,"pages":"1","x":0,"y":0,"scale":1,"opacity":1,"rotation":0}', + ['source' => 'letterhead.pdf', 'sourcePage' => '1', 'pages' => '1', 'x' => '0', 'y' => '0', 'scale' => '1', 'opacity' => '1', 'rotation' => '0'], + ['letterhead.pdf'], + ]; + + yield 'set metadata' => [ + Stage::setMetadata('Title', 'Author', 'Subject', 'Keywords', 'Creator', 'Producer', 'D:20260101120000Z', 'D:20260102120000Z'), + '{"op":"set-metadata","title":"Title","author":"Author","subject":"Subject","keywords":"Keywords","creator":"Creator","producer":"Producer","creationDate":"D:20260101120000Z","modDate":"D:20260102120000Z"}', + ['title' => 'Title', 'author' => 'Author', 'subject' => 'Subject', 'keywords' => 'Keywords', 'creator' => 'Creator', 'producer' => 'Producer', 'creationDate' => 'D:20260101120000Z', 'modDate' => 'D:20260102120000Z'], + ]; + + yield 'set metadata partially' => [Stage::setMetadata(title: 'Title'), '{"op":"set-metadata","title":"Title"}', ['title' => 'Title']]; + yield 'set bookmarks' => [ + Stage::setBookmarks([['title' => 'Summary', 'pageNumber' => 1, 'children' => [['title' => 'Figures', 'pageNumber' => 2]]]]), + '{"op":"set-bookmarks","bookmarks":[{"title":"Summary","pageNumber":1,"children":[{"title":"Figures","pageNumber":2}]}]}', + ['bookmarks' => '[{"title":"Summary","pageNumber":1,"children":[{"title":"Figures","pageNumber":2}]}]'], + ]; + + yield 'set form' => [ + Stage::setForm(['agree' => 'Yes', 'colors' => ['Red', 'Green']]), + '{"op":"set-form","fields":{"agree":"Yes","colors":["Red","Green"]}}', + ['fields' => '{"agree":"Yes","colors":["Red","Green"]}'], + ]; + + yield 'flatten form' => [Stage::flattenForm(), '{"op":"flatten-form"}', []]; + yield 'flatten transparency' => [Stage::flattenTransparency(), '{"op":"flatten-transparency"}', []]; + yield 'convert colors' => [Stage::convertColors('cmyk'), '{"op":"convert-colors","target":"cmyk"}', ['target' => 'cmyk']]; + yield 'redact' => [ + Stage::redact([['page' => 1, 'x' => 160, 'y' => 435, 'width' => 60, 'height' => 25]]), + '{"op":"redact","regions":[{"page":1,"x":160,"y":435,"width":60,"height":25}]}', + ['regions' => '[{"page":1,"x":160,"y":435,"width":60,"height":25}]'], + ]; + + yield 'autotag' => [Stage::autotag(), '{"op":"autotag"}', []]; + yield 'attach' => [ + Stage::attach($file, 'data.csv', 'text/csv', 'Data'), + '{"op":"attach","file":"data.csv","name":"data.csv","mimeType":"text\/csv","relationship":"Data"}', + ['file' => 'data.csv', 'name' => 'data.csv', 'mimeType' => 'text/csv', 'relationship' => 'Data'], + ['data.csv'], + ]; + + yield 'detach' => [Stage::detach('data.csv'), '{"op":"detach","name":"data.csv"}', ['name' => 'data.csv']]; + yield 'optimize' => [Stage::optimize(), '{"op":"optimize"}', []]; + yield 'optimize without font subsetting' => [ + Stage::optimize(subsetFonts: false, dedup: true), + '{"op":"optimize","dedup":true,"subsetFonts":false}', + ['dedup' => 'true', 'subsetFonts' => 'false'], + ]; + + yield 'encrypt' => [ + Stage::encrypt('user', 'owner', 'aes256', ['print' => true, 'extractText' => false]), + '{"op":"encrypt","userPassword":"user","ownerPassword":"owner","algorithm":"aes256","permissions":{"print":true,"extractText":false}}', + ['userPassword' => 'user', 'ownerPassword' => 'owner', 'algorithm' => 'aes256', 'permissions' => '{"print":true,"extractText":false}'], + ]; + + yield 'decrypt' => [Stage::decrypt('secret'), '{"op":"decrypt","password":"secret"}', ['password' => 'secret']]; + yield 'decrypt without password' => [Stage::decrypt(), '{"op":"decrypt"}', []]; + yield 'remediate' => [ + Stage::remediate(['PDF/A-2b', 'PDF/UA-1'], 'en-US', true, 'A chart'), + '{"op":"remediate","profile":["PDF\/A-2b","PDF\/UA-1"],"language":"en-US","aggressive":true,"figureAltText":"A chart"}', + ['profile' => '["PDF\/A-2b","PDF\/UA-1"]', 'language' => 'en-US', 'aggressive' => 'true', 'figureAltText' => 'A chart'], + ]; + + yield 'remediate factur-x' => [ + Stage::remediate('Factur-X', xml: $xml, level: 'EN 16931', documentType: 'INVOICE'), + '{"op":"remediate","profile":"Factur-X","xml":"invoice.xml","level":"EN 16931","documentType":"INVOICE"}', + ['profile' => 'Factur-X', 'xml' => 'invoice.xml', 'level' => 'EN 16931', 'documentType' => 'INVOICE'], + ['invoice.xml'], + ]; + + yield 'sign' => [ + Stage::sign($cert, $key, 'pass', 'Approval', 'Paris', 'me@example.com', 'Me', true, 'https://tsa.example.com'), + '{"op":"sign","certificate":"cert.pem","key":"key.pem","certificatePassword":"pass","reason":"Approval","location":"Paris","contactInfo":"me@example.com","name":"Me","pades":true,"timestampUrl":"https:\/\/tsa.example.com"}', + ['certificate' => 'cert.pem', 'key' => 'key.pem', 'certificatePassword' => 'pass', 'reason' => 'Approval', 'location' => 'Paris', 'contactInfo' => 'me@example.com', 'name' => 'Me', 'pades' => 'true', 'timestampUrl' => 'https://tsa.example.com'], + ['cert.pem', 'key.pem'], + ]; + + yield 'timestamp' => [ + Stage::timestamp('https://tsa.example.com'), + '{"op":"timestamp","timestampUrl":"https:\/\/tsa.example.com"}', + ['timestampUrl' => 'https://tsa.example.com'], + ]; + + yield 'render' => [ + Stage::render('1', 300, 'jpeg', 80), + '{"op":"render","pages":"1","dpi":300,"format":"jpeg","quality":80}', + ['pages' => '1', 'dpi' => '300', 'format' => 'jpeg', 'quality' => '80'], + ]; + } +} diff --git a/tests/StreamTest.php b/tests/StreamTest.php index 3e7977b..dbdccbe 100644 --- a/tests/StreamTest.php +++ b/tests/StreamTest.php @@ -7,39 +7,24 @@ use Gotenberg\Stream; use PHPUnit\Framework\Attributes\Test; -use function file_exists; -use function file_put_contents; -use function unlink; - final class StreamTest extends TestCase { #[Test] public function it_creates_a_stream_from_a_string(): void { $stream = Stream::string('my.txt', 'My content'); - $stream->getStream()->rewind(); - $this->assertSame('my.txt', $stream->getFilename()); - $this->assertSame('My content', $stream->getStream()->getContents()); + $this->assertSame('my.txt', $stream->filename); + $this->assertSame('My content', $stream->stream->getContents()); } #[Test] public function it_creates_a_stream_from_a_path(): void { - // Create a temporary dummy file to ensure the test passes reliably - $path = __DIR__ . '/dummy.txt'; - file_put_contents($path, 'Dummy content'); - - try { - $stream = Stream::path($path); + $path = __DIR__ . '/Helpers/Dummies/dummy.txt'; - $this->assertSame('dummy.txt', $stream->getFilename()); - $this->assertSame('Dummy content', $stream->getStream()->getContents()); - } finally { - // Cleanup: remove the file after the test - if (file_exists($path)) { - unlink($path); - } - } + $this->assertSame('dummy.txt', Stream::path($path)->filename); + $this->assertSame('other.txt', Stream::path($path, 'other.txt')->filename); + $this->assertNotSame('', Stream::path($path)->stream->getContents()); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index f01f54d..e1efdc5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,10 +4,10 @@ namespace Gotenberg\Test; -use Gotenberg\Exceptions\NativeFunctionErrored; use Gotenberg\Test\Helpers\Constraints\FormFileConstraint; use Gotenberg\Test\Helpers\Constraints\FormValueConstraint; use PHPUnit\Framework\TestCase as BaseTestCase; +use Psr\Http\Message\RequestInterface; use function preg_replace; use function trim; @@ -16,12 +16,30 @@ abstract class TestCase extends BaseTestCase { protected function sanitize(string $body): string { - $sanitized = preg_replace('/\s\s+/', ' ', $body); - if ($sanitized === null) { - throw NativeFunctionErrored::createFromLastPhpError(); + return trim((string) preg_replace('/\s\s+/', ' ', $body)); + } + + /** + * Asserts the request targets $endpoint and carries the form values and + * files. + * + * @param list $values [name, value] + * @param list $files [field, filename, content, content type] + */ + protected function assertRequest(RequestInterface $request, string $endpoint, array $values = [], array $files = []): void + { + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://my.url' . $endpoint, (string) $request->getUri()); + + $body = $this->sanitize($request->getBody()->getContents()); + + foreach ($values as [$name, $value]) { + $this->assertContainsFormValue($body, $name, $value); } - return trim($sanitized); + foreach ($files as $file) { + $this->assertContainsFormFile($body, $file[1], $file[2], $file[3] ?? null, $file[0]); + } } public function assertContainsFormValue(string $body, string $name, string $value, string $message = ''): void From 0c50650284589830e5ead8d26fa8cf4196bfab6a Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Thu, 5 Nov 2026 12:04:13 +0100 Subject: [PATCH 2/6] docs: document the Gotenberg 9 client and its upgrade from v2 --- CONTRIBUTING.md | 27 ++++++----- README.md | 116 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 121 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83f92f7..200a79b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Official PHP client for [Gotenberg](https://gotenberg.dev), a Docker-based API for converting documents to PDF. Two rules override everything else: **backward compatibility** (never rename or remove public methods or change method signatures without discussion) and **PSR compliance** (build on PSR-7, PSR-17, PSR-18, never introduce hard dependencies on specific HTTP implementations). -- PHP 8.1+ (see `composer.json`) +- PHP 8.3+ (see `composer.json`) - Composer ## Quick start @@ -21,7 +21,7 @@ Run all Composer commands inside this Docker container: docker run --rm -it \ -e PHP_EXTENSION_XDEBUG=1 \ -v $(PWD):/usr/src/app/ \ - thecodingmachine/php:8.1-v4-cli \ + thecodingmachine/php:8.3-v4-cli \ bash -c "composer run all" ``` @@ -31,7 +31,9 @@ docker run --rm -it \ src/ Gotenberg.php Static factory (entry point) ApiModule.php Base trait: URL, endpoint, headers, request building - MultipartFormDataModule.php Trait: multipart form data, webhooks, downloads + MultipartFormDataModule.php Trait: multipart form data, webhooks, downloads, archives + StagesModule.php Trait: stages, merge ordering (Index) + Stage.php Stage value object: one named constructor per Gotenberg stage Modules/ Chromium.php Chromium entry point (pdf/screenshot) ChromiumPdf.php Chromium PDF conversion builder @@ -39,10 +41,9 @@ src/ ChromiumMultipartFormDataModule.php Shared Chromium trait ChromiumCookie.php Cookie value object LibreOffice.php LibreOffice conversion builder - PdfEngines.php PDF engines builder (merge, split, convert) + Pdf.php PDF builder: pipe, single-stage routes, read routes Exceptions/ Custom exceptions Stream.php File stream wrapper - SplitMode.php Split mode enum DownloadFrom.php Download-from value object tests/ TestCase.php Base class with custom assertions @@ -55,7 +56,7 @@ Start with `src/Gotenberg.php` for the public API surface, then follow into `src ### Fluent API -All configuration methods return `$this`. The builder pattern is the backbone of the public API. Keep it uniform. +All configuration methods return `$this`, typed `static`. The builder pattern is the backbone of the public API. Keep it uniform. ### Strict types @@ -63,7 +64,7 @@ Every PHP file declares `strict_types=1`. ### Multipart form data -Configuration is stored in `$this->multipartFormData` (array of `[name, value]` or `[name, value, filename]` entries). Use `$this->formValue()` and `$this->formFile()` helpers. +Configuration is stored in `$this->multipartFormData` (array of `[name, value]` or `[name, value, filename]` entries). Use `$this->formValue()` and `$this->formFile()` helpers. `formValue()` sends booleans as `true`/`false`; encode lists and objects with `json_encode(..., JSON_THROW_ON_ERROR)`. ### Coding standard @@ -73,14 +74,16 @@ Doctrine coding standard enforced via phpcs/phpcbf. Follow it strictly. Use expl For Chromium: add the method to `ChromiumPdf.php`, `ChromiumScreenshot.php`, or the shared trait `ChromiumMultipartFormDataModule.php` depending on scope. Call `$this->formValue('formFieldName', $value)` or `$this->formFile('formFieldName', $stream)`. Add a corresponding unit test with a data provider. -For LibreOffice or PdfEngines: add the method to the relevant module class. Same `formValue`/`formFile` pattern, same test requirement. +For LibreOffice or Pdf: add the method to the relevant module class. Same `formValue`/`formFile` pattern, same test requirement. + +For a new stage: add a named constructor to `Stage.php`, its options keyed by their Gotenberg names, `null` for "keep the default", and a `Stream` for a file option. `stages()` and `Pdf::run()` pick it up; add a case to `StageTest`. ## Testing -Unit tests only (no integration tests, this is a client library). PHPUnit 10.5+. Base class `Gotenberg\Test\TestCase` provides custom assertions: +Unit tests only (no integration tests, this is a client library). PHPUnit 12.4+. Base class `Gotenberg\Test\TestCase` provides custom assertions: -- `assertContainsFormValue(string $name, string $expectedValue, RequestInterface $request)`: asserts a multipart form field has the expected value. -- `assertContainsFormFile(string $name, RequestInterface $request)`: asserts a multipart form file exists. +- `assertRequest(RequestInterface $request, string $endpoint, array $values, array $files)`: asserts the endpoint, the form values (`[name, value]`) and the form files (`[field, filename, content, content type]`). +- `assertContainsFormValue()` and `assertContainsFormFile()`: the single checks `assertRequest()` runs. Tests follow a data provider pattern: a static method returns an array of test cases (description, `RequestInterface` built via the fluent API, expected form values/files), and the test method runs assertions against each. @@ -106,6 +109,6 @@ Plan non-trivial changes before coding. Present what needs to change, why, which ### Commits -[Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`. Scope should match the module (e.g., `chromium`, `libreoffice`, `pdfengines`). +[Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`. Scope should match the module (e.g., `chromium`, `libreoffice`, `pdf`, `stage`). Stage specific files. Never `git add -A` or `git add .`. diff --git a/README.md b/README.md index 935edeb..0007b24 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ PDF files, and more! | Gotenberg version | Client | |---------------------|---------------------------------------------------------------------------------------------------| -| `8.x` **(current)** | `v2.x` **(current)** | +| `9.x` **(current)** | `v3.x` **(current)** | +| `8.x` | `v2.x` | | `7.x` | `v1.x` | | `6.x` | [thecodingmachine/gotenberg-php-client](https://github.com/thecodingmachine/gotenberg-php-client) | @@ -58,7 +59,7 @@ $response = Gotenberg::send( ## Requirement -This packages requires [Gotenberg](https://gotenberg.dev), a containerized API for seamless PDF conversion. +This package requires PHP 8.3 or later, and [Gotenberg](https://gotenberg.dev) 9, a containerized API for seamless PDF conversion. See the [installation guide](https://gotenberg.dev/docs/getting-started/installation) for more information. @@ -92,7 +93,7 @@ use Gotenberg\Gotenberg; Gotenberg::chromium($apiUrl); Gotenberg::libreOffice($apiUrl); -Gotenberg::pdfEngines($apiUrl); +Gotenberg::pdf($apiUrl); ``` Each of these modules offers a variety of methods to populate a *multipart/form-data* request. @@ -106,7 +107,7 @@ use Gotenberg\Gotenberg; Gotenberg::chromium($apiUrl) ->pdf() // Or screenshot(). ->singlePage() // Optional. - ->url('https://my.url')); + ->url('https://my.url'); ``` > [!TIP] @@ -132,14 +133,78 @@ Gotenberg::chromium($apiUrl) Gotenberg::libreOffice($apiUrl) ->convert(new Stream('document.docx', $stream)); +// Or upload a ZIP archive that Gotenberg unpacks. +Gotenberg::chromium($apiUrl) + ->pdf() + ->archives(Stream::path($pathToSiteZip)) + ->html(null); + // Or even tell Gotenberg to download the files for you. Gotenberg::libreOffice($apiUrl) - ->downloadFrom([ + ->downloadFrom( new DownloadFrom('https://url.to.document.docx', ['MyHeader' => 'MyValue']) - ]) + ) ->convert(); ``` +## Stages + +A stage is one operation on a PDF: merge, split, stamp, redact, sign, make PDF/A, and so on. Gotenberg runs them in +order on the PDFs a Chromium or LibreOffice conversion makes: + +```php +use Gotenberg\Gotenberg; +use Gotenberg\Stage; +use Gotenberg\Stream; + +Gotenberg::libreOffice($apiUrl) + ->stages( + Stage::merge(), + Stage::stampText('DRAFT', x: 72, y: 72, fontSize: 24), + Stage::remediate('PDF/A-2b'), + ) + ->convert( + Stream::path($pathToDocx), + Stream::path($pathToXlsx) + ); +``` + +A stage that reads a file, such as an image to stamp or a certificate, takes a `Stream`: the client uploads it for you. +With a `merge` stage, the documents are merged in the order of the arguments. See [Run stages](https://gotenberg.dev/docs/pdf/pipe) +for every stage and its order rules. + +The `pdf` module runs stages on the PDFs you upload, reads them, and validates them: + +```php +use Gotenberg\Gotenberg; +use Gotenberg\Stage; +use Gotenberg\Stream; + +// Runs a list of stages, through /forms/pdf/pipe. +Gotenberg::pdf($apiUrl) + ->stages( + Stage::select('1-2'), + Stage::stampImage(Stream::path($pathToLogo), x: 72, y: 72, width: 100), + Stage::optimize(), + ) + ->pipe(Stream::path($pathToPdf)); + +// Runs one stage, through its own route: /forms/pdf/merge, /forms/pdf/sign, and so on. +Gotenberg::pdf($apiUrl) + ->run(Stage::merge(), Stream::path($pathToPdf1), Stream::path($pathToPdf2)); + +Gotenberg::pdf($apiUrl) + ->run(Stage::sign(Stream::path($pathToP12), certificatePassword: 'secret'), Stream::path($pathToPdf)); + +// Reads the PDFs; Gotenberg answers JSON, one entry per PDF. +Gotenberg::pdf($apiUrl)->info(Stream::path($pathToPdf)); +Gotenberg::pdf($apiUrl)->metadata(Stream::path($pathToPdf)); +Gotenberg::pdf($apiUrl)->bookmarks(Stream::path($pathToPdf)); +Gotenberg::pdf($apiUrl)->attachments(Stream::path($pathToPdf)); +Gotenberg::pdf($apiUrl)->form(null, Stream::path($pathToPdf)); +Gotenberg::pdf($apiUrl)->validate(['PDF/A-2b', 'PDF/UA-1'], Stream::path($pathToPdf)); +``` + ## Send a request to the API After having created the HTTP request, you have two options: @@ -203,8 +268,9 @@ $request = Gotenberg::chromium($apiUrl) $filename = Gotenberg::save($request, '/path/to/saving/directory'); ``` -It returns the filename of the resulting file. By default, Gotenberg creates a *UUID* filename (i.e., -`95cd9945-484f-4f89-8bdb-23dbdd0bdea9`) with either a `.zip` or a `.pdf` file extension (or image formats for screenshots). +It returns the filename of the resulting file, as the `Content-Disposition` header names it. Without +`outputFilename()`, Gotenberg names the result after its source (`report.docx` gives `report.docx.pdf`), or picks a +*UUID* for a web page or a ZIP archive. You may also explicitly set the HTTP client: @@ -277,5 +343,35 @@ try { } ``` -> [!NOTE] -> The `trace()` and `getGotenbergTrace()` methods are deprecated but still available for backward compatibility. +### Webhook + +You may tell Gotenberg to upload the result to a webhook instead of answering with it. Gotenberg then sends a +`webhook.success` or `webhook.error` event to the events URL: + +```php +use Gotenberg\Gotenberg; + +$request = Gotenberg::chromium($apiUrl) + ->pdf() + ->webhook('https://my.webhook.url', 'https://my.webhook.url/events') + ->webhookMethod('PUT') // Optional, default POST. + ->webhookExtraHttpHeaders(['Authorization' => 'Bearer …']) // Optional. + ->url('https://my.url'); +``` + +## Upgrade from v2 + +The client follows the Gotenberg 9 API: + +* `Gotenberg::pdfEngines()` becomes `Gotenberg::pdf()`: each route is a stage, run with `run()` or `stages()->pipe()`. +* The post-processing methods of the conversions (`merge()`, `split()`, `pdfa()`, `pdfua()`, `metadata()`, `flatten()`, + `encrypt()`, `allow*()`, `facturX()`, `embeds()`, `optimizeImages()`, `watermarking()`, `stamping()`, `rotating()`) + become stages. `SplitMode`, `FacturX` and `EmbedMetadata` give way to `Stage`. +* Fields with an engine twin take the `native` prefix: `nativePages()`, `nativeBookmarks()`, `nativeTaggedPdf()`, + `nativePdfa()`, `nativePdfua()`, `nativeQuality()`, `nativeWatermark*()`, and so on. `paperSize()` takes a name + (`A4`, `Letter`…), `paperDimensions()` the width and height. +* `webhook()` takes the events URL, which Gotenberg requires; the error webhook is gone. +* `downloadFrom()`, `cookies()` and the `failOn*()` methods are variadic, `emulatedMediaFeatures()` takes an array. +* `Stream` exposes `filename` and `stream` as readonly properties. +* JSON encoding errors throw `JsonException`. +* The deprecated `trace()` and `getGotenbergTrace()` are removed: use `correlationId()` and `getCorrelationId()`. From 905b9bc9fe11bd26b72111700d2f5eb41d9463b2 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Thu, 5 Nov 2026 12:12:16 +0100 Subject: [PATCH 3/6] feat: add enums for the known option values --- src/ColorSpace.php | 17 ++++ src/ConsoleMessageType.php | 18 +++++ src/EncryptionAlgorithm.php | 16 ++++ src/FacturXDocumentType.php | 16 ++++ src/FacturXLevel.php | 18 +++++ src/Font.php | 26 ++++++ src/InitialView.php | 15 ++++ src/Magnification.php | 17 ++++ src/Modules/ChromiumCookie.php | 5 +- .../ChromiumMultipartFormDataModule.php | 6 +- src/Modules/ChromiumPdf.php | 9 ++- src/Modules/LibreOffice.php | 54 +++++++------ src/Modules/Pdf.php | 11 +-- src/MultipartFormDataModule.php | 10 +-- src/PageLayout.php | 16 ++++ src/Pages.php | 17 ++++ src/PaperSize.php | 20 +++++ src/Permission.php | 19 +++++ src/Profile.php | 26 ++++++ src/Relationship.php | 17 ++++ src/RenderFormat.php | 14 ++++ src/SameSite.php | 15 ++++ src/Stage.php | 80 ++++++++++--------- src/WebhookMethod.php | 15 ++++ tests/Modules/ChromiumPdfTest.php | 9 ++- tests/Modules/LibreOfficeTest.php | 27 ++++--- tests/Modules/PdfTest.php | 8 +- tests/MultipartFormDataModuleTest.php | 3 +- tests/StageTest.php | 32 +++++--- 29 files changed, 444 insertions(+), 112 deletions(-) create mode 100644 src/ColorSpace.php create mode 100644 src/ConsoleMessageType.php create mode 100644 src/EncryptionAlgorithm.php create mode 100644 src/FacturXDocumentType.php create mode 100644 src/FacturXLevel.php create mode 100644 src/Font.php create mode 100644 src/InitialView.php create mode 100644 src/Magnification.php create mode 100644 src/PageLayout.php create mode 100644 src/Pages.php create mode 100644 src/PaperSize.php create mode 100644 src/Permission.php create mode 100644 src/Profile.php create mode 100644 src/Relationship.php create mode 100644 src/RenderFormat.php create mode 100644 src/SameSite.php create mode 100644 src/WebhookMethod.php diff --git a/src/ColorSpace.php b/src/ColorSpace.php new file mode 100644 index 0000000..9e89be4 --- /dev/null +++ b/src/ColorSpace.php @@ -0,0 +1,17 @@ +sameSite !== null) { - $serialized['sameSite'] = $this->sameSite; + $serialized['sameSite'] = $this->sameSite->value; } return $serialized; diff --git a/src/Modules/ChromiumMultipartFormDataModule.php b/src/Modules/ChromiumMultipartFormDataModule.php index 920dcbd..9e7db6f 100644 --- a/src/Modules/ChromiumMultipartFormDataModule.php +++ b/src/Modules/ChromiumMultipartFormDataModule.php @@ -4,6 +4,7 @@ namespace Gotenberg\Modules; +use Gotenberg\ConsoleMessageType; use Gotenberg\MultipartFormDataModule; use Gotenberg\Stream; use Psr\Http\Message\RequestInterface; @@ -195,10 +196,9 @@ public function failOnConsoleExceptions(): static /** * Forces Gotenberg to return a 409 Conflict response if the Chromium - * console logs messages of the given types (log, debug, info, warning, - * error, assert). + * console logs messages of the given types. */ - public function failOnConsoleMessageTypes(string ...$types): static + public function failOnConsoleMessageTypes(ConsoleMessageType ...$types): static { $this->formValue('failOnConsoleMessageTypes', json_encode(array_values($types), JSON_THROW_ON_ERROR)); diff --git a/src/Modules/ChromiumPdf.php b/src/Modules/ChromiumPdf.php index 6d0f235..51e4180 100644 --- a/src/Modules/ChromiumPdf.php +++ b/src/Modules/ChromiumPdf.php @@ -4,6 +4,7 @@ namespace Gotenberg\Modules; +use Gotenberg\PaperSize; use Gotenberg\StagesModule; use Gotenberg\Stream; use Psr\Http\Message\RequestInterface; @@ -24,12 +25,12 @@ public function singlePage(): static } /** - * Sets the paper size: "A3", "A4", "A5", "B4", "B5", "Letter" (default), - * "Legal" or "Tabloid". paperDimensions() overrides it. + * Sets the paper size; default PaperSize::Letter. paperDimensions() + * overrides it. */ - public function paperSize(string $size): static + public function paperSize(PaperSize $size): static { - $this->formValue('paperSize', $size); + $this->formValue('paperSize', $size->value); return $this; } diff --git a/src/Modules/LibreOffice.php b/src/Modules/LibreOffice.php index 9edb6c1..0a21b01 100644 --- a/src/Modules/LibreOffice.php +++ b/src/Modules/LibreOffice.php @@ -4,7 +4,12 @@ namespace Gotenberg\Modules; +use Gotenberg\InitialView; +use Gotenberg\Magnification; use Gotenberg\MultipartFormDataModule; +use Gotenberg\PageLayout; +use Gotenberg\PaperSize; +use Gotenberg\Profile; use Gotenberg\StagesModule; use Gotenberg\Stream; use Psr\Http\Message\RequestInterface; @@ -25,13 +30,12 @@ public function password(string $password): static } /** - * Sets the paper size of every page: "A3", "A4", "A5", "B4", "B5", - * "Letter", "Legal" or "Tabloid". Without it, each document keeps its own - * page size. + * Sets the paper size of every page. Without it, each document keeps its + * own page size. */ - public function paperSize(string $size): static + public function paperSize(PaperSize $size): static { - $this->formValue('paperSize', $size); + $this->formValue('paperSize', $size->value); return $this; } @@ -352,22 +356,23 @@ public function nativeTiledWatermarkText(string $text): static } /** - * Exports PDF/A: "PDF/A-1b", "PDF/A-2b", "PDF/A-3b" or "PDF/A-4". The - * remediate stage reaches the other PDF/A levels. + * Exports PDF/A: Profile::PdfA1b, PdfA2b, PdfA3b or PdfA4; Gotenberg + * answers 400 to the others. The remediate stage reaches the other PDF/A + * levels. */ - public function nativePdfa(string $profile): static + public function nativePdfa(Profile $profile): static { - $this->formValue('nativePdfa', $profile); + $this->formValue('nativePdfa', $profile->value); return $this; } /** - * Exports PDF/UA: "PDF/UA-1". + * Exports PDF/UA-1, the only PDF/UA profile LibreOffice writes. */ - public function nativePdfua(string $profile): static + public function nativePdfua(): static { - $this->formValue('nativePdfua', $profile); + $this->formValue('nativePdfua', Profile::PdfUa1->value); return $this; } @@ -383,12 +388,11 @@ public function nativeTaggedPdf(): static } /** - * Specifies how the document shall be displayed when opened: "default", - * "outline" or "thumbnails". + * Specifies how the document shall be displayed when opened. */ - public function initialView(string $view): static + public function initialView(InitialView $view): static { - $this->formValue('initialView', $view); + $this->formValue('initialView', $view->value); return $this; } @@ -404,19 +408,18 @@ public function initialPage(int $page): static } /** - * Specifies the magnification when the document opens: "default", - * "fitVisible", "fitWidth", "fitPage" or "fitActualSize". + * Specifies the magnification when the document opens. */ - public function magnification(string $magnification): static + public function magnification(Magnification $magnification): static { - $this->formValue('magnification', $magnification); + $this->formValue('magnification', $magnification->value); return $this; } /** - * Specifies the zoom when the document opens, with the magnification - * "fitActualSize". + * Specifies the zoom, in percent, when the document opens, with + * Magnification::Zoom. */ public function zoom(int $zoom): static { @@ -426,12 +429,11 @@ public function zoom(int $zoom): static } /** - * Specifies the page layout when the document opens: "default", - * "singlePage", "continuous" or "continuousFacing". + * Specifies the page layout when the document opens. */ - public function pageLayout(string $layout): static + public function pageLayout(PageLayout $layout): static { - $this->formValue('pageLayout', $layout); + $this->formValue('pageLayout', $layout->value); return $this; } diff --git a/src/Modules/Pdf.php b/src/Modules/Pdf.php index 84b1d6e..8d9533e 100644 --- a/src/Modules/Pdf.php +++ b/src/Modules/Pdf.php @@ -5,6 +5,7 @@ namespace Gotenberg\Modules; use Gotenberg\MultipartFormDataModule; +use Gotenberg\Profile; use Gotenberg\Stage; use Gotenberg\StagesModule; use Gotenberg\Stream; @@ -111,15 +112,15 @@ public function form(string|null $pages, Stream ...$pdfs): RequestInterface } /** - * Checks the PDFs against a profile, such as "PDF/A-3b", "PDF/UA-1" or - * "Factur-X", or against a PDF/A profile and a PDF/UA one together. + * Checks the PDFs against a profile, or against a PDF/A profile, or + * Factur-X, and a PDF/UA one together. * Gotenberg returns JSON, one entry per PDF under its file name. * - * @param string|list $profile + * @param Profile|list $profile */ - public function validate(string|array $profile, Stream ...$pdfs): RequestInterface + public function validate(Profile|array $profile, Stream ...$pdfs): RequestInterface { - $this->formValue('profile', is_array($profile) ? json_encode($profile, JSON_THROW_ON_ERROR) : $profile); + $this->formValue('profile', is_array($profile) ? json_encode($profile, JSON_THROW_ON_ERROR) : $profile->value); return $this->read('validate', ...$pdfs); } diff --git a/src/MultipartFormDataModule.php b/src/MultipartFormDataModule.php index 2b7de3f..c92bb0b 100644 --- a/src/MultipartFormDataModule.php +++ b/src/MultipartFormDataModule.php @@ -70,14 +70,12 @@ public function webhook(string $url, string $eventsUrl): static } /** - * Overrides the default HTTP method that Gotenberg uses to upload the - * result to the webhook. - * - * Either "POST", "PATCH", or "PUT" - default "POST". + * Overrides the default HTTP method (POST) that Gotenberg uses to upload + * the result to the webhook. */ - public function webhookMethod(string $method): static + public function webhookMethod(WebhookMethod $method): static { - $this->headers['Gotenberg-Webhook-Method'] = $method; + $this->headers['Gotenberg-Webhook-Method'] = $method->value; return $this; } diff --git a/src/PageLayout.php b/src/PageLayout.php new file mode 100644 index 0000000..8214ef6 --- /dev/null +++ b/src/PageLayout.php @@ -0,0 +1,16 @@ + + * @phpstan-type Option string|int|float|bool|Stream|BackedEnum|array */ final readonly class Stage implements JsonSerializable { @@ -56,10 +56,11 @@ public static function splitEvery(int $every): self } /** - * Cuts each PDF into one part per page selection, such as "1-3" or "4,6". + * Cuts each PDF into one part per page selection, such as "1-3", "4,6" or + * Pages::Odd. * The stages after it run on each part. */ - public static function splitRanges(string ...$ranges): self + public static function splitRanges(string|Pages ...$ranges): self { return self::of('split', ['ranges' => array_values($ranges)]); } @@ -67,7 +68,7 @@ public static function splitRanges(string ...$ranges): self /** * Keeps the pages you list, in the order you list them. */ - public static function select(string $pages): self + public static function select(string|Pages $pages): self { return self::of('select', ['pages' => $pages]); } @@ -75,7 +76,7 @@ public static function select(string $pages): self /** * Removes the pages you list. */ - public static function delete(string $pages): self + public static function delete(string|Pages $pages): self { return self::of('delete', ['pages' => $pages]); } @@ -84,7 +85,7 @@ public static function delete(string $pages): self * Inserts the pages of another PDF after page $at; 0 inserts before the * first page. */ - public static function insert(Stream $source, int|null $at = null, string|null $sourcePages = null): self + public static function insert(Stream $source, int|null $at = null, string|Pages|null $sourcePages = null): self { return self::of('insert', [ 'source' => $source, @@ -97,7 +98,7 @@ public static function insert(Stream $source, int|null $at = null, string|null $ * Sets the rotation of pages, a multiple of 90. Viewers turn them * clockwise. */ - public static function rotate(int $angle, string|null $pages = null): self + public static function rotate(int $angle, string|Pages|null $pages = null): self { return self::of('rotate', ['angle' => $angle, 'pages' => $pages]); } @@ -109,11 +110,11 @@ public static function rotate(int $angle, string|null $pages = null): self */ public static function stampText( string $text, - string|null $pages = null, + string|Pages|null $pages = null, float|null $x = null, float|null $y = null, float|null $fontSize = null, - string|null $font = null, + Font|null $font = null, string|null $color = null, float|null $opacity = null, float|null $rotation = null, @@ -137,7 +138,7 @@ public static function stampText( */ public static function stampImage( Stream $image, - string|null $pages = null, + string|Pages|null $pages = null, float|null $x = null, float|null $y = null, float|null $width = null, @@ -163,7 +164,7 @@ public static function stampImage( public static function stampPdf( Stream $source, int|null $sourcePage = null, - string|null $pages = null, + string|Pages|null $pages = null, float|null $x = null, float|null $y = null, float|null $scale = null, @@ -246,9 +247,9 @@ public static function flattenTransparency(): self } /** - * Converts every color to "cmyk", "rgb", "gray", or "output-intent". + * Converts every color to one color space. */ - public static function convertColors(string $target): self + public static function convertColors(ColorSpace $target): self { return self::of('convert-colors', ['target' => $target]); } @@ -273,14 +274,14 @@ public static function autotag(): self } /** - * Embeds a file in the PDF. $relationship is "Source", "Data", - * "Alternative", "Supplement" or "Unspecified". + * Embeds a file in the PDF. $relationship makes it a PDF/A-3 associated + * file. */ public static function attach( Stream $file, string|null $name = null, string|null $mimeType = null, - string|null $relationship = null, + Relationship|null $relationship = null, ): self { return self::of('attach', [ 'file' => $file, @@ -317,19 +318,25 @@ public static function optimize( } /** - * Protects the PDF with passwords. $algorithm is "aes256", "aes128", - * "rc4-128" or "rc4-40". $permissions sets what a reader without the owner - * password may do: print, printHighQuality, modifyContent, extractText, - * modifyAnnotations, fillForms, assemble. + * Protects the PDF with passwords. A reader without the owner password may + * do everything but what $deny lists. * - * @param array|null $permissions + * @param list|null $deny */ public static function encrypt( string|null $userPassword = null, string|null $ownerPassword = null, - string|null $algorithm = null, - array|null $permissions = null, + EncryptionAlgorithm|null $algorithm = null, + array|null $deny = null, ): self { + $permissions = null; + if ($deny !== null && $deny !== []) { + $permissions = []; + foreach ($deny as $permission) { + $permissions[$permission->value] = false; + } + } + return self::of('encrypt', [ 'userPassword' => $userPassword, 'ownerPassword' => $ownerPassword, @@ -347,24 +354,22 @@ public static function decrypt(string|null $password = null): self } /** - * Repairs the PDF until it conforms to a profile, such as "PDF/A-2b", - * "PDF/UA-1" or "Factur-X", or to a PDF/A profile and a PDF/UA one - * together. + * Repairs the PDF until it conforms to a profile, or to a PDF/A profile, + * or Factur-X, and a PDF/UA one together. * - * For Factur-X, $xml embeds an invoice XML, with its $level ("MINIMUM", - * "BASIC WL", "BASIC", "EN 16931", "EXTENDED" or "XRECHNUNG") and + * For Factur-X, $xml embeds an invoice XML, with its $level and * $documentType. * - * @param string|list $profile + * @param Profile|list $profile */ public static function remediate( - string|array $profile, + Profile|array $profile, string|null $language = null, bool|null $aggressive = null, string|null $figureAltText = null, Stream|null $xml = null, - string|null $level = null, - string|null $documentType = null, + FacturXLevel|null $level = null, + FacturXDocumentType|null $documentType = null, ): self { return self::of('remediate', [ 'profile' => $profile, @@ -414,12 +419,12 @@ public static function timestamp(string $timestampUrl): self } /** - * Draws pages as "png" or "jpeg" images. + * Draws pages as images. */ public static function render( - string|null $pages = null, + string|Pages|null $pages = null, int|null $dpi = null, - string|null $format = null, + RenderFormat|null $format = null, int|null $quality = null, ): self { return self::of('render', [ @@ -453,6 +458,7 @@ public function formFields(): array return array_map(static function (mixed $value): string { return match (true) { $value instanceof Stream => $value->filename, + $value instanceof BackedEnum => (string) $value->value, is_bool($value) => $value ? 'true' : 'false', is_array($value) => json_encode($value, JSON_THROW_ON_ERROR), default => (string) $value, diff --git a/src/WebhookMethod.php b/src/WebhookMethod.php new file mode 100644 index 0000000..9bcd697 --- /dev/null +++ b/src/WebhookMethod.php @@ -0,0 +1,15 @@ +pdf() ->singlePage() - ->paperSize('A4') + ->paperSize(PaperSize::A4) ->paperDimensions(8.27, '11.7in') ->margins(1, 2, '3mm', 4) ->preferCssPageSize() @@ -52,7 +55,7 @@ public static function provideRequestData(): iterable ->waitForWindowStatus('ready') ->emulatePrintMediaType() ->emulatedMediaFeatures(['prefers-color-scheme' => 'dark']) - ->cookies(new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com', secure: true, sameSite: 'Lax')) + ->cookies(new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com', secure: true, sameSite: SameSite::Lax)) ->userAgent('Gotenberg') ->extraHttpHeaders(['My-Http-Header' => 'HTTP header content']) ->failOnHttpStatusCodes(499, 599) @@ -60,7 +63,7 @@ public static function provideRequestData(): iterable ->ignoreResourceHttpStatusDomains('example.com') ->failOnResourceLoadingFailed() ->failOnConsoleExceptions() - ->failOnConsoleMessageTypes('error', 'warning') + ->failOnConsoleMessageTypes(ConsoleMessageType::Error, ConsoleMessageType::Warning) ->skipNetworkIdleEvent(false) ->skipNetworkAlmostIdleEvent() ->assets(Stream::string('my.jpg', 'Image content')) diff --git a/tests/Modules/LibreOfficeTest.php b/tests/Modules/LibreOfficeTest.php index eff6725..b65c210 100644 --- a/tests/Modules/LibreOfficeTest.php +++ b/tests/Modules/LibreOfficeTest.php @@ -5,6 +5,11 @@ namespace Gotenberg\Test\Modules; use Gotenberg\Gotenberg; +use Gotenberg\InitialView; +use Gotenberg\Magnification; +use Gotenberg\PageLayout; +use Gotenberg\PaperSize; +use Gotenberg\Profile; use Gotenberg\Stage; use Gotenberg\Stream; use Gotenberg\Test\Helpers\Dummies\DummyIndex; @@ -32,7 +37,7 @@ public static function provideRequestData(): iterable yield 'every option' => [ Gotenberg::libreOffice('https://my.url') ->password('my_password') - ->paperSize('A4') + ->paperSize(PaperSize::Legal) ->paperDimensions('21cm', 29.7) ->landscape() ->nativePages('my_nativePages') @@ -63,14 +68,14 @@ public static function provideRequestData(): iterable ->nativeWatermarkRotation(45.5) ->nativeWatermarkFont('my_nativeWatermarkFont') ->nativeTiledWatermarkText('my_nativeTiledWatermarkText') - ->nativePdfa('PDF/A-2b') - ->nativePdfua('PDF/UA-1') + ->nativePdfa(Profile::PdfA2b) + ->nativePdfua() ->nativeTaggedPdf() - ->initialView('my_initialView') + ->initialView(InitialView::Thumbnails) ->initialPage(2) - ->magnification('my_magnification') + ->magnification(Magnification::FitWindow) ->zoom(2) - ->pageLayout('my_pageLayout') + ->pageLayout(PageLayout::TwoColumnRight) ->openBookmarkLevels(2) ->resizeWindowToInitialPage() ->centerWindow() @@ -82,7 +87,7 @@ public static function provideRequestData(): iterable ->convert(Stream::string('my.docx', 'Word content'), Stream::string('my.xlsx', 'Excel content')), [ ['password', 'my_password'], - ['paperSize', 'A4'], + ['paperSize', 'Legal'], ['paperWidth', '21cm'], ['paperHeight', '29.7'], ['landscape', 'true'], @@ -117,11 +122,11 @@ public static function provideRequestData(): iterable ['nativePdfa', 'PDF/A-2b'], ['nativePdfua', 'PDF/UA-1'], ['nativeTaggedPdf', 'true'], - ['initialView', 'my_initialView'], + ['initialView', 'thumbnails'], ['initialPage', '2'], - ['magnification', 'my_magnification'], + ['magnification', 'fitWindow'], ['zoom', '2'], - ['pageLayout', 'my_pageLayout'], + ['pageLayout', 'twoColumnRight'], ['openBookmarkLevels', '2'], ['resizeWindowToInitialPage', 'true'], ['centerWindow', 'true'], @@ -140,7 +145,7 @@ public static function provideRequestData(): iterable yield 'merge stage keeps the order of the arguments' => [ Gotenberg::libreOffice('https://my.url') ->index(new DummyIndex()) - ->stages(Stage::merge(), Stage::remediate('PDF/A-2b')) + ->stages(Stage::merge(), Stage::remediate(Profile::PdfA2b)) ->convert(Stream::string('b.docx', 'Word content'), Stream::string('a.xlsx', 'Excel content')), [['stages', '[{"op":"merge"},{"op":"remediate","profile":"PDF\\/A-2b"}]']], [ diff --git a/tests/Modules/PdfTest.php b/tests/Modules/PdfTest.php index ce2c42b..bfd3960 100644 --- a/tests/Modules/PdfTest.php +++ b/tests/Modules/PdfTest.php @@ -5,6 +5,8 @@ namespace Gotenberg\Test\Modules; use Gotenberg\Gotenberg; +use Gotenberg\Permission; +use Gotenberg\Profile; use Gotenberg\Stage; use Gotenberg\Stream; use Gotenberg\Test\Helpers\Dummies\DummyIndex; @@ -88,7 +90,7 @@ public static function provideRequestData(): iterable yield 'run encrypt' => [ Gotenberg::pdf('https://my.url') - ->run(Stage::encrypt('user', permissions: ['print' => false]), Stream::string('report.pdf', 'Report content')), + ->run(Stage::encrypt('user', deny: [Permission::Print]), Stream::string('report.pdf', 'Report content')), '/forms/pdf/encrypt', [ ['userPassword', 'user'], @@ -116,14 +118,14 @@ public static function provideRequestData(): iterable ]; yield 'validate a profile' => [ - Gotenberg::pdf('https://my.url')->validate('PDF/A-3b', Stream::string('invoice.pdf', 'Invoice content')), + Gotenberg::pdf('https://my.url')->validate(Profile::PdfA3b, Stream::string('invoice.pdf', 'Invoice content')), '/forms/pdf/validate', [['profile', 'PDF/A-3b']], [['files', 'invoice.pdf', 'Invoice content', 'application/pdf']], ]; yield 'validate a pair of profiles' => [ - Gotenberg::pdf('https://my.url')->validate(['PDF/A-2b', 'PDF/UA-1'], Stream::string('archive.pdf', 'Archive content')), + Gotenberg::pdf('https://my.url')->validate([Profile::PdfA2b, Profile::PdfUa1], Stream::string('archive.pdf', 'Archive content')), '/forms/pdf/validate', [['profile', '["PDF\/A-2b","PDF\/UA-1"]']], [['files', 'archive.pdf', 'Archive content', 'application/pdf']], diff --git a/tests/MultipartFormDataModuleTest.php b/tests/MultipartFormDataModuleTest.php index 71ce0d6..8d91278 100644 --- a/tests/MultipartFormDataModuleTest.php +++ b/tests/MultipartFormDataModuleTest.php @@ -7,6 +7,7 @@ use Gotenberg\DownloadFrom; use Gotenberg\Stream; use Gotenberg\Test\Helpers\Dummies\DummyMultipartFormDataModule; +use Gotenberg\WebhookMethod; use PHPUnit\Framework\Attributes\Test; final class MultipartFormDataModuleTest extends TestCase @@ -23,7 +24,7 @@ public function it_creates_a_valid_request_with_the_common_fields_and_headers(): ) ->archives(Stream::string('site.zip', 'zip content')) ->webhook('https://my.webhook.url', 'https://my.webhook.events.url') - ->webhookMethod('PUT') + ->webhookMethod(WebhookMethod::Put) ->webhookExtraHttpHeaders(['My-Webhook-Http-Header' => 'HTTP header content']) ->build(); diff --git a/tests/StageTest.php b/tests/StageTest.php index 348583c..8197c74 100644 --- a/tests/StageTest.php +++ b/tests/StageTest.php @@ -4,6 +4,16 @@ namespace Gotenberg\Test; +use Gotenberg\ColorSpace; +use Gotenberg\EncryptionAlgorithm; +use Gotenberg\FacturXDocumentType; +use Gotenberg\FacturXLevel; +use Gotenberg\Font; +use Gotenberg\Pages; +use Gotenberg\Permission; +use Gotenberg\Profile; +use Gotenberg\Relationship; +use Gotenberg\RenderFormat; use Gotenberg\Stage; use Gotenberg\Stream; use PHPUnit\Framework\Attributes\DataProvider; @@ -41,9 +51,9 @@ public static function provideStageData(): iterable yield 'merge' => [Stage::merge(), '{"op":"merge"}', []]; yield 'split every' => [Stage::splitEvery(2), '{"op":"split","every":2}', ['every' => '2']]; - yield 'split ranges' => [Stage::splitRanges('1', '2-3'), '{"op":"split","ranges":["1","2-3"]}', ['ranges' => '["1","2-3"]']]; + yield 'split ranges' => [Stage::splitRanges('1', '2-3', Pages::Odd), '{"op":"split","ranges":["1","2-3","odd"]}', ['ranges' => '["1","2-3","odd"]']]; yield 'select' => [Stage::select('1-2'), '{"op":"select","pages":"1-2"}', ['pages' => '1-2']]; - yield 'delete' => [Stage::delete('last'), '{"op":"delete","pages":"last"}', ['pages' => 'last']]; + yield 'delete' => [Stage::delete(Pages::Last), '{"op":"delete","pages":"last"}', ['pages' => 'last']]; yield 'insert' => [ Stage::insert($pdf, 1, '2'), '{"op":"insert","source":"letterhead.pdf","at":1,"sourcePages":"2"}', @@ -53,7 +63,7 @@ public static function provideStageData(): iterable yield 'rotate' => [Stage::rotate(90), '{"op":"rotate","angle":90}', ['angle' => '90']]; yield 'stamp text' => [ - Stage::stampText('DRAFT', 'all', 72, 72.5, 24, 'Helvetica-Bold', '#ff0000', 0.5, 45), + Stage::stampText('DRAFT', Pages::All, 72, 72.5, 24, Font::HelveticaBold, '#ff0000', 0.5, 45), '{"op":"stamp-text","text":"DRAFT","pages":"all","x":72,"y":72.5,"fontSize":24,"font":"Helvetica-Bold","color":"#ff0000","opacity":0.5,"rotation":45}', ['text' => 'DRAFT', 'pages' => 'all', 'x' => '72', 'y' => '72.5', 'fontSize' => '24', 'font' => 'Helvetica-Bold', 'color' => '#ff0000', 'opacity' => '0.5', 'rotation' => '45'], ]; @@ -93,7 +103,7 @@ public static function provideStageData(): iterable yield 'flatten form' => [Stage::flattenForm(), '{"op":"flatten-form"}', []]; yield 'flatten transparency' => [Stage::flattenTransparency(), '{"op":"flatten-transparency"}', []]; - yield 'convert colors' => [Stage::convertColors('cmyk'), '{"op":"convert-colors","target":"cmyk"}', ['target' => 'cmyk']]; + yield 'convert colors' => [Stage::convertColors(ColorSpace::Cmyk), '{"op":"convert-colors","target":"cmyk"}', ['target' => 'cmyk']]; yield 'redact' => [ Stage::redact([['page' => 1, 'x' => 160, 'y' => 435, 'width' => 60, 'height' => 25]]), '{"op":"redact","regions":[{"page":1,"x":160,"y":435,"width":60,"height":25}]}', @@ -102,7 +112,7 @@ public static function provideStageData(): iterable yield 'autotag' => [Stage::autotag(), '{"op":"autotag"}', []]; yield 'attach' => [ - Stage::attach($file, 'data.csv', 'text/csv', 'Data'), + Stage::attach($file, 'data.csv', 'text/csv', Relationship::Data), '{"op":"attach","file":"data.csv","name":"data.csv","mimeType":"text\/csv","relationship":"Data"}', ['file' => 'data.csv', 'name' => 'data.csv', 'mimeType' => 'text/csv', 'relationship' => 'Data'], ['data.csv'], @@ -117,21 +127,21 @@ public static function provideStageData(): iterable ]; yield 'encrypt' => [ - Stage::encrypt('user', 'owner', 'aes256', ['print' => true, 'extractText' => false]), - '{"op":"encrypt","userPassword":"user","ownerPassword":"owner","algorithm":"aes256","permissions":{"print":true,"extractText":false}}', - ['userPassword' => 'user', 'ownerPassword' => 'owner', 'algorithm' => 'aes256', 'permissions' => '{"print":true,"extractText":false}'], + Stage::encrypt('user', 'owner', EncryptionAlgorithm::Aes256, [Permission::Print, Permission::ExtractText]), + '{"op":"encrypt","userPassword":"user","ownerPassword":"owner","algorithm":"aes256","permissions":{"print":false,"extractText":false}}', + ['userPassword' => 'user', 'ownerPassword' => 'owner', 'algorithm' => 'aes256', 'permissions' => '{"print":false,"extractText":false}'], ]; yield 'decrypt' => [Stage::decrypt('secret'), '{"op":"decrypt","password":"secret"}', ['password' => 'secret']]; yield 'decrypt without password' => [Stage::decrypt(), '{"op":"decrypt"}', []]; yield 'remediate' => [ - Stage::remediate(['PDF/A-2b', 'PDF/UA-1'], 'en-US', true, 'A chart'), + Stage::remediate([Profile::PdfA2b, Profile::PdfUa1], 'en-US', true, 'A chart'), '{"op":"remediate","profile":["PDF\/A-2b","PDF\/UA-1"],"language":"en-US","aggressive":true,"figureAltText":"A chart"}', ['profile' => '["PDF\/A-2b","PDF\/UA-1"]', 'language' => 'en-US', 'aggressive' => 'true', 'figureAltText' => 'A chart'], ]; yield 'remediate factur-x' => [ - Stage::remediate('Factur-X', xml: $xml, level: 'EN 16931', documentType: 'INVOICE'), + Stage::remediate(Profile::FacturX, xml: $xml, level: FacturXLevel::En16931, documentType: FacturXDocumentType::Invoice), '{"op":"remediate","profile":"Factur-X","xml":"invoice.xml","level":"EN 16931","documentType":"INVOICE"}', ['profile' => 'Factur-X', 'xml' => 'invoice.xml', 'level' => 'EN 16931', 'documentType' => 'INVOICE'], ['invoice.xml'], @@ -151,7 +161,7 @@ public static function provideStageData(): iterable ]; yield 'render' => [ - Stage::render('1', 300, 'jpeg', 80), + Stage::render('1', 300, RenderFormat::Jpeg, 80), '{"op":"render","pages":"1","dpi":300,"format":"jpeg","quality":80}', ['pages' => '1', 'dpi' => '300', 'format' => 'jpeg', 'quality' => '80'], ]; From b55ccea1c76afa445074010688d6b0e4c3e76767 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Thu, 5 Nov 2026 12:12:16 +0100 Subject: [PATCH 4/6] docs: document the option value enums --- CONTRIBUTING.md | 5 +++++ README.md | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 200a79b..5521039 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,7 @@ src/ MultipartFormDataModule.php Trait: multipart form data, webhooks, downloads, archives StagesModule.php Trait: stages, merge ordering (Index) Stage.php Stage value object: one named constructor per Gotenberg stage + Profile.php, PaperSize.php… Backed enums for the parameters with a fixed set of values Modules/ Chromium.php Chromium entry point (pdf/screenshot) ChromiumPdf.php Chromium PDF conversion builder @@ -66,6 +67,10 @@ Every PHP file declares `strict_types=1`. Configuration is stored in `$this->multipartFormData` (array of `[name, value]` or `[name, value, filename]` entries). Use `$this->formValue()` and `$this->formFile()` helpers. `formValue()` sends booleans as `true`/`false`; encode lists and objects with `json_encode(..., JSON_THROW_ON_ERROR)`. +### Known values + +A parameter with a fixed set of values takes a backed string enum, one file per enum in `src/` (`Profile`, `PaperSize`, `Magnification`…), with PascalCase cases. Send `$enum->value`; `json_encode` serializes enums inside lists and stage options on its own. A parameter that also takes free text, such as a page selection, takes a union (`string|Pages`). + ### Coding standard Doctrine coding standard enforced via phpcs/phpcbf. Follow it strictly. Use explicit `use function` and `use const` imports. Group class imports, function imports, and constant imports separately. diff --git a/README.md b/README.md index 0007b24..f4fd7d4 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ order on the PDFs a Chromium or LibreOffice conversion makes: ```php use Gotenberg\Gotenberg; +use Gotenberg\Profile; use Gotenberg\Stage; use Gotenberg\Stream; @@ -161,7 +162,7 @@ Gotenberg::libreOffice($apiUrl) ->stages( Stage::merge(), Stage::stampText('DRAFT', x: 72, y: 72, fontSize: 24), - Stage::remediate('PDF/A-2b'), + Stage::remediate(Profile::PdfA2b), ) ->convert( Stream::path($pathToDocx), @@ -170,6 +171,7 @@ Gotenberg::libreOffice($apiUrl) ``` A stage that reads a file, such as an image to stamp or a certificate, takes a `Stream`: the client uploads it for you. +Options with a fixed set of values take an enum: `Profile`, `Font`, `Pages`, `PaperSize`, and so on. With a `merge` stage, the documents are merged in the order of the arguments. See [Run stages](https://gotenberg.dev/docs/pdf/pipe) for every stage and its order rules. @@ -177,6 +179,7 @@ The `pdf` module runs stages on the PDFs you upload, reads them, and validates t ```php use Gotenberg\Gotenberg; +use Gotenberg\Profile; use Gotenberg\Stage; use Gotenberg\Stream; @@ -202,7 +205,7 @@ Gotenberg::pdf($apiUrl)->metadata(Stream::path($pathToPdf)); Gotenberg::pdf($apiUrl)->bookmarks(Stream::path($pathToPdf)); Gotenberg::pdf($apiUrl)->attachments(Stream::path($pathToPdf)); Gotenberg::pdf($apiUrl)->form(null, Stream::path($pathToPdf)); -Gotenberg::pdf($apiUrl)->validate(['PDF/A-2b', 'PDF/UA-1'], Stream::path($pathToPdf)); +Gotenberg::pdf($apiUrl)->validate([Profile::PdfA2b, Profile::PdfUa1], Stream::path($pathToPdf)); ``` ## Send a request to the API @@ -350,11 +353,12 @@ You may tell Gotenberg to upload the result to a webhook instead of answering wi ```php use Gotenberg\Gotenberg; +use Gotenberg\WebhookMethod; $request = Gotenberg::chromium($apiUrl) ->pdf() ->webhook('https://my.webhook.url', 'https://my.webhook.url/events') - ->webhookMethod('PUT') // Optional, default POST. + ->webhookMethod(WebhookMethod::Put) // Optional, default POST. ->webhookExtraHttpHeaders(['Authorization' => 'Bearer …']) // Optional. ->url('https://my.url'); ``` @@ -368,10 +372,13 @@ The client follows the Gotenberg 9 API: `encrypt()`, `allow*()`, `facturX()`, `embeds()`, `optimizeImages()`, `watermarking()`, `stamping()`, `rotating()`) become stages. `SplitMode`, `FacturX` and `EmbedMetadata` give way to `Stage`. * Fields with an engine twin take the `native` prefix: `nativePages()`, `nativeBookmarks()`, `nativeTaggedPdf()`, - `nativePdfa()`, `nativePdfua()`, `nativeQuality()`, `nativeWatermark*()`, and so on. `paperSize()` takes a name - (`A4`, `Letter`…), `paperDimensions()` the width and height. + `nativePdfa()`, `nativePdfua()`, `nativeQuality()`, `nativeWatermark*()`, and so on. `paperSize()` takes a + `PaperSize`, `paperDimensions()` the width and height. * `webhook()` takes the events URL, which Gotenberg requires; the error webhook is gone. * `downloadFrom()`, `cookies()` and the `failOn*()` methods are variadic, `emulatedMediaFeatures()` takes an array. * `Stream` exposes `filename` and `stream` as readonly properties. +* Parameters with a fixed set of values take backed enums instead of strings (`Profile`, `PaperSize`, `SameSite`, + `WebhookMethod`…). `FacturX::CONFORMANCE_*` becomes `FacturXLevel`, `EmbedMetadata::RELATIONSHIP_*` becomes + `Relationship`. `nativePdfua()` takes no argument: LibreOffice only writes PDF/UA-1. * JSON encoding errors throw `JsonException`. * The deprecated `trace()` and `getGotenbergTrace()` are removed: use `correlationId()` and `getCorrelationId()`. From a7fb4c230b8960a0bed20dbaaf3d932b29620e49 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Thu, 5 Nov 2026 14:34:53 +0100 Subject: [PATCH 5/6] test: build requests inside the test methods so coverage counts them --- CONTRIBUTING.md | 2 +- tests/GotenbergTest.php | 21 +++++++ tests/Modules/ChromiumPdfTest.php | 28 +++++----- tests/Modules/ChromiumScreenshotTest.php | 20 ++++--- tests/Modules/LibreOfficeTest.php | 16 +++--- tests/Modules/PdfTest.php | 38 +++++++------ tests/StageTest.php | 70 +++++++++++++----------- 7 files changed, 114 insertions(+), 81 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5521039..9233d50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,7 @@ Unit tests only (no integration tests, this is a client library). PHPUnit 12.4+. - `assertRequest(RequestInterface $request, string $endpoint, array $values, array $files)`: asserts the endpoint, the form values (`[name, value]`) and the form files (`[field, filename, content, content type]`). - `assertContainsFormValue()` and `assertContainsFormFile()`: the single checks `assertRequest()` runs. -Tests follow a data provider pattern: a static method returns an array of test cases (description, `RequestInterface` built via the fluent API, expected form values/files), and the test method runs assertions against each. +Tests follow a data provider pattern: a static method yields test cases (a closure that builds the request or stage with the fluent API, then the expected form values and files), and the test method calls the closure and runs assertions against the result. Never build the request in the provider itself: PHPUnit runs providers before it starts recording coverage, so that code counts as untested. Wrap the closure in parentheses, `(static fn (): RequestInterface => ...)`, or phpcs misreads the array. Form field names must match Gotenberg's API exactly. Cross-reference with the [Gotenberg documentation](https://gotenberg.dev). diff --git a/tests/GotenbergTest.php b/tests/GotenbergTest.php index 13b03f9..5e7246c 100644 --- a/tests/GotenbergTest.php +++ b/tests/GotenbergTest.php @@ -5,6 +5,7 @@ namespace Gotenberg\Test; use Gotenberg\Exceptions\GotenbergApiErrored; +use Gotenberg\Exceptions\NativeFunctionErrored; use Gotenberg\Exceptions\NoOutputFileInResponse; use Gotenberg\Gotenberg; use Gotenberg\Test\Helpers\Dummies\DummyClient; @@ -12,8 +13,10 @@ use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; use function file_get_contents; +use function ini_set; use function sys_get_temp_dir; use function unlink; @@ -96,6 +99,24 @@ public function it_throws_an_exception_if_there_is_no_attachment(string|null $co Gotenberg::save(new Request('POST', 'https://my.url'), sys_get_temp_dir(), $client); } + #[Test] + #[WithoutErrorHandler] + public function it_throws_an_exception_if_the_output_file_cannot_be_opened(): void + { + $response = new Response(200, ['Content-Disposition' => 'attachment; filename=my.pdf'], 'PDF content'); + $client = new DummyClient($response); + + $this->expectException(NativeFunctionErrored::class); + + // Without PHPUnit's handler, PHP prints the fopen warning itself. + $displayErrors = ini_set('display_errors', '0'); + try { + Gotenberg::save(new Request('POST', 'https://my.url'), sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'missing-dir', $client); + } finally { + ini_set('display_errors', (string) $displayErrors); + } + } + /** @return array */ public static function provideContentDispositionData(): array { diff --git a/tests/Modules/ChromiumPdfTest.php b/tests/Modules/ChromiumPdfTest.php index 30ae939..d67b57d 100644 --- a/tests/Modules/ChromiumPdfTest.php +++ b/tests/Modules/ChromiumPdfTest.php @@ -4,6 +4,7 @@ namespace Gotenberg\Test\Modules; +use Closure; use Gotenberg\ConsoleMessageType; use Gotenberg\Gotenberg; use Gotenberg\Modules\ChromiumCookie; @@ -19,21 +20,22 @@ final class ChromiumPdfTest extends TestCase { /** + * @param Closure(): RequestInterface $build * @param list $values * @param list $files */ #[Test] #[DataProvider('provideRequestData')] - public function it_creates_a_valid_request(RequestInterface $request, string $endpoint, array $values, array $files = []): void + public function it_creates_a_valid_request(Closure $build, string $endpoint, array $values, array $files = []): void { - $this->assertRequest($request, $endpoint, $values, $files); + $this->assertRequest($build(), $endpoint, $values, $files); } - /** @return iterable, 3?: list}> */ + /** @return iterable, 3?: list}> */ public static function provideRequestData(): iterable { yield 'url with every option' => [ - Gotenberg::chromium('https://my.url/') + (static fn (): RequestInterface => Gotenberg::chromium('https://my.url/') ->pdf() ->singlePage() ->paperSize(PaperSize::A4) @@ -55,7 +57,7 @@ public static function provideRequestData(): iterable ->waitForWindowStatus('ready') ->emulatePrintMediaType() ->emulatedMediaFeatures(['prefers-color-scheme' => 'dark']) - ->cookies(new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com', secure: true, sameSite: SameSite::Lax)) + ->cookies(new ChromiumCookie('yummy_cookie', 'choco', 'theyummycookie.com', path: '/', secure: true, httpOnly: true, sameSite: SameSite::Lax)) ->userAgent('Gotenberg') ->extraHttpHeaders(['My-Http-Header' => 'HTTP header content']) ->failOnHttpStatusCodes(499, 599) @@ -67,7 +69,7 @@ public static function provideRequestData(): iterable ->skipNetworkIdleEvent(false) ->skipNetworkAlmostIdleEvent() ->assets(Stream::string('my.jpg', 'Image content')) - ->url('https://my.url'), + ->url('https://my.url')), '/forms/chromium/convert/url', [ ['url', 'https://my.url'], @@ -93,7 +95,7 @@ public static function provideRequestData(): iterable ['waitForWindowStatus', 'ready'], ['emulatedMediaType', 'print'], ['emulatedMediaFeatures', '{"prefers-color-scheme":"dark"}'], - ['cookies', '[{"name":"yummy_cookie","value":"choco","domain":"theyummycookie.com","secure":true,"sameSite":"Lax"}]'], + ['cookies', '[{"name":"yummy_cookie","value":"choco","domain":"theyummycookie.com","path":"\/","secure":true,"httpOnly":true,"sameSite":"Lax"}]'], ['userAgent', 'Gotenberg'], ['extraHttpHeaders', '{"My-Http-Header":"HTTP header content"}'], ['failOnHttpStatusCodes', '[499,599]'], @@ -113,14 +115,14 @@ public static function provideRequestData(): iterable ]; yield 'html with stages' => [ - Gotenberg::chromium('https://my.url') + (static fn (): RequestInterface => Gotenberg::chromium('https://my.url') ->pdf() ->emulateScreenMediaType() ->stages( Stage::stampImage(Stream::string('logo.png', 'Logo content'), x: 72, y: 72), Stage::optimize(), ) - ->html(Stream::string('my.html', 'HTML content')), + ->html(Stream::string('my.html', 'HTML content'))), '/forms/chromium/convert/html', [ ['emulatedMediaType', 'screen'], @@ -133,23 +135,23 @@ public static function provideRequestData(): iterable ]; yield 'html from an archive' => [ - Gotenberg::chromium('https://my.url') + (static fn (): RequestInterface => Gotenberg::chromium('https://my.url') ->pdf() ->archives(Stream::string('site.zip', 'ZIP content')) - ->html(null), + ->html(null)), '/forms/chromium/convert/html', [], [['archives', 'site.zip', 'ZIP content']], ]; yield 'markdown' => [ - Gotenberg::chromium('https://my.url') + (static fn (): RequestInterface => Gotenberg::chromium('https://my.url') ->pdf() ->markdown( Stream::string('my.html', 'HTML content'), Stream::string('my.md', 'Markdown content'), Stream::string('my_second.md', 'Second markdown content'), - ), + )), '/forms/chromium/convert/markdown', [], [ diff --git a/tests/Modules/ChromiumScreenshotTest.php b/tests/Modules/ChromiumScreenshotTest.php index 82e9b4e..862086f 100644 --- a/tests/Modules/ChromiumScreenshotTest.php +++ b/tests/Modules/ChromiumScreenshotTest.php @@ -4,6 +4,7 @@ namespace Gotenberg\Test\Modules; +use Closure; use Gotenberg\Gotenberg; use Gotenberg\Stream; use Gotenberg\Test\TestCase; @@ -14,21 +15,22 @@ final class ChromiumScreenshotTest extends TestCase { /** + * @param Closure(): RequestInterface $build * @param list $values * @param list $files */ #[Test] #[DataProvider('provideRequestData')] - public function it_creates_a_valid_request(RequestInterface $request, string $endpoint, array $values, array $files = []): void + public function it_creates_a_valid_request(Closure $build, string $endpoint, array $values, array $files = []): void { - $this->assertRequest($request, $endpoint, $values, $files); + $this->assertRequest($build(), $endpoint, $values, $files); } - /** @return iterable, 3?: list}> */ + /** @return iterable, 3?: list}> */ public static function provideRequestData(): iterable { yield 'url' => [ - Gotenberg::chromium('https://my.url') + (static fn (): RequestInterface => Gotenberg::chromium('https://my.url') ->screenshot() ->width(1280) ->height(720) @@ -39,7 +41,7 @@ public static function provideRequestData(): iterable ->optimizeForSpeed() ->selector('#main') ->waitDelay('1s') - ->url('https://my.url'), + ->url('https://my.url')), '/forms/chromium/screenshot/url', [ ['url', 'https://my.url'], @@ -56,20 +58,20 @@ public static function provideRequestData(): iterable ]; yield 'html' => [ - Gotenberg::chromium('https://my.url') + (static fn (): RequestInterface => Gotenberg::chromium('https://my.url') ->screenshot() ->png() - ->html(Stream::string('my.html', 'HTML content')), + ->html(Stream::string('my.html', 'HTML content'))), '/forms/chromium/screenshot/html', [['format', 'png']], [['files', 'index.html', 'HTML content', 'text/html']], ]; yield 'markdown' => [ - Gotenberg::chromium('https://my.url') + (static fn (): RequestInterface => Gotenberg::chromium('https://my.url') ->screenshot() ->webp() - ->markdown(Stream::string('my.html', 'HTML content'), Stream::string('my.md', 'Markdown content')), + ->markdown(Stream::string('my.html', 'HTML content'), Stream::string('my.md', 'Markdown content'))), '/forms/chromium/screenshot/markdown', [['format', 'webp']], [ diff --git a/tests/Modules/LibreOfficeTest.php b/tests/Modules/LibreOfficeTest.php index b65c210..5011b82 100644 --- a/tests/Modules/LibreOfficeTest.php +++ b/tests/Modules/LibreOfficeTest.php @@ -4,6 +4,7 @@ namespace Gotenberg\Test\Modules; +use Closure; use Gotenberg\Gotenberg; use Gotenberg\InitialView; use Gotenberg\Magnification; @@ -21,21 +22,22 @@ final class LibreOfficeTest extends TestCase { /** + * @param Closure(): RequestInterface $build * @param list $values * @param list $files */ #[Test] #[DataProvider('provideRequestData')] - public function it_creates_a_valid_request(RequestInterface $request, array $values, array $files = []): void + public function it_creates_a_valid_request(Closure $build, array $values, array $files = []): void { - $this->assertRequest($request, '/forms/libreoffice/convert', $values, $files); + $this->assertRequest($build(), '/forms/libreoffice/convert', $values, $files); } - /** @return iterable, 2?: list}> */ + /** @return iterable, 2?: list}> */ public static function provideRequestData(): iterable { yield 'every option' => [ - Gotenberg::libreOffice('https://my.url') + (static fn (): RequestInterface => Gotenberg::libreOffice('https://my.url') ->password('my_password') ->paperSize(PaperSize::Legal) ->paperDimensions('21cm', 29.7) @@ -84,7 +86,7 @@ public static function provideRequestData(): iterable ->hideViewerMenubar() ->hideViewerToolbar() ->hideViewerWindowControls() - ->convert(Stream::string('my.docx', 'Word content'), Stream::string('my.xlsx', 'Excel content')), + ->convert(Stream::string('my.docx', 'Word content'), Stream::string('my.xlsx', 'Excel content'))), [ ['password', 'my_password'], ['paperSize', 'Legal'], @@ -143,10 +145,10 @@ public static function provideRequestData(): iterable ]; yield 'merge stage keeps the order of the arguments' => [ - Gotenberg::libreOffice('https://my.url') + (static fn (): RequestInterface => Gotenberg::libreOffice('https://my.url') ->index(new DummyIndex()) ->stages(Stage::merge(), Stage::remediate(Profile::PdfA2b)) - ->convert(Stream::string('b.docx', 'Word content'), Stream::string('a.xlsx', 'Excel content')), + ->convert(Stream::string('b.docx', 'Word content'), Stream::string('a.xlsx', 'Excel content'))), [['stages', '[{"op":"merge"},{"op":"remediate","profile":"PDF\\/A-2b"}]']], [ ['files', '0_b.docx', 'Word content'], diff --git a/tests/Modules/PdfTest.php b/tests/Modules/PdfTest.php index bfd3960..55e6420 100644 --- a/tests/Modules/PdfTest.php +++ b/tests/Modules/PdfTest.php @@ -4,6 +4,7 @@ namespace Gotenberg\Test\Modules; +use Closure; use Gotenberg\Gotenberg; use Gotenberg\Permission; use Gotenberg\Profile; @@ -18,28 +19,29 @@ final class PdfTest extends TestCase { /** + * @param Closure(): RequestInterface $build * @param list $values * @param list $files */ #[Test] #[DataProvider('provideRequestData')] - public function it_creates_a_valid_request(RequestInterface $request, string $endpoint, array $values, array $files = []): void + public function it_creates_a_valid_request(Closure $build, string $endpoint, array $values, array $files = []): void { - $this->assertRequest($request, $endpoint, $values, $files); + $this->assertRequest($build(), $endpoint, $values, $files); } - /** @return iterable, 3?: list}> */ + /** @return iterable, 3?: list}> */ public static function provideRequestData(): iterable { yield 'pipe' => [ - Gotenberg::pdf('https://my.url') + (static fn (): RequestInterface => Gotenberg::pdf('https://my.url') ->index(new DummyIndex()) ->stages( Stage::merge(), Stage::stampText('DRAFT'), Stage::stampPdf(Stream::string('letterhead.pdf', 'Letterhead content')), ) - ->pipe(Stream::string('report.pdf', 'Report content'), Stream::string('annex.pdf', 'Annex content')), + ->pipe(Stream::string('report.pdf', 'Report content'), Stream::string('annex.pdf', 'Annex content'))), '/forms/pdf/pipe', [['stages', '[{"op":"merge"},{"op":"stamp-text","text":"DRAFT"},{"op":"stamp-pdf","source":"letterhead.pdf"}]']], [ @@ -50,18 +52,18 @@ public static function provideRequestData(): iterable ]; yield 'pipe without merge keeps the names' => [ - Gotenberg::pdf('https://my.url') + (static fn (): RequestInterface => Gotenberg::pdf('https://my.url') ->stages(Stage::splitEvery(1)) - ->pipe(Stream::string('report.pdf', 'Report content')), + ->pipe(Stream::string('report.pdf', 'Report content'))), '/forms/pdf/pipe', [['stages', '[{"op":"split","every":1}]']], [['files', 'report.pdf', 'Report content', 'application/pdf']], ]; yield 'run merge' => [ - Gotenberg::pdf('https://my.url') + (static fn (): RequestInterface => Gotenberg::pdf('https://my.url') ->index(new DummyIndex()) - ->run(Stage::merge(), Stream::string('b.pdf', 'B content'), Stream::string('a.pdf', 'A content')), + ->run(Stage::merge(), Stream::string('b.pdf', 'B content'), Stream::string('a.pdf', 'A content'))), '/forms/pdf/merge', [], [ @@ -71,11 +73,11 @@ public static function provideRequestData(): iterable ]; yield 'run stamp image' => [ - Gotenberg::pdf('https://my.url') + (static fn (): RequestInterface => Gotenberg::pdf('https://my.url') ->run( Stage::stampImage(Stream::string('logo.png', 'Logo content'), pages: '1', opacity: 0.5), Stream::string('report.pdf', 'Report content'), - ), + )), '/forms/pdf/stamp-image', [ ['image', 'logo.png'], @@ -89,8 +91,8 @@ public static function provideRequestData(): iterable ]; yield 'run encrypt' => [ - Gotenberg::pdf('https://my.url') - ->run(Stage::encrypt('user', deny: [Permission::Print]), Stream::string('report.pdf', 'Report content')), + (static fn (): RequestInterface => Gotenberg::pdf('https://my.url') + ->run(Stage::encrypt('user', deny: [Permission::Print]), Stream::string('report.pdf', 'Report content'))), '/forms/pdf/encrypt', [ ['userPassword', 'user'], @@ -101,9 +103,9 @@ public static function provideRequestData(): iterable foreach (['info', 'metadata', 'bookmarks', 'attachments'] as $route) { yield $route => [ - Gotenberg::pdf('https://my.url') + (static fn (): RequestInterface => Gotenberg::pdf('https://my.url') ->password('secret') - ->{$route}(Stream::string('report.pdf', 'Report content')), + ->{$route}(Stream::string('report.pdf', 'Report content'))), '/forms/pdf/' . $route, [['password', 'secret']], [['files', 'report.pdf', 'Report content', 'application/pdf']], @@ -111,21 +113,21 @@ public static function provideRequestData(): iterable } yield 'form' => [ - Gotenberg::pdf('https://my.url')->form('1-2', Stream::string('fields.pdf', 'Form content')), + (static fn (): RequestInterface => Gotenberg::pdf('https://my.url')->form('1-2', Stream::string('fields.pdf', 'Form content'))), '/forms/pdf/form', [['pages', '1-2']], [['files', 'fields.pdf', 'Form content', 'application/pdf']], ]; yield 'validate a profile' => [ - Gotenberg::pdf('https://my.url')->validate(Profile::PdfA3b, Stream::string('invoice.pdf', 'Invoice content')), + (static fn (): RequestInterface => Gotenberg::pdf('https://my.url')->validate(Profile::PdfA3b, Stream::string('invoice.pdf', 'Invoice content'))), '/forms/pdf/validate', [['profile', 'PDF/A-3b']], [['files', 'invoice.pdf', 'Invoice content', 'application/pdf']], ]; yield 'validate a pair of profiles' => [ - Gotenberg::pdf('https://my.url')->validate([Profile::PdfA2b, Profile::PdfUa1], Stream::string('archive.pdf', 'Archive content')), + (static fn (): RequestInterface => Gotenberg::pdf('https://my.url')->validate([Profile::PdfA2b, Profile::PdfUa1], Stream::string('archive.pdf', 'Archive content'))), '/forms/pdf/validate', [['profile', '["PDF\/A-2b","PDF\/UA-1"]']], [['files', 'archive.pdf', 'Archive content', 'application/pdf']], diff --git a/tests/StageTest.php b/tests/StageTest.php index 8197c74..d1792d7 100644 --- a/tests/StageTest.php +++ b/tests/StageTest.php @@ -4,6 +4,7 @@ namespace Gotenberg\Test; +use Closure; use Gotenberg\ColorSpace; use Gotenberg\EncryptionAlgorithm; use Gotenberg\FacturXDocumentType; @@ -27,19 +28,22 @@ final class StageTest extends TestCase { /** + * @param Closure(): Stage $build * @param array $formFields * @param list $files */ #[Test] #[DataProvider('provideStageData')] - public function it_serializes_a_stage(Stage $stage, string $json, array $formFields, array $files = []): void + public function it_serializes_a_stage(Closure $build, string $json, array $formFields, array $files = []): void { + $stage = $build(); + $this->assertSame($json, json_encode($stage, JSON_THROW_ON_ERROR)); $this->assertSame($formFields, $stage->formFields()); $this->assertSame($files, array_map(static fn (Stream $file): string => $file->filename, $stage->files())); } - /** @return iterable, 3?: list}> */ + /** @return iterable, 3?: list}> */ public static function provideStageData(): iterable { $pdf = Stream::string('letterhead.pdf', 'PDF'); @@ -49,119 +53,119 @@ public static function provideStageData(): iterable $key = Stream::string('key.pem', 'KEY'); $file = Stream::string('data.csv', 'CSV'); - yield 'merge' => [Stage::merge(), '{"op":"merge"}', []]; - yield 'split every' => [Stage::splitEvery(2), '{"op":"split","every":2}', ['every' => '2']]; - yield 'split ranges' => [Stage::splitRanges('1', '2-3', Pages::Odd), '{"op":"split","ranges":["1","2-3","odd"]}', ['ranges' => '["1","2-3","odd"]']]; - yield 'select' => [Stage::select('1-2'), '{"op":"select","pages":"1-2"}', ['pages' => '1-2']]; - yield 'delete' => [Stage::delete(Pages::Last), '{"op":"delete","pages":"last"}', ['pages' => 'last']]; + yield 'merge' => [static fn (): Stage => Stage::merge(), '{"op":"merge"}', []]; + yield 'split every' => [static fn (): Stage => Stage::splitEvery(2), '{"op":"split","every":2}', ['every' => '2']]; + yield 'split ranges' => [static fn (): Stage => Stage::splitRanges('1', '2-3', Pages::Odd), '{"op":"split","ranges":["1","2-3","odd"]}', ['ranges' => '["1","2-3","odd"]']]; + yield 'select' => [static fn (): Stage => Stage::select('1-2'), '{"op":"select","pages":"1-2"}', ['pages' => '1-2']]; + yield 'delete' => [static fn (): Stage => Stage::delete(Pages::Last), '{"op":"delete","pages":"last"}', ['pages' => 'last']]; yield 'insert' => [ - Stage::insert($pdf, 1, '2'), + (static fn (): Stage => Stage::insert($pdf, 1, '2')), '{"op":"insert","source":"letterhead.pdf","at":1,"sourcePages":"2"}', ['source' => 'letterhead.pdf', 'at' => '1', 'sourcePages' => '2'], ['letterhead.pdf'], ]; - yield 'rotate' => [Stage::rotate(90), '{"op":"rotate","angle":90}', ['angle' => '90']]; + yield 'rotate' => [static fn (): Stage => Stage::rotate(90), '{"op":"rotate","angle":90}', ['angle' => '90']]; yield 'stamp text' => [ - Stage::stampText('DRAFT', Pages::All, 72, 72.5, 24, Font::HelveticaBold, '#ff0000', 0.5, 45), + (static fn (): Stage => Stage::stampText('DRAFT', Pages::All, 72, 72.5, 24, Font::HelveticaBold, '#ff0000', 0.5, 45)), '{"op":"stamp-text","text":"DRAFT","pages":"all","x":72,"y":72.5,"fontSize":24,"font":"Helvetica-Bold","color":"#ff0000","opacity":0.5,"rotation":45}', ['text' => 'DRAFT', 'pages' => 'all', 'x' => '72', 'y' => '72.5', 'fontSize' => '24', 'font' => 'Helvetica-Bold', 'color' => '#ff0000', 'opacity' => '0.5', 'rotation' => '45'], ]; yield 'stamp image' => [ - Stage::stampImage($image, width: 100), + (static fn (): Stage => Stage::stampImage($image, width: 100)), '{"op":"stamp-image","image":"logo.png","width":100}', ['image' => 'logo.png', 'width' => '100'], ['logo.png'], ]; yield 'stamp pdf' => [ - Stage::stampPdf($pdf, 1, '1', 0, 0, 1, 1, 0), + (static fn (): Stage => Stage::stampPdf($pdf, 1, '1', 0, 0, 1, 1, 0)), '{"op":"stamp-pdf","source":"letterhead.pdf","sourcePage":1,"pages":"1","x":0,"y":0,"scale":1,"opacity":1,"rotation":0}', ['source' => 'letterhead.pdf', 'sourcePage' => '1', 'pages' => '1', 'x' => '0', 'y' => '0', 'scale' => '1', 'opacity' => '1', 'rotation' => '0'], ['letterhead.pdf'], ]; yield 'set metadata' => [ - Stage::setMetadata('Title', 'Author', 'Subject', 'Keywords', 'Creator', 'Producer', 'D:20260101120000Z', 'D:20260102120000Z'), + (static fn (): Stage => Stage::setMetadata('Title', 'Author', 'Subject', 'Keywords', 'Creator', 'Producer', 'D:20260101120000Z', 'D:20260102120000Z')), '{"op":"set-metadata","title":"Title","author":"Author","subject":"Subject","keywords":"Keywords","creator":"Creator","producer":"Producer","creationDate":"D:20260101120000Z","modDate":"D:20260102120000Z"}', ['title' => 'Title', 'author' => 'Author', 'subject' => 'Subject', 'keywords' => 'Keywords', 'creator' => 'Creator', 'producer' => 'Producer', 'creationDate' => 'D:20260101120000Z', 'modDate' => 'D:20260102120000Z'], ]; - yield 'set metadata partially' => [Stage::setMetadata(title: 'Title'), '{"op":"set-metadata","title":"Title"}', ['title' => 'Title']]; + yield 'set metadata partially' => [static fn (): Stage => Stage::setMetadata(title: 'Title'), '{"op":"set-metadata","title":"Title"}', ['title' => 'Title']]; yield 'set bookmarks' => [ - Stage::setBookmarks([['title' => 'Summary', 'pageNumber' => 1, 'children' => [['title' => 'Figures', 'pageNumber' => 2]]]]), + (static fn (): Stage => Stage::setBookmarks([['title' => 'Summary', 'pageNumber' => 1, 'children' => [['title' => 'Figures', 'pageNumber' => 2]]]])), '{"op":"set-bookmarks","bookmarks":[{"title":"Summary","pageNumber":1,"children":[{"title":"Figures","pageNumber":2}]}]}', ['bookmarks' => '[{"title":"Summary","pageNumber":1,"children":[{"title":"Figures","pageNumber":2}]}]'], ]; yield 'set form' => [ - Stage::setForm(['agree' => 'Yes', 'colors' => ['Red', 'Green']]), + (static fn (): Stage => Stage::setForm(['agree' => 'Yes', 'colors' => ['Red', 'Green']])), '{"op":"set-form","fields":{"agree":"Yes","colors":["Red","Green"]}}', ['fields' => '{"agree":"Yes","colors":["Red","Green"]}'], ]; - yield 'flatten form' => [Stage::flattenForm(), '{"op":"flatten-form"}', []]; - yield 'flatten transparency' => [Stage::flattenTransparency(), '{"op":"flatten-transparency"}', []]; - yield 'convert colors' => [Stage::convertColors(ColorSpace::Cmyk), '{"op":"convert-colors","target":"cmyk"}', ['target' => 'cmyk']]; + yield 'flatten form' => [static fn (): Stage => Stage::flattenForm(), '{"op":"flatten-form"}', []]; + yield 'flatten transparency' => [static fn (): Stage => Stage::flattenTransparency(), '{"op":"flatten-transparency"}', []]; + yield 'convert colors' => [static fn (): Stage => Stage::convertColors(ColorSpace::Cmyk), '{"op":"convert-colors","target":"cmyk"}', ['target' => 'cmyk']]; yield 'redact' => [ - Stage::redact([['page' => 1, 'x' => 160, 'y' => 435, 'width' => 60, 'height' => 25]]), + (static fn (): Stage => Stage::redact([['page' => 1, 'x' => 160, 'y' => 435, 'width' => 60, 'height' => 25]])), '{"op":"redact","regions":[{"page":1,"x":160,"y":435,"width":60,"height":25}]}', ['regions' => '[{"page":1,"x":160,"y":435,"width":60,"height":25}]'], ]; - yield 'autotag' => [Stage::autotag(), '{"op":"autotag"}', []]; + yield 'autotag' => [static fn (): Stage => Stage::autotag(), '{"op":"autotag"}', []]; yield 'attach' => [ - Stage::attach($file, 'data.csv', 'text/csv', Relationship::Data), + (static fn (): Stage => Stage::attach($file, 'data.csv', 'text/csv', Relationship::Data)), '{"op":"attach","file":"data.csv","name":"data.csv","mimeType":"text\/csv","relationship":"Data"}', ['file' => 'data.csv', 'name' => 'data.csv', 'mimeType' => 'text/csv', 'relationship' => 'Data'], ['data.csv'], ]; - yield 'detach' => [Stage::detach('data.csv'), '{"op":"detach","name":"data.csv"}', ['name' => 'data.csv']]; - yield 'optimize' => [Stage::optimize(), '{"op":"optimize"}', []]; + yield 'detach' => [static fn (): Stage => Stage::detach('data.csv'), '{"op":"detach","name":"data.csv"}', ['name' => 'data.csv']]; + yield 'optimize' => [static fn (): Stage => Stage::optimize(), '{"op":"optimize"}', []]; yield 'optimize without font subsetting' => [ - Stage::optimize(subsetFonts: false, dedup: true), + (static fn (): Stage => Stage::optimize(subsetFonts: false, dedup: true)), '{"op":"optimize","dedup":true,"subsetFonts":false}', ['dedup' => 'true', 'subsetFonts' => 'false'], ]; yield 'encrypt' => [ - Stage::encrypt('user', 'owner', EncryptionAlgorithm::Aes256, [Permission::Print, Permission::ExtractText]), + (static fn (): Stage => Stage::encrypt('user', 'owner', EncryptionAlgorithm::Aes256, [Permission::Print, Permission::ExtractText])), '{"op":"encrypt","userPassword":"user","ownerPassword":"owner","algorithm":"aes256","permissions":{"print":false,"extractText":false}}', ['userPassword' => 'user', 'ownerPassword' => 'owner', 'algorithm' => 'aes256', 'permissions' => '{"print":false,"extractText":false}'], ]; - yield 'decrypt' => [Stage::decrypt('secret'), '{"op":"decrypt","password":"secret"}', ['password' => 'secret']]; - yield 'decrypt without password' => [Stage::decrypt(), '{"op":"decrypt"}', []]; + yield 'decrypt' => [static fn (): Stage => Stage::decrypt('secret'), '{"op":"decrypt","password":"secret"}', ['password' => 'secret']]; + yield 'decrypt without password' => [static fn (): Stage => Stage::decrypt(), '{"op":"decrypt"}', []]; yield 'remediate' => [ - Stage::remediate([Profile::PdfA2b, Profile::PdfUa1], 'en-US', true, 'A chart'), + (static fn (): Stage => Stage::remediate([Profile::PdfA2b, Profile::PdfUa1], 'en-US', true, 'A chart')), '{"op":"remediate","profile":["PDF\/A-2b","PDF\/UA-1"],"language":"en-US","aggressive":true,"figureAltText":"A chart"}', ['profile' => '["PDF\/A-2b","PDF\/UA-1"]', 'language' => 'en-US', 'aggressive' => 'true', 'figureAltText' => 'A chart'], ]; yield 'remediate factur-x' => [ - Stage::remediate(Profile::FacturX, xml: $xml, level: FacturXLevel::En16931, documentType: FacturXDocumentType::Invoice), + (static fn (): Stage => Stage::remediate(Profile::FacturX, xml: $xml, level: FacturXLevel::En16931, documentType: FacturXDocumentType::Invoice)), '{"op":"remediate","profile":"Factur-X","xml":"invoice.xml","level":"EN 16931","documentType":"INVOICE"}', ['profile' => 'Factur-X', 'xml' => 'invoice.xml', 'level' => 'EN 16931', 'documentType' => 'INVOICE'], ['invoice.xml'], ]; yield 'sign' => [ - Stage::sign($cert, $key, 'pass', 'Approval', 'Paris', 'me@example.com', 'Me', true, 'https://tsa.example.com'), + (static fn (): Stage => Stage::sign($cert, $key, 'pass', 'Approval', 'Paris', 'me@example.com', 'Me', true, 'https://tsa.example.com')), '{"op":"sign","certificate":"cert.pem","key":"key.pem","certificatePassword":"pass","reason":"Approval","location":"Paris","contactInfo":"me@example.com","name":"Me","pades":true,"timestampUrl":"https:\/\/tsa.example.com"}', ['certificate' => 'cert.pem', 'key' => 'key.pem', 'certificatePassword' => 'pass', 'reason' => 'Approval', 'location' => 'Paris', 'contactInfo' => 'me@example.com', 'name' => 'Me', 'pades' => 'true', 'timestampUrl' => 'https://tsa.example.com'], ['cert.pem', 'key.pem'], ]; yield 'timestamp' => [ - Stage::timestamp('https://tsa.example.com'), + (static fn (): Stage => Stage::timestamp('https://tsa.example.com')), '{"op":"timestamp","timestampUrl":"https:\/\/tsa.example.com"}', ['timestampUrl' => 'https://tsa.example.com'], ]; yield 'render' => [ - Stage::render('1', 300, RenderFormat::Jpeg, 80), + (static fn (): Stage => Stage::render('1', 300, RenderFormat::Jpeg, 80)), '{"op":"render","pages":"1","dpi":300,"format":"jpeg","quality":80}', ['pages' => '1', 'dpi' => '300', 'format' => 'jpeg', 'quality' => '80'], ]; From cf0c0e54cd162254660341fada4ea22e88d8877e Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Thu, 5 Nov 2026 14:38:10 +0100 Subject: [PATCH 6/6] refactor: stream the saved output with file_put_contents --- src/Gotenberg.php | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/Gotenberg.php b/src/Gotenberg.php index cdceb40..e9cccc8 100644 --- a/src/Gotenberg.php +++ b/src/Gotenberg.php @@ -10,15 +10,14 @@ use Gotenberg\Modules\Chromium; use Gotenberg\Modules\LibreOffice; use Gotenberg\Modules\Pdf; +use GuzzleHttp\Psr7\StreamWrapper; use Http\Discovery\Psr18ClientDiscovery; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use function basename; -use function fclose; -use function fopen; -use function fwrite; +use function file_put_contents; use function preg_match; use function rawurldecode; @@ -76,21 +75,8 @@ public static function save(RequestInterface $request, string $dirPath, ClientIn throw new NoOutputFileInResponse(); } - $file = fopen($dirPath . DIRECTORY_SEPARATOR . $filename, 'w'); - if ($file === false) { - throw NativeFunctionErrored::createFromLastPhpError(); - } - - $body = $response->getBody(); - while (! $body->eof()) { - if (fwrite($file, $body->read(8192)) === false) { - fclose($file); - - throw NativeFunctionErrored::createFromLastPhpError(); - } - } - - if (fclose($file) === false) { + // Streams the body to disk in chunks, without loading it into memory. + if (file_put_contents($dirPath . DIRECTORY_SEPARATOR . $filename, StreamWrapper::getResource($response->getBody())) === false) { throw NativeFunctionErrored::createFromLastPhpError(); }