diff --git a/README.md b/README.md index ebd1571..50c1288 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ $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. +`95cd9945-484f-4f89-8bdb-23dbdd0bdea9`) with either a `.zip` or a `.pdf` file extension (or image formats for screenshots). You may also explicitly set the HTTP client: diff --git a/composer.json b/composer.json index 2075d86..c617bb4 100644 --- a/composer.json +++ b/composer.json @@ -42,10 +42,10 @@ "psr/http-message": "^1.0|^2.0" }, "require-dev": { - "doctrine/coding-standard": "^12.0", - "pestphp/pest": "^2.28", + "doctrine/coding-standard": "^14.0", + "pestphp/pest": "^2.36", "phpstan/phpstan": "^1.12", - "squizlabs/php_codesniffer": "^3.10" + "squizlabs/php_codesniffer": "^4.0" }, "autoload": { "psr-4": { diff --git a/src/DownloadFrom.php b/src/DownloadFrom.php index acc1740..3d0cddb 100644 --- a/src/DownloadFrom.php +++ b/src/DownloadFrom.php @@ -12,14 +12,16 @@ class DownloadFrom implements JsonSerializable public function __construct( public readonly string $url, public readonly array|null $extraHttpHeaders = null, + public readonly bool $embedded = false, ) { } - /** @return array> */ + /** @return array|bool|string> */ public function jsonSerialize(): array { $serialized = [ 'url' => $this->url, + 'embedded' => $this->embedded, ]; if (! empty($this->extraHttpHeaders)) { diff --git a/src/Modules/ChromiumPdf.php b/src/Modules/ChromiumPdf.php index ba6676a..e1dac3b 100644 --- a/src/Modules/ChromiumPdf.php +++ b/src/Modules/ChromiumPdf.php @@ -215,6 +215,39 @@ public function metadata(array $metadata): self 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; + } + + /** + * 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; + } + /** * Converts a target URL to PDF. * diff --git a/src/Modules/LibreOffice.php b/src/Modules/LibreOffice.php index e5b9adf..4ebc47e 100644 --- a/src/Modules/LibreOffice.php +++ b/src/Modules/LibreOffice.php @@ -358,6 +358,29 @@ public function flatten(): self 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; + } + + /** + * 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; + } + /** * 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. diff --git a/src/Modules/PdfEngines.php b/src/Modules/PdfEngines.php index 3290f7d..9e107d2 100644 --- a/src/Modules/PdfEngines.php +++ b/src/Modules/PdfEngines.php @@ -81,6 +81,30 @@ public function flattening(): self 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; + } + + /** + * 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; + } + /** * Merges PDFs into a unique PDF. * @@ -186,4 +210,42 @@ public function writeMetadata(array $metadata, Stream ...$pdfs): RequestInterfac 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(); + } + + /** + * 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(); + } } diff --git a/src/MultipartFormDataModule.php b/src/MultipartFormDataModule.php index e0c54cd..80087b8 100644 --- a/src/MultipartFormDataModule.php +++ b/src/MultipartFormDataModule.php @@ -118,10 +118,10 @@ protected function formValue(string $name, mixed $value): self return $this; } - protected function formFile(string $filename, StreamInterface $stream): void + protected function formFile(string $filename, StreamInterface $stream, string $name = 'files'): void { $this->multipartFormData[] = [ - 'name' => 'files', + 'name' => $name, 'filename' => $filename, 'contents' => $stream, ]; diff --git a/tests/Modules/ChromiumPdfTest.php b/tests/Modules/ChromiumPdfTest.php index 68c8f65..e0c467b 100644 --- a/tests/Modules/ChromiumPdfTest.php +++ b/tests/Modules/ChromiumPdfTest.php @@ -17,6 +17,7 @@ * @param int[] $failOnHttpStatusCodes * @param int[] $failOnResourceHttpStatusCodes * @param array> $metadata + * @param Stream[] $embeds * @param Stream[] $assets */ function ( @@ -53,6 +54,10 @@ function ( string|null $pdfa = null, bool $pdfua = false, array $metadata = [], + bool $flatten = false, + string $userPassword = '', + string $ownerPassword = '', + array $embeds = [], array $assets = [], ): void { $chromium = Gotenberg::chromium('')->pdf(); @@ -90,6 +95,10 @@ function ( $pdfa, $pdfua, $metadata, + $flatten, + $userPassword, + $ownerPassword, + $embeds, $assets, ); @@ -133,6 +142,10 @@ function ( $pdfa, $pdfua, $metadata, + $flatten, + $userPassword, + $ownerPassword, + $embeds, $assets, ); }, @@ -179,6 +192,13 @@ function ( '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'), + ], [ Stream::string('my.jpg', 'Image content'), ], @@ -193,6 +213,7 @@ function ( * @param int[] $failOnHttpStatusCodes * @param int[] $failOnResourceHttpStatusCodes * @param array> $metadata + * @param Stream[] $embeds * @param Stream[] $assets */ function ( @@ -229,6 +250,10 @@ function ( string|null $pdfa = null, bool $pdfua = false, array $metadata = [], + bool $flatten = false, + string $userPassword = '', + string $ownerPassword = '', + array $embeds = [], array $assets = [], ): void { $chromium = Gotenberg::chromium('')->pdf(); @@ -266,6 +291,10 @@ function ( $pdfa, $pdfua, $metadata, + $flatten, + $userPassword, + $ownerPassword, + $embeds, $assets, ); @@ -311,6 +340,10 @@ function ( $pdfa, $pdfua, $metadata, + $flatten, + $userPassword, + $ownerPassword, + $embeds, $assets, ); }, @@ -356,6 +389,13 @@ function ( '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'), + ], [ Stream::string('my.jpg', 'Image content'), ], @@ -371,6 +411,7 @@ function ( * @param int[] $failOnResourceHttpStatusCodes * @param Stream[] $markdowns * @param array> $metadata + * @param Stream[] $embeds * @param Stream[] $assets */ function ( @@ -408,6 +449,10 @@ function ( string|null $pdfa = null, bool $pdfua = false, array $metadata = [], + bool $flatten = false, + string $userPassword = '', + string $ownerPassword = '', + array $embeds = [], array $assets = [], ): void { $chromium = Gotenberg::chromium('')->pdf(); @@ -445,6 +490,10 @@ function ( $pdfa, $pdfua, $metadata, + $flatten, + $userPassword, + $ownerPassword, + $embeds, $assets, ); @@ -495,6 +544,10 @@ function ( $pdfa, $pdfua, $metadata, + $flatten, + $userPassword, + $ownerPassword, + $embeds, $assets, ); }, @@ -549,6 +602,13 @@ function ( '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'), + ], [ Stream::string('my.jpg', 'Image content'), ], @@ -561,6 +621,7 @@ function ( * @param int[] $failOnHttpStatusCodes * @param int[] $failOnResourceHttpStatusCodes * @param array> $metadata + * @param Stream[] $embeds * @param Stream[] $assets */ function hydrateChromiumPdfFormData( @@ -597,6 +658,10 @@ function hydrateChromiumPdfFormData( string|null $pdfa = null, bool $pdfua = false, array $metadata = [], + bool $flatten = false, + string $userPassword = '', + string $ownerPassword = '', + array $embeds = [], array $assets = [], ): ChromiumPdf { if ($singlePage) { @@ -715,6 +780,18 @@ function hydrateChromiumPdfFormData( $chromium->metadata($metadata); } + if ($flatten) { + $chromium->flatten(); + } + + if ($userPassword !== '') { + $chromium->encrypt($userPassword, $ownerPassword); + } + + if (count($embeds) > 0) { + $chromium->embeds(...$embeds); + } + if (count($assets) > 0) { $chromium->assets(...$assets); } @@ -728,6 +805,7 @@ function hydrateChromiumPdfFormData( * @param int[] $failOnHttpStatusCodes * @param int[] $failOnResourceHttpStatusCodes * @param array> $metadata + * @param Stream[] $embeds * @param Stream[] $assets */ function expectChromiumPdfOptions( @@ -764,6 +842,10 @@ function expectChromiumPdfOptions( string|null $pdfa, bool $pdfua, array $metadata, + bool $flatten, + string $userPassword, + string $ownerPassword, + array $embeds, array $assets, ): void { expect($body)->unless($singlePage === false, fn ($body) => $body->toContainFormValue('singlePage', '1')); @@ -865,6 +947,15 @@ function expectChromiumPdfOptions( expect($body)->toContainFormValue('metadata', $json); } + expect($body)->unless($flatten === false, fn ($body) => $body->toContainFormValue('flatten', '1')); + expect($body)->unless($userPassword === '', fn ($body) => $body->toContainFormValue('userPassword', $userPassword)); + expect($body)->unless($userPassword === '', fn ($body) => $body->toContainFormValue('ownerPassword', $ownerPassword)); + + foreach ($embeds as $embed) { + $embed->getStream()->rewind(); + expect($body)->toContainFormFile($embed->getFilename(), $embed->getStream()->getContents(), 'application/xml', 'embeds'); + } + if (count($assets) <= 0) { return; } diff --git a/tests/Modules/LibreOfficeTest.php b/tests/Modules/LibreOfficeTest.php index 4350468..2078973 100644 --- a/tests/Modules/LibreOfficeTest.php +++ b/tests/Modules/LibreOfficeTest.php @@ -13,6 +13,7 @@ /** * @param Stream[] $files * @param array> $metadata + * @param Stream[] $embeds */ function ( array $files, @@ -45,6 +46,9 @@ function ( array $metadata = [], bool $merge = false, bool $flatten = false, + string $userPassword = '', + string $ownerPassword = '', + array $embeds = [], ): void { $libreOffice = Gotenberg::libreOffice(''); @@ -166,6 +170,14 @@ function ( $libreOffice->flatten(); } + if ($userPassword !== '') { + $libreOffice->encrypt($userPassword, $ownerPassword); + } + + if (count($embeds) > 0) { + $libreOffice->embeds(...$embeds); + } + $request = $libreOffice->convert(...$files); $body = sanitize($request->getBody()->getContents()); @@ -204,6 +216,8 @@ function ( expect($body)->unless($pdfua === false, fn ($body) => $body->toContainFormValue('pdfua', '1')); expect($body)->unless($merge === false, fn ($body) => $body->toContainFormValue('merge', '1')); expect($body)->unless($flatten === false, fn ($body) => $body->toContainFormValue('flatten', '1')); + expect($body)->unless($userPassword === '', fn ($body) => $body->toContainFormValue('userPassword', $userPassword)); + expect($body)->unless($userPassword === '', fn ($body) => $body->toContainFormValue('ownerPassword', $ownerPassword)); if (count($metadata) > 0) { $json = json_encode($metadata); @@ -220,6 +234,11 @@ function ( expect($body)->toContainFormFile($filename, $file->getStream()->getContents(), 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); } + + foreach ($embeds as $embed) { + $embed->getStream()->rewind(); + expect($body)->toContainFormFile($embed->getFilename(), $embed->getStream()->getContents(), 'application/xml', 'embeds'); + } }, )->with([ [ @@ -261,5 +280,11 @@ function ( [ 'Producer' => 'Gotenberg' ], true, true, + 'my_user_password', + 'my_owner_password', + [ + Stream::string('my.xml', 'XML content'), + Stream::string('my_second.xml', 'Second XML content'), + ], ], ]); diff --git a/tests/Modules/PdfEnginesTest.php b/tests/Modules/PdfEnginesTest.php index 8ab07ea..936e781 100644 --- a/tests/Modules/PdfEnginesTest.php +++ b/tests/Modules/PdfEnginesTest.php @@ -13,8 +13,9 @@ /** * @param Stream[] $pdfs * @param array> $metadata + * @param Stream[] $embeds */ - function (array $pdfs, string|null $pdfa = null, bool $pdfua = false, array $metadata = [], bool $flatten = false): void { + function (array $pdfs, string|null $pdfa = null, bool $pdfua = false, array $metadata = [], bool $flatten = false, string $userPassword = '', string $ownerPassword = '', array $embeds = []): void { $pdfEngines = Gotenberg::pdfEngines('')->index(new DummyIndex()); if ($pdfa !== null) { @@ -33,6 +34,14 @@ function (array $pdfs, string|null $pdfa = null, bool $pdfua = false, array $met $pdfEngines->flattening(); } + if ($userPassword !== '') { + $pdfEngines->encrypting($userPassword, $ownerPassword); + } + + if (count($embeds) > 0) { + $pdfEngines->embeds(...$embeds); + } + $request = $pdfEngines->merge(...$pdfs); $body = sanitize($request->getBody()->getContents()); @@ -50,11 +59,18 @@ function (array $pdfs, string|null $pdfa = null, bool $pdfua = false, array $met } expect($body)->unless($flatten === false, fn ($body) => $body->toContainFormValue('flatten', '1')); + expect($body)->unless($userPassword === '', fn ($body) => $body->toContainFormValue('userPassword', $userPassword)); + expect($body)->unless($userPassword === '', fn ($body) => $body->toContainFormValue('ownerPassword', $ownerPassword)); foreach ($pdfs as $pdf) { $pdf->getStream()->rewind(); expect($body)->toContainFormFile('foo_' . $pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); } + + foreach ($embeds as $embed) { + $embed->getStream()->rewind(); + expect($body)->toContainFormFile($embed->getFilename(), $embed->getStream()->getContents(), 'application/xml', 'embeds'); + } }, )->with([ [ @@ -73,13 +89,22 @@ function (array $pdfs, string|null $pdfa = null, bool $pdfua = false, array $met true, [ 'Producer' => 'Gotenberg' ], true, + 'my_user_password', + 'my_owner_password', + [ + Stream::string('my.xml', 'XML content'), + Stream::string('my_second.xml', 'Second XML content'), + ], ], ]); it( 'creates a valid request for the "/forms/pdfengines/split" endpoint', - /** @param Stream[] $pdfs */ - function (array $pdfs, SplitMode $mode, string|null $pdfa = null, bool $pdfua = false, array $metadata = [], bool $flatten = false): void { + /** + * @param Stream[] $pdfs + * @param Stream[] $embeds + */ + function (array $pdfs, SplitMode $mode, string|null $pdfa = null, bool $pdfua = false, array $metadata = [], bool $flatten = false, string $userPassword = '', string $ownerPassword = '', array $embeds = []): void { $pdfEngines = Gotenberg::pdfEngines(''); if ($pdfa !== null) { @@ -98,6 +123,14 @@ function (array $pdfs, SplitMode $mode, string|null $pdfa = null, bool $pdfua = $pdfEngines->flattening(); } + if ($userPassword !== '') { + $pdfEngines->encrypting($userPassword, $ownerPassword); + } + + if (count($embeds) > 0) { + $pdfEngines->embeds(...$embeds); + } + $request = $pdfEngines->split($mode, ...$pdfs); $body = sanitize($request->getBody()->getContents()); @@ -118,11 +151,18 @@ function (array $pdfs, SplitMode $mode, string|null $pdfa = null, bool $pdfua = } expect($body)->unless($flatten === false, fn ($body) => $body->toContainFormValue('flatten', '1')); + expect($body)->unless($userPassword === '', fn ($body) => $body->toContainFormValue('userPassword', $userPassword)); + expect($body)->unless($userPassword === '', fn ($body) => $body->toContainFormValue('ownerPassword', $ownerPassword)); foreach ($pdfs as $pdf) { $pdf->getStream()->rewind(); expect($body)->toContainFormFile($pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); } + + foreach ($embeds as $embed) { + $embed->getStream()->rewind(); + expect($body)->toContainFormFile($embed->getFilename(), $embed->getStream()->getContents(), 'application/xml', 'embeds'); + } }, )->with([ [ @@ -142,6 +182,12 @@ function (array $pdfs, SplitMode $mode, string|null $pdfa = null, bool $pdfua = true, [ 'Producer' => 'Gotenberg' ], true, + 'my_user_password', + 'my_owner_password', + [ + Stream::string('my.xml', 'XML content'), + Stream::string('my_second.xml', 'Second XML content'), + ], ], ]); @@ -266,3 +312,69 @@ function (array $metadata, array $pdfs): void { ], ], ]); + +it( + 'creates a valid request for the "/forms/pdfengines/encrypt" endpoint', + /** @param Stream[] $pdfs */ + function (string $userPassword, string $ownerPassword, array $pdfs): void { + $pdfEngines = Gotenberg::pdfEngines(''); + + $request = $pdfEngines->encrypt($userPassword, $ownerPassword, ...$pdfs); + $body = sanitize($request->getBody()->getContents()); + + expect($request->getUri()->getPath())->toBe('/forms/pdfengines/encrypt'); + expect($body)->toContainFormValue('userPassword', $userPassword); + expect($body)->toContainFormValue('ownerPassword', $ownerPassword); + + foreach ($pdfs as $pdf) { + $pdf->getStream()->rewind(); + expect($body)->toContainFormFile($pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); + } + }, +)->with([ + [ + 'my_user_password', + 'my_owner_password', + [ + Stream::string('my.pdf', 'PDF content'), + Stream::string('my_second.pdf', 'Second PDF content'), + ], + ], +]); + +it( + 'creates a valid request for the "/forms/pdfengines/embed" endpoint', + /** + * @param Stream[] $pdfs + * @param Stream[] $embeds + */ + function (array $embeds, array $pdfs): void { + $pdfEngines = Gotenberg::pdfEngines(''); + + $request = $pdfEngines->embed($embeds, ...$pdfs); + $body = sanitize($request->getBody()->getContents()); + + expect($request->getUri()->getPath())->toBe('/forms/pdfengines/embed'); + + foreach ($pdfs as $pdf) { + $pdf->getStream()->rewind(); + expect($body)->toContainFormFile($pdf->getFilename(), $pdf->getStream()->getContents(), 'application/pdf'); + } + + foreach ($embeds as $embed) { + $embed->getStream()->rewind(); + expect($body)->toContainFormFile($embed->getFilename(), $embed->getStream()->getContents(), 'application/xml', 'embeds'); + } + }, +)->with([ + [ + [ + 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'), + ], + ], +]); diff --git a/tests/MultipartFormDataModuleTest.php b/tests/MultipartFormDataModuleTest.php index ec48438..19437f0 100644 --- a/tests/MultipartFormDataModuleTest.php +++ b/tests/MultipartFormDataModuleTest.php @@ -14,7 +14,7 @@ function (): void { ->outputFilename('my_filename') ->downloadFrom([ new DownloadFrom('https://my.url/my_filename'), - new DownloadFrom('https://my.url/my_filename_2', ['X-Header' => 'value']), + new DownloadFrom('https://my.url/my_filename_2', ['X-Header' => 'value'], true), ]) ->webhook('https://my.webhook.url', 'https://my.webhook.error.url') ->webhookMethod('POST') @@ -27,7 +27,7 @@ function (): void { expect($request->getHeader('Gotenberg-Output-Filename'))->toMatchArray(['my_filename']); - expect(sanitize($request->getBody()->getContents()))->toContainFormValue('downloadFrom', '[{"url":"https:\/\/my.url\/my_filename"},{"url":"https:\/\/my.url\/my_filename_2","extraHttpHeaders":{"X-Header":"value"}}]'); + expect(sanitize($request->getBody()->getContents()))->toContainFormValue('downloadFrom', '[{"url":"https:\/\/my.url\/my_filename","embedded":false},{"url":"https:\/\/my.url\/my_filename_2","embedded":true,"extraHttpHeaders":{"X-Header":"value"}}]'); expect($request->getHeader('Gotenberg-Webhook-Url'))->toMatchArray(['https://my.webhook.url']); expect($request->getHeader('Gotenberg-Webhook-Error-Url'))->toMatchArray(['https://my.webhook.error.url']); diff --git a/tests/Pest.php b/tests/Pest.php index ddb7354..0f7e7a3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -22,10 +22,10 @@ function sanitize(string $body): string ); }); -expect()->extend('toContainFormFile', function (string $filename, string $content, string|null $contentType = null) { +expect()->extend('toContainFormFile', function (string $filename, string $content, string|null $contentType = null, string $fieldName = 'files') { $length = mb_strlen($content); - $needle = 'Content-Disposition: form-data; name="files"; filename="' . $filename . '" Content-Length: ' . $length; + $needle = 'Content-Disposition: form-data; name="' . $fieldName . '"; filename="' . $filename . '" Content-Length: ' . $length; if ($contentType !== null) { $needle .= ' Content-Type: ' . $contentType; }