diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ba0b7a99..347c557b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,6 +29,11 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Validate composer.json and composer.lock run: composer validate --strict @@ -66,3 +71,6 @@ jobs: - name: Run PHP CS Fixer run: php vendor/bin/php-cs-fixer fix -v --dry-run --stop-on-violation + + - name: Run test suite + run: php vendor/bin/phpstan diff --git a/composer.json b/composer.json index 26dd9d27..422a3684 100644 --- a/composer.json +++ b/composer.json @@ -27,20 +27,24 @@ "symfony/mime": "^6.4 || ^7.0", "symfony/string": "^6.4 || ^7.0", "symfony/http-client": "^6.4 || ^7.0", - "symfony/filesystem": "^6.4 || ^7.0", - "symfony/twig-bundle": "^6.4 || ^7.0" + "symfony/filesystem": "^6.4 || ^7.0" }, "require-dev": { - "phpunit/phpunit": "^10.4", - "symfony/framework-bundle": "^6.4 || ^7.0", + "friendsofphp/php-cs-fixer": "^3.41", "phpstan/phpstan": "^1.10", "phpstan/extension-installer": "^1.3", "phpstan/phpstan-symfony": "^1.3", - "friendsofphp/php-cs-fixer": "^3.41" + "phpunit/phpunit": "^10.4", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/twig-bundle": "^6.4 || ^7.0" }, "config": { "allow-plugins": { "phpstan/extension-installer": true - } + }, + "sort-packages": true + }, + "suggest": { + "symfony/twig-bundle": "Allows you to use Twig to render templates into PDF" } } diff --git a/config/services.php b/config/services.php index 14fe08fa..a25b932b 100644 --- a/config/services.php +++ b/config/services.php @@ -1,8 +1,11 @@ set('sensiolabs_gotenberg', Gotenberg::class) ->args([ service('sensiolabs_gotenberg.client'), - service('twig'), abstract_arg('user configuration options'), param('kernel.project_dir'), + service('twig')->nullOnInvalid(), ]) - ->public(); - $services->alias(Gotenberg::class, 'sensiolabs_gotenberg') - ->private(); + ->public() + ->alias(GotenbergInterface::class, 'sensiolabs_gotenberg'); $services->set('sensiolabs_gotenberg.client', GotenbergClient::class) ->args([ abstract_arg('base_uri to gotenberg API'), - service('Symfony\Contracts\HttpClient\HttpClientInterface'), + service(HttpClientInterface::class), ]) - ->public(); - $services->alias(GotenbergClient::class, 'sensiolabs_gotenberg.client') - ->private(); + ->public() + ->alias(GotenbergClientInterface::class, 'sensiolabs_gotenberg.client'); }; diff --git a/phpstan.neon b/phpstan.neon index d7d2a144..7b035798 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,16 +3,3 @@ parameters: paths: - 'src' - 'tests' - ignoreErrors: - - - message: "#^Offset 'files' does not exist on array#" - count: 1 - path: tests/Builder/OfficePdfBuilderTest.php - - - message: "#^Offset 'files' does not exist on array#" - count: 3 - path: tests/Builder/TwigPdfBuilderTest.php - - - message: "#^Offset 'files' does not exist on array#" - count: 1 - path: tests/Builder/MarkdownPdfBuilderTest.php diff --git a/src/Builder/AbstractChromiumPdfBuilder.php b/src/Builder/AbstractChromiumPdfBuilder.php new file mode 100644 index 00000000..b558b9c1 --- /dev/null +++ b/src/Builder/AbstractChromiumPdfBuilder.php @@ -0,0 +1,444 @@ +paperWidth($width); + $this->paperHeight($height); + + return $this; + } + + public function paperWidth(float $width): static + { + $this->formFields['paperWidth'] = $width; + + return $this; + } + + public function paperHeight(float $height): static + { + $this->formFields['paperHeight'] = $height; + + return $this; + } + + /** + * Overrides the default margins (e.g., 0.39), in inches. + * + * @see https://gotenberg.dev/docs/routes#page-properties-chromium + */ + public function margins(float $top, float $bottom, float $left, float $right): static + { + $this->marginTop($top); + $this->marginBottom($bottom); + $this->marginLeft($left); + $this->marginRight($right); + + return $this; + } + + public function marginTop(float $top): static + { + $this->formFields['marginTop'] = $top; + + return $this; + } + + public function marginBottom(float $bottom): static + { + $this->formFields['marginBottom'] = $bottom; + + return $this; + } + + public function marginLeft(float $left): static + { + $this->formFields['marginLeft'] = $left; + + return $this; + } + + public function marginRight(float $right): static + { + $this->formFields['marginRight'] = $right; + + return $this; + } + + /** + * Define whether to prefer page size as defined by CSS. (Default false). + * + * @see https://gotenberg.dev/docs/routes#page-properties-chromium + */ + public function preferCssPageSize(bool $bool = true): static + { + $this->formFields['preferCssPageSize'] = $bool; + + return $this; + } + + /** + * Prints the background graphics. (Default false). + * + * @see https://gotenberg.dev/docs/routes#page-properties-chromium + */ + public function printBackground(bool $bool = true): static + { + $this->formFields['printBackground'] = $bool; + + return $this; + } + + /** + * Hides default white background and allows generating PDFs with + * transparency. (Default false). + * + * @see https://gotenberg.dev/docs/routes#page-properties-chromium + */ + public function omitBackground(bool $bool = true): static + { + $this->formFields['omitBackground'] = $bool; + + return $this; + } + + /** + * Sets the paper orientation to landscape. (Default false). + * + * @see https://gotenberg.dev/docs/routes#page-properties-chromium + */ + public function landscape(bool $bool = true): static + { + $this->formFields['landscape'] = $bool; + + return $this; + } + + /** + * The scale of the page rendering (e.g., 1.0). (Default 1.0). + * + * @see https://gotenberg.dev/docs/routes#page-properties-chromium + */ + public function scale(float $scale): static + { + $this->formFields['scale'] = $scale; + + return $this; + } + + /** + * Page ranges to print, e.g., '1-5, 8, 11-13'. (default All pages). + * + * @see https://gotenberg.dev/docs/routes#page-properties-chromium + */ + public function nativePageRanges(string $range): static + { + $this->formFields['nativePageRanges'] = $range; + + return $this; + } + + /** + * @param array $context + * + * @throws PdfPartRenderingException if the template could not be rendered + */ + public function header(string $template, array $context = []): static + { + return $this->withRenderedPart(PdfPart::HeaderPart, $template, $context); + } + + /** + * @param array $context + * + * @throws PdfPartRenderingException if the template could not be rendered + */ + public function footer(string $template, array $context = []): static + { + return $this->withRenderedPart(PdfPart::FooterPart, $template, $context); + } + + /** + * HTML file containing the header. (default None). + */ + public function headerFile(string $path): static + { + return $this->withPdfPartFile(PdfPart::HeaderPart, $path); + } + + /** + * HTML file containing the footer. (default None). + */ + public function footerFile(string $path): static + { + return $this->withPdfPartFile(PdfPart::FooterPart, $path); + } + + /** + * Adds additional files, like images, fonts, stylesheets, and so on (overrides any previous files). + */ + public function assets(string ...$paths): static + { + $this->formFields['assets'] = []; + + foreach ($paths as $path) { + $this->addAsset($path); + } + + return $this; + } + + /** + * Adds a file, like an image, font, stylesheet, and so on. + */ + public function addAsset(string $path): static + { + $dataPart = new DataPart(new DataPartFile($this->resolveFilePath($path))); + + $this->formFields['assets'][$path] = $dataPart; + + return $this; + } + + /** + * Sets the duration (i.e., "1s", "2ms", etc.) to wait when loading an HTML + * document before converting it to PDF. (default None). + * + * @see https://gotenberg.dev/docs/routes#wait-before-rendering + */ + public function waitDelay(string $delay): static + { + $this->formFields['waitDelay'] = $delay; + + return $this; + } + + /** + * Sets the JavaScript expression to wait before converting an HTML + * document to PDF until it returns true. (default None). + * + * For instance: "window.status === 'ready'". + * + * @see https://gotenberg.dev/docs/routes#wait-before-rendering + */ + public function waitForExpression(string $expression): static + { + $this->formFields['waitForExpression'] = $expression; + + return $this; + } + + /** + * Forces Chromium to emulate, either "screen" or "print". (default "print"). + * + * @see https://gotenberg.dev/docs/routes#console-exceptions + */ + public function emulatedMediaType(string $mediaType): static + { + $this->formFields['emulatedMediaType'] = $mediaType; + + return $this; + } + + /** + * Overrides the default "User-Agent" header.(default None). + * + * @see https://gotenberg.dev/docs/routes#custom-http-headers + */ + public function userAgent(string $userAgent): static + { + $this->formFields['userAgent'] = $userAgent; + + return $this; + } + + /** + * Sets extra HTTP headers that Chromium will send when loading the HTML + * document. (default None). + * + * @see https://gotenberg.dev/docs/routes#custom-http-headers + * + * @param array $headers + */ + public function extraHttpHeaders(array $headers): static + { + $this->formFields['extraHttpHeaders'] = $headers; + + return $this; + } + + /** + * Adds extra HTTP headers that Chromium will send when loading the HTML + * document. (default None). + * + * @see https://gotenberg.dev/docs/routes#custom-http-headers + * + * @param array $headers + */ + public function addExtraHttpHeaders(array $headers): static + { + $this->formFields['extraHttpHeaders'] = [ + ...$this->formFields['extraHttpHeaders'], + ...$headers, + ]; + + return $this; + } + + /** + * Forces Gotenberg to return a 409 Conflict response if there are + * exceptions in the Chromium console. (default false). + * + * @see https://gotenberg.dev/docs/routes#console-exceptions + */ + public function failOnConsoleExceptions(bool $bool = true): static + { + $this->formFields['failOnConsoleExceptions'] = $bool; + + return $this; + } + + /** + * Sets the PDF format of the resulting PDF. (default None). + * + * @See https://gotenberg.dev/docs/routes#pdfa-chromium. + */ + public function pdfFormat(string $format): static + { + $this->formFields['pdfa'] = $format; + + return $this; + } + + /** + * Enable PDF for Universal Access for optimal accessibility. (default false). + * + * @See https://gotenberg.dev/docs/routes#pdfa-chromium. + */ + public function pdfUniversalAccess(bool $bool = true): static + { + $this->formFields['pdfua'] = $bool; + + return $this; + } + + /** + * @throws ExtraHttpHeadersJsonEncodingException + */ + public function getMultipartFormData(): array + { + $formFields = $this->formFields; + $multipartFormData = []; + + $extraHttpHeaders = $this->formFields['extraHttpHeaders'] ?? []; + if ([] !== $extraHttpHeaders) { + try { + $extraHttpHeaders = json_encode($extraHttpHeaders, \JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new ExtraHttpHeadersJsonEncodingException('Could not encode extra HTTP headers into JSON', previous: $exception); + } + + $multipartFormData[] = [ + 'extraHttpHeaders' => $extraHttpHeaders, + ]; + unset($formFields['extraHttpHeaders']); + } + + foreach ($formFields as $key => $value) { + if (\is_bool($value)) { + $multipartFormData[] = [ + $key => $value ? 'true' : 'false', + ]; + continue; + } + + if (\is_array($value)) { + foreach ($value as $nestedValue) { + $multipartFormData[] = [ + ($nestedValue instanceof DataPart ? 'files' : $key) => $nestedValue, + ]; + } + continue; + } + + $multipartFormData[] = [ + ($value instanceof DataPart ? 'files' : $key) => $value, + ]; + } + + return $multipartFormData; + } + + protected function withPdfPartFile(PdfPart $pdfPart, string $path): static + { + $dataPart = new DataPart( + new DataPartFile($this->resolveFilePath($path)), + $pdfPart->value, + ); + + $this->formFields[$pdfPart->value] = $dataPart; + + return $this; + } + + /** + * @param array $context + * + * @throws PdfPartRenderingException if the template could not be rendered + */ + protected function withRenderedPart(PdfPart $pdfPart, string $template, array $context = []): static + { + if (!$this->twig instanceof Environment) { + throw new \LogicException(sprintf('Twig is required to use "%s" method. Try to run "composer require symfony/twig-bundle".', __METHOD__)); + } + + try { + $html = $this->twig->render($template, $context); + } catch (\Throwable $error) { + throw new PdfPartRenderingException(sprintf('Could not render template "%s" into PDF part "%s".', $template, $pdfPart->value), previous: $error); + } + + $this->formFields[$pdfPart->value] = new DataPart($html, $pdfPart->value, 'text/html'); + + return $this; + } +} diff --git a/src/Builder/AbstractPdfBuilder.php b/src/Builder/AbstractPdfBuilder.php new file mode 100644 index 00000000..2b00de9b --- /dev/null +++ b/src/Builder/AbstractPdfBuilder.php @@ -0,0 +1,79 @@ + + */ + protected array $formFields = []; + + public function __construct( + protected readonly GotenbergClientInterface $gotenbergClient, + protected readonly string $projectDir, + ) { + } + + /** + * Compiles the form values into a multipart form data array to send to the HTTP client. + * + * @return array> + * + * @throws MissingRequiredFieldException + */ + abstract public function getMultipartFormData(): array; + + /** + * The Gotenberg API endpoint path. + */ + abstract protected function getEndpoint(): string; + + public function generate(): PdfResponse + { + return $this->gotenbergClient->call($this->getEndpoint(), $this->getMultipartFormData()); + } + + /** + * To set configurations by an array of configurations. + * + * @param array $configurations + */ + public function setConfigurations(array $configurations): static + { + foreach ($configurations as $property => $value) { + $method = (new UnicodeString($property))->camel()->toString(); + if (!method_exists($this, $method)) { + throw new \InvalidArgumentException(sprintf('Invalid option "%s": the method "%s" does not exist in class "%s".', $property, $method, static::class)); + } + + $this->{$method}($value); + } + + return $this; + } + + /** + * @param string[] $validExtensions + */ + protected function assertFileExtension(string $path, array $validExtensions): void + { + $file = new File($this->resolveFilePath($path)); + $extension = $file->getExtension(); + + if (!\in_array($extension, $validExtensions, true)) { + throw new \InvalidArgumentException(sprintf('The file extension "%s" is not available in Gotenberg.', $extension)); + } + } + + protected function resolveFilePath(string $path): string + { + return str_starts_with($path, '/') ? $path : $this->projectDir.'/'.$path; + } +} diff --git a/src/Builder/BuilderInterface.php b/src/Builder/BuilderInterface.php deleted file mode 100644 index adac1cfd..00000000 --- a/src/Builder/BuilderInterface.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ -interface BuilderInterface -{ - public function getEndpoint(): string; - - /** - * @return ConfigBuilder - */ - public function getMultipartFormData(): array; -} diff --git a/src/Builder/BuilderTrait.php b/src/Builder/BuilderTrait.php deleted file mode 100644 index 24f4c9a0..00000000 --- a/src/Builder/BuilderTrait.php +++ /dev/null @@ -1,418 +0,0 @@ -, - * 'fail_on_console_exceptions'?: bool, - * 'pdf_format'?: string, - * 'pdf_universal_access'?: bool, - * } - */ -trait BuilderTrait -{ - /** - * @var ConfigBuilder - */ - private array $multipartFormData = []; - - public function getMultipartFormData(): array - { - return $this->multipartFormData; - } - - /** - * To set configurations by an array of configurations. - * - * @param ConfigOptions $configurations - */ - public function setConfigurations(array $configurations): self - { - foreach ($configurations as $property => $value) { - $method = u($property)->camel()->toString(); - if (\is_callable([$this, $method])) { - $this->{$method}($value); - } - } - - return $this; - } - - /** - * Add a twig template for the header. - * - * @see https://gotenberg.dev/docs/routes#header--footer - * - * @param array $context - */ - public function header(string $path, array $context = []): self - { - return $this->addTwigTemplate($path, PdfPart::HeaderPart, $context); - } - - /** - * Add a twig template for the footer. - * - * @see https://gotenberg.dev/docs/routes#header--footer - * - * @param array $context - */ - public function footer(string $path, array $context = []): self - { - return $this->addTwigTemplate($path, PdfPart::FooterPart, $context); - } - - /** - * Add some assets as img, css, js. - * - * Assets are not loaded in header and footer - * - * @see https://gotenberg.dev/docs/routes#url-into-pdf-route - */ - public function assets(string ...$pathToAssets): self - { - foreach ($pathToAssets as $filePath) { - $file = new DataPartFile($this->resolveFilePath($filePath)); - $dataPart = new DataPart($file); - - $this->multipartFormData[] = [ - 'files' => $dataPart, - ]; - } - - return $this; - } - - /** - * Overrides the default paper size, in inches. - * - * 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 - * - * @see https://gotenberg.dev/docs/routes#page-properties-chromium - */ - public function paperSize(float $width, float $height): self - { - $this->paperWidth($width); - $this->paperHeight($height); - - return $this; - } - - /** - * Overrides the default margins (e.g., 0.39), in inches. - * - * @see https://gotenberg.dev/docs/routes#page-properties-chromium - */ - public function margins(float $top, float $bottom, float $left, float $right): self - { - $this->marginTop($top); - $this->marginBottom($bottom); - $this->marginLeft($left); - $this->marginRight($right); - - return $this; - } - - /** - * Define whether to prefer page size as defined by CSS. (Default false). - * - * @see https://gotenberg.dev/docs/routes#page-properties-chromium - */ - public function preferCssPageSize(): self - { - $this->multipartFormData[] = ['preferCssPageSize' => true]; - - return $this; - } - - /** - * Prints the background graphics. (Default false). - * - * @see https://gotenberg.dev/docs/routes#page-properties-chromium - */ - public function printBackground(): self - { - $this->multipartFormData[] = ['printBackground' => true]; - - return $this; - } - - /** - * Hides default white background and allows generating PDFs with - * transparency. (Default false). - * - * @see https://gotenberg.dev/docs/routes#page-properties-chromium - */ - public function omitBackground(): self - { - $this->multipartFormData[] = ['omitBackground' => true]; - - return $this; - } - - /** - * Sets the paper orientation to landscape. (Default false). - * - * @see https://gotenberg.dev/docs/routes#page-properties-chromium - */ - public function landscape(): self - { - $this->multipartFormData[] = ['landscape' => true]; - - return $this; - } - - /** - * The scale of the page rendering (e.g., 1.0). (Default 1.0). - * - * @see https://gotenberg.dev/docs/routes#page-properties-chromium - */ - public function scale(float $scale): self - { - $this->multipartFormData[] = ['scale' => $scale]; - - return $this; - } - - /** - * Page ranges to print, e.g., '1-5, 8, 11-13'. (default All pages). - * - * @see https://gotenberg.dev/docs/routes#page-properties-chromium - */ - public function nativePageRanges(string $range): self - { - $this->multipartFormData[] = ['nativePageRanges' => $range]; - - return $this; - } - - /** - * Sets the duration (i.e., "1s", "2ms", etc.) to wait when loading an HTML - * document before converting it to PDF. (default None). - * - * @see https://gotenberg.dev/docs/routes#wait-before-rendering - */ - public function waitDelay(string $delay): self - { - $this->multipartFormData[] = ['waitDelay' => $delay]; - - return $this; - } - - /** - * Sets the JavaScript expression to wait before converting an HTML - * document to PDF until it returns true. (default None). - * - * For instance: "window.status === 'ready'". - * - * @see https://gotenberg.dev/docs/routes#wait-before-rendering - */ - public function waitForExpression(string $expression): self - { - $this->multipartFormData[] = ['waitForExpression' => $expression]; - - return $this; - } - - /** - * Forces Chromium to emulate, either "screen" or "print". (default "print"). - * - * @see https://gotenberg.dev/docs/routes#console-exceptions - */ - public function emulatedMediaType(string $mediaType): self - { - $this->multipartFormData[] = ['emulatedMediaType' => $mediaType]; - - return $this; - } - - /** - * Overrides the default "User-Agent" header.(default None). - * - * @see https://gotenberg.dev/docs/routes#custom-http-headers - */ - public function userAgent(string $userAgent): self - { - $this->multipartFormData[] = ['userAgent' => $userAgent]; - - return $this; - } - - /** - * Sets extra HTTP headers that Chromium will send when loading the HTML - * document. (default None). - * - * @see https://gotenberg.dev/docs/routes#custom-http-headers - * - * @param array $headers - */ - public function extraHttpHeaders(array $headers): self - { - if (0 !== \count($headers)) { - $json = json_encode($headers, flags: \JSON_THROW_ON_ERROR); - - if (\is_string($json)) { - $this->multipartFormData[] = ['extraHttpHeaders' => $json]; - } - } - - return $this; - } - - /** - * Forces Gotenberg to return a 409 Conflict response if there are - * exceptions in the Chromium console. (default false). - * - * @see https://gotenberg.dev/docs/routes#console-exceptions - */ - public function failOnConsoleExceptions(): self - { - $this->multipartFormData[] = ['failOnConsoleExceptions' => true]; - - return $this; - } - - /** - * Sets the PDF format of the resulting PDF. (default None). - * - * @See https://gotenberg.dev/docs/routes#pdfa-chromium. - */ - public function pdfFormat(string $format): self - { - $this->multipartFormData[] = ['pdfa' => $format]; - - return $this; - } - - /** - * Enable PDF for Universal Access for optimal accessibility. (default false). - * - * @See https://gotenberg.dev/docs/routes#pdfa-chromium. - */ - public function pdfUniversalAccess(): self - { - $this->multipartFormData[] = ['pdfua' => true]; - - return $this; - } - - private function paperWidth(float $width): void - { - $this->multipartFormData[] = ['paperWidth' => $width]; - } - - private function paperHeight(float $height): void - { - $this->multipartFormData[] = ['paperHeight' => $height]; - } - - private function marginTop(float $top): void - { - $this->multipartFormData[] = ['marginTop' => $top]; - } - - private function marginBottom(float $bottom): void - { - $this->multipartFormData[] = ['marginBottom' => $bottom]; - } - - private function marginLeft(float $left): void - { - $this->multipartFormData[] = ['marginLeft' => $left]; - } - - private function marginRight(float $right): void - { - $this->multipartFormData[] = ['marginRight' => $right]; - } - - /** - * @param array $context - */ - private function addTwigTemplate(string $path, PdfPart $pdfPart, array $context = []): self - { - $stream = $this->twig->render($path, $context); - $dataPart = new DataPart($stream, $pdfPart->value, 'text/html'); - - $this->multipartFormData[] = [ - 'files' => $dataPart, - ]; - - return $this; - } - - private function resolveFilePath(string $filePath): string - { - if (str_starts_with('/', $filePath)) { - return $filePath; - } - - return $this->projectDir.'/'.$filePath; - } - - private function addFile(string $filePath): self - { - $dataPart = new DataPart(new DataPartFile($this->resolveFilePath($filePath))); - - $this->multipartFormData[] = [ - 'files' => $dataPart, - ]; - - return $this; - } - - /** - * @param string|list $acceptExtension - */ - private function fileExtensionChecker(string $filePath, string|array $acceptExtension): void - { - $file = new File($this->resolveFilePath($filePath)); - $extension = $file->getExtension(); - - if (\is_string($acceptExtension)) { - $acceptExtension = [$acceptExtension]; - } - - if (!\in_array($extension, $acceptExtension, true)) { - throw new HttpException(400, "The extension file {$extension} is not available in Gotenberg."); - } - } -} diff --git a/src/Builder/HtmlPdfBuilder.php b/src/Builder/HtmlPdfBuilder.php new file mode 100644 index 00000000..12396e43 --- /dev/null +++ b/src/Builder/HtmlPdfBuilder.php @@ -0,0 +1,44 @@ + $context + * + * @throws PdfPartRenderingException if the template could not be rendered + */ + public function content(string $template, array $context = []): self + { + return $this->withRenderedPart(PdfPart::BodyPart, $template, $context); + } + + /** + * The HTML file to convert into PDF. + */ + public function contentFile(string $path): self + { + return $this->withPdfPartFile(PdfPart::BodyPart, $path); + } + + public function getMultipartFormData(): array + { + if (!\array_key_exists(PdfPart::BodyPart->value, $this->formFields)) { + throw new MissingRequiredFieldException('Content is required'); + } + + return parent::getMultipartFormData(); + } + + protected function getEndpoint(): string + { + return self::ENDPOINT; + } +} diff --git a/src/Builder/LibreOfficePdfBuilder.php b/src/Builder/LibreOfficePdfBuilder.php new file mode 100644 index 00000000..c73b7a42 --- /dev/null +++ b/src/Builder/LibreOfficePdfBuilder.php @@ -0,0 +1,121 @@ +formFields['landscape'] = $bool; + + return $this; + } + + /** + * Page ranges to print, e.g., '1-4' - empty means all pages. + * + * If multiple files are provided, the page ranges will be applied independently to each file. + */ + public function nativePageRanges(string $range): self + { + $this->formFields['nativePageRanges'] = $range; + + return $this; + } + + /** + * Convert the resulting PDF into the given PDF/A format. + */ + public function pdfFormat(string $format): self + { + $this->formFields['pdfa'] = $format; + + return $this; + } + + /** + * Enable PDF for Universal Access for optimal accessibility. + */ + public function pdfUniversalAccess(bool $bool = true): self + { + $this->formFields['pdfua'] = $bool; + + return $this; + } + + /** + * Adds office files to convert (overrides any previous files). + */ + public function files(string ...$paths): self + { + $this->formFields['files'] = []; + + foreach ($paths as $path) { + $this->assertFileExtension($path, self::AVAILABLE_EXTENSIONS); + + $dataPart = new DataPart(new DataPartFile($this->resolveFilePath($path))); + + $this->formFields['files'][$path] = $dataPart; + } + + return $this; + } + + public function getMultipartFormData(): array + { + if ([] === ($this->formFields['files'] ?? [])) { + throw new MissingRequiredFieldException('At least one office file is required'); + } + + $formFields = $this->formFields; + $multipartFormData = []; + + $files = $this->formFields['files'] ?? []; + if ([] !== $files) { + foreach ($files as $dataPart) { + $multipartFormData[] = [ + 'files' => $dataPart, + ]; + } + unset($formFields['files']); + } + + foreach ($formFields as $key => $value) { + if (\is_bool($value)) { + $multipartFormData[] = [ + $key => $value ? 'true' : 'false', + ]; + continue; + } + + $multipartFormData[] = [ + $key => $value, + ]; + } + + return $multipartFormData; + } + + protected function getEndpoint(): string + { + return self::ENDPOINT; + } +} diff --git a/src/Builder/MarkdownPdfBuilder.php b/src/Builder/MarkdownPdfBuilder.php index b163c3cf..810caffc 100644 --- a/src/Builder/MarkdownPdfBuilder.php +++ b/src/Builder/MarkdownPdfBuilder.php @@ -2,57 +2,66 @@ namespace Sensiolabs\GotenbergBundle\Builder; -use Sensiolabs\GotenbergBundle\Client\PdfResponse; use Sensiolabs\GotenbergBundle\Enum\PdfPart; -use Sensiolabs\GotenbergBundle\Pdf\GotenbergInterface; -use Symfony\Component\HttpKernel\Exception\HttpException; +use Sensiolabs\GotenbergBundle\Exception\MissingRequiredFieldException; +use Sensiolabs\GotenbergBundle\Exception\PdfPartRenderingException; use Symfony\Component\Mime\Part\DataPart; -use Twig\Environment; +use Symfony\Component\Mime\Part\File as DataPartFile; -final class MarkdownPdfBuilder implements BuilderInterface +final class MarkdownPdfBuilder extends AbstractChromiumPdfBuilder { - use BuilderTrait; - private const ENDPOINT = '/forms/chromium/convert/markdown'; - public function __construct(private GotenbergInterface $gotenberg, private Environment $twig, private string $projectDir) + /** + * The HTML file that wraps the markdown content, rendered from a Twig template. + * + * @param array $context + * + * @throws PdfPartRenderingException if the template could not be rendered + */ + public function wrapper(string $template, array $context = []): self { + return $this->withRenderedPart(PdfPart::BodyPart, $template, $context); } - public function getEndpoint(): string + /** + * The HTML file that wraps the markdown content. + */ + public function wrapperFile(string $path): self { - return self::ENDPOINT; + return $this->withPdfPartFile(PdfPart::BodyPart, $path); } - /** - * @param array $context - */ - public function content(string $path, array $context = []): self + public function files(string ...$paths): self { - $this->addTwigTemplate($path, PdfPart::BodyPart, $context); + $this->formFields['files'] = []; + + foreach ($paths as $path) { + $this->assertFileExtension($path, ['md']); + + $dataPart = new DataPart(new DataPartFile($this->resolveFilePath($path))); + + $this->formFields['files'][$path] = $dataPart; + } return $this; } - public function markdownFile(string $filePath): self + public function getMultipartFormData(): array { - $this->fileExtensionChecker($filePath, 'md'); + if (!\array_key_exists(PdfPart::BodyPart->value, $this->formFields)) { + throw new MissingRequiredFieldException('HTML template is required'); + } - return $this->addFile($filePath); + if ([] === ($this->formFields['files'] ?? [])) { + throw new MissingRequiredFieldException('At least one markdown file is required'); + } + + return parent::getMultipartFormData(); } - public function generate(): PdfResponse + protected function getEndpoint(): string { - $markdownFile = array_filter($this->multipartFormData, static function ($formData) { - return array_filter($formData, static function ($data) { - return $data instanceof DataPart && $data->getContentType() === 'text/markdown'; - }); - }); - - if (\count($markdownFile) !== 1) { - throw new HttpException(400, 'Invalid request, a Markdown file is required with markdown method'); - } - - return $this->gotenberg->generate($this); + return self::ENDPOINT; } } diff --git a/src/Builder/OfficePdfBuilder.php b/src/Builder/OfficePdfBuilder.php deleted file mode 100644 index 48135164..00000000 --- a/src/Builder/OfficePdfBuilder.php +++ /dev/null @@ -1,117 +0,0 @@ -gotenberg->generate($this); - } - - public function officeFile(string $filePath): self - { - $this->fileExtensionChecker($filePath, self::AVAILABLE_EXTENSIONS); - - return $this->addFile($filePath); - } -} diff --git a/src/Builder/PdfBuilderInterface.php b/src/Builder/PdfBuilderInterface.php new file mode 100644 index 00000000..d39d80b1 --- /dev/null +++ b/src/Builder/PdfBuilderInterface.php @@ -0,0 +1,13 @@ + $context - */ - public function content(string $path, array $context = []): self - { - $this->addTwigTemplate($path, PdfPart::BodyPart, $context); - - return $this; - } - - public function generate(): PdfResponse - { - return $this->gotenberg->generate($this); - } -} diff --git a/src/Builder/UrlPdfBuilder.php b/src/Builder/UrlPdfBuilder.php index 284fc38e..7e493de0 100644 --- a/src/Builder/UrlPdfBuilder.php +++ b/src/Builder/UrlPdfBuilder.php @@ -2,34 +2,33 @@ namespace Sensiolabs\GotenbergBundle\Builder; -use Sensiolabs\GotenbergBundle\Client\PdfResponse; -use Sensiolabs\GotenbergBundle\Pdf\GotenbergInterface; -use Twig\Environment; +use Sensiolabs\GotenbergBundle\Exception\MissingRequiredFieldException; -final class UrlPdfBuilder implements BuilderInterface +final class UrlPdfBuilder extends AbstractChromiumPdfBuilder { - use BuilderTrait; - private const ENDPOINT = '/forms/chromium/convert/url'; - public function __construct(private GotenbergInterface $gotenberg, private Environment $twig, private string $projectDir) + /** + * URL of the page you want to convert into PDF. + */ + public function url(string $url): self { - } + $this->formFields['url'] = $url; - public function getEndpoint(): string - { - return self::ENDPOINT; + return $this; } - public function content(string $url): self + public function getMultipartFormData(): array { - $this->multipartFormData[] = ['url' => $url]; + if (!\array_key_exists('url', $this->formFields)) { + throw new MissingRequiredFieldException('URL is required'); + } - return $this; + return parent::getMultipartFormData(); } - public function generate(): PdfResponse + protected function getEndpoint(): string { - return $this->gotenberg->generate($this); + return self::ENDPOINT; } } diff --git a/src/Client/GotenbergClient.php b/src/Client/GotenbergClient.php index 60ec38f8..9c5f9ef1 100644 --- a/src/Client/GotenbergClient.php +++ b/src/Client/GotenbergClient.php @@ -2,26 +2,24 @@ namespace Sensiolabs\GotenbergBundle\Client; -use Sensiolabs\GotenbergBundle\Builder\BuilderInterface; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\Mime\Part\Multipart\FormDataPart; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; -class GotenbergClient +final readonly class GotenbergClient implements GotenbergClientInterface { - public function __construct(private string $gotenbergUri, private HttpClientInterface $client) + public function __construct(private string $gotenbergBaseUri, private HttpClientInterface $client) { } - public function post(BuilderInterface $builder): ResponseInterface + public function call(string $endpoint, array $multipartFormData): PdfResponse { - $formData = new FormDataPart($builder->getMultipartFormData()); + $formData = new FormDataPart($multipartFormData); $headers = $this->prepareHeaders($formData); $response = $this->client->request( 'POST', - $this->gotenbergUri.$builder->getEndpoint(), + rtrim($this->gotenbergBaseUri, '/').$endpoint, [ 'headers' => $headers, 'body' => $formData->bodyToString(), @@ -32,7 +30,7 @@ public function post(BuilderInterface $builder): ResponseInterface throw new HttpException($response->getStatusCode(), $response->getContent()); } - return $response; + return new PdfResponse($response); } /** diff --git a/src/Client/GotenbergClientInterface.php b/src/Client/GotenbergClientInterface.php new file mode 100644 index 00000000..0032966a --- /dev/null +++ b/src/Client/GotenbergClientInterface.php @@ -0,0 +1,11 @@ +> $multipartFormData + */ + public function call(string $endpoint, array $multipartFormData): PdfResponse; +} diff --git a/src/DependencyInjection/SensiolabsGotenbergExtension.php b/src/DependencyInjection/SensiolabsGotenbergExtension.php index d54fbf15..bdd8edd3 100644 --- a/src/DependencyInjection/SensiolabsGotenbergExtension.php +++ b/src/DependencyInjection/SensiolabsGotenbergExtension.php @@ -2,15 +2,11 @@ namespace Sensiolabs\GotenbergBundle\DependencyInjection; -use Sensiolabs\GotenbergBundle\Builder\BuilderTrait; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\Extension; -/** - * @phpstan-import-type ConfigOptions from BuilderTrait - */ class SensiolabsGotenbergExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void @@ -20,14 +16,14 @@ public function load(array $configs, ContainerBuilder $container): void $configuration = new Configuration(); - /** @var array{base_uri: string, default_options: ConfigOptions} $config */ + /** @var array{base_uri: string, default_options: array} $config */ $config = $this->processConfiguration($configuration, $configs); $definition = $container->getDefinition('sensiolabs_gotenberg.client'); $definition->replaceArgument(0, $config['base_uri']); $definition = $container->getDefinition('sensiolabs_gotenberg'); - $definition->replaceArgument(2, $this->cleanDefaultOptions($config['default_options'])); + $definition->replaceArgument(1, $this->cleanDefaultOptions($config['default_options'])); } /** diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php new file mode 100644 index 00000000..11e6c94c --- /dev/null +++ b/src/Exception/ExceptionInterface.php @@ -0,0 +1,7 @@ + $userConfigurations */ - public function __construct(private GotenbergClient $gotenbergClient, private Environment $twig, private array $userConfigurations, private string $projectDir) - { + public function __construct( + private GotenbergClientInterface $gotenbergClient, + private array $userConfigurations, + private string $projectDir, + private ?Environment $twig = null, + ) { } - public function generate(BuilderInterface $builder): PdfResponse + public function html(): HtmlPdfBuilder { - $response = $this->sendRequest($builder); - - if (200 !== $response->getStatusCode()) { - throw new HttpException($response->getStatusCode(), $response->getContent()); - } - - return new PdfResponse($response); - } - - public function twig(): TwigPdfBuilder - { - return (new TwigPdfBuilder($this, $this->twig, $this->projectDir)) + return (new HtmlPdfBuilder($this->gotenbergClient, $this->projectDir, $this->twig)) ->setConfigurations($this->userConfigurations) ; } public function url(): UrlPdfBuilder { - return (new UrlPdfBuilder($this, $this->twig, $this->projectDir)) + return (new UrlPdfBuilder($this->gotenbergClient, $this->projectDir, $this->twig)) ->setConfigurations($this->userConfigurations) ; } public function markdown(): MarkdownPdfBuilder { - return (new MarkdownPdfBuilder($this, $this->twig, $this->projectDir)) + return (new MarkdownPdfBuilder($this->gotenbergClient, $this->projectDir, $this->twig)) ->setConfigurations($this->userConfigurations) ; } - public function office(): OfficePdfBuilder + public function office(): LibreOfficePdfBuilder { - return (new OfficePdfBuilder($this, $this->twig, $this->projectDir)) + return (new LibreOfficePdfBuilder($this->gotenbergClient, $this->projectDir)) ->setConfigurations($this->userConfigurations) ; } - - private function sendRequest(BuilderInterface $builder): ResponseInterface - { - return $this->gotenbergClient->post($builder); - } } diff --git a/src/Pdf/GotenbergInterface.php b/src/Pdf/GotenbergInterface.php index 872310e5..8e02c66e 100644 --- a/src/Pdf/GotenbergInterface.php +++ b/src/Pdf/GotenbergInterface.php @@ -2,19 +2,15 @@ namespace Sensiolabs\GotenbergBundle\Pdf; -use Sensiolabs\GotenbergBundle\Builder\BuilderInterface; -use Sensiolabs\GotenbergBundle\Builder\MarkdownPdfBuilder; -use Sensiolabs\GotenbergBundle\Builder\TwigPdfBuilder; -use Sensiolabs\GotenbergBundle\Builder\UrlPdfBuilder; -use Sensiolabs\GotenbergBundle\Client\PdfResponse; +use Sensiolabs\GotenbergBundle\Builder\PdfBuilderInterface; interface GotenbergInterface { - public function generate(BuilderInterface $builder): PdfResponse; + public function html(): PdfBuilderInterface; - public function twig(): TwigPdfBuilder; + public function url(): PdfBuilderInterface; - public function url(): UrlPdfBuilder; + public function markdown(): PdfBuilderInterface; - public function markdown(): MarkdownPdfBuilder; + public function office(): PdfBuilderInterface; } diff --git a/tests/Builder/AbstractBuilderTestCase.php b/tests/Builder/AbstractBuilderTestCase.php new file mode 100644 index 00000000..5d68f976 --- /dev/null +++ b/tests/Builder/AbstractBuilderTestCase.php @@ -0,0 +1,19 @@ + 'https://gotenberg.dev/docs/routes'], - ]; - } - }; - } -} diff --git a/tests/Builder/BuilderTestTrait.php b/tests/Builder/BuilderTestTrait.php deleted file mode 100644 index e5b8f669..00000000 --- a/tests/Builder/BuilderTestTrait.php +++ /dev/null @@ -1,22 +0,0 @@ -createMock(GotenbergInterface::class); - } - - private function getTwig(): Environment - { - return new Environment(new FilesystemLoader(self::FIXTURE_DIR.'/templates')); - } -} diff --git a/tests/Builder/HtmlPdfBuilderTest.php b/tests/Builder/HtmlPdfBuilderTest.php new file mode 100644 index 00000000..c52aeaec --- /dev/null +++ b/tests/Builder/HtmlPdfBuilderTest.php @@ -0,0 +1,164 @@ +createMock(GotenbergClientInterface::class); + $builder = new HtmlPdfBuilder($client, self::FIXTURE_DIR); + $builder->contentFile('content.html'); + $builder->setConfigurations(self::getUserConfig()); + + $multipartFormData = $builder->getMultipartFormData(); + + self::assertCount(21, $multipartFormData); + + self::assertSame(['extraHttpHeaders' => '{"MyHeader":"Value","User-Agent":"MyValue"}'], $multipartFormData[0]); + self::assertSame(['paperWidth' => 33.1], $multipartFormData[2]); + self::assertSame(['paperHeight' => 46.8], $multipartFormData[3]); + self::assertSame(['marginTop' => 1.0], $multipartFormData[4]); + self::assertSame(['marginBottom' => 1.0], $multipartFormData[5]); + self::assertSame(['marginLeft' => 1.0], $multipartFormData[6]); + self::assertSame(['marginRight' => 1.0], $multipartFormData[7]); + self::assertSame(['preferCssPageSize' => 'true'], $multipartFormData[8]); + self::assertSame(['printBackground' => 'true'], $multipartFormData[9]); + self::assertSame(['omitBackground' => 'true'], $multipartFormData[10]); + self::assertSame(['landscape' => 'true'], $multipartFormData[11]); + self::assertSame(['scale' => 1.5], $multipartFormData[12]); + self::assertSame(['nativePageRanges' => '1-5'], $multipartFormData[13]); + self::assertSame(['waitDelay' => '10s'], $multipartFormData[14]); + self::assertSame(['waitForExpression' => 'window.globalVar === "ready"'], $multipartFormData[15]); + self::assertSame(['emulatedMediaType' => 'screen'], $multipartFormData[16]); + self::assertSame(['userAgent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML => like Gecko) Version/11.0 Mobile/15A372 Safari/604.1'], $multipartFormData[17]); + self::assertSame(['failOnConsoleExceptions' => 'true'], $multipartFormData[18]); + self::assertSame(['pdfa' => 'PDF/A-1a'], $multipartFormData[19]); + self::assertSame(['pdfua' => 'true'], $multipartFormData[20]); + + self::assertIsArray($multipartFormData[1]); + self::assertCount(1, $multipartFormData[1]); + self::assertArrayHasKey('files', $multipartFormData[1]); + self::assertInstanceOf(DataPart::class, $multipartFormData[1]['files']); + self::assertSame('index.html', $multipartFormData[1]['files']->getFilename()); + } + + public function testWithTemplate(): void + { + $client = $this->createMock(GotenbergClientInterface::class); + $builder = new HtmlPdfBuilder($client, self::FIXTURE_DIR, self::$twig); + $builder->content('content.html.twig'); + + $multipartFormData = $builder->getMultipartFormData(); + + self::assertCount(1, $multipartFormData); + self::assertArrayHasKey(0, $multipartFormData); + self::assertIsArray($multipartFormData[0]); + self::assertArrayHasKey('files', $multipartFormData[0]); + self::assertInstanceOf(DataPart::class, $multipartFormData[0]['files']); + self::assertSame('text/html', $multipartFormData[0]['files']->getContentType()); + } + + public function testWithAssets(): void + { + $client = $this->createMock(GotenbergClientInterface::class); + $builder = new HtmlPdfBuilder($client, self::FIXTURE_DIR); + $builder->contentFile('content.html'); + $builder->assets('assets/logo.png'); + + $multipartFormData = $builder->getMultipartFormData(); + + self::assertCount(2, $multipartFormData); + + self::assertArrayHasKey(1, $multipartFormData); + self::assertIsArray($multipartFormData[1]); + self::assertArrayHasKey('files', $multipartFormData[1]); + self::assertInstanceOf(DataPart::class, $multipartFormData[1]['files']); + self::assertSame('image/png', $multipartFormData[1]['files']->getContentType()); + } + + public function testWithHeader(): void + { + $client = $this->createMock(GotenbergClientInterface::class); + $builder = new HtmlPdfBuilder($client, self::FIXTURE_DIR); + $builder->headerFile('header.html'); + $builder->contentFile('content.html'); + + $multipartFormData = $builder->getMultipartFormData(); + + self::assertCount(2, $multipartFormData); + + self::assertArrayHasKey(1, $multipartFormData); + self::assertIsArray($multipartFormData[1]); + self::assertArrayHasKey('files', $multipartFormData[1]); + self::assertInstanceOf(DataPart::class, $multipartFormData[1]['files']); + self::assertSame('text/html', $multipartFormData[1]['files']->getContentType()); + } + + public function testInvalidTwigTemplate(): void + { + $this->expectException(PdfPartRenderingException::class); + $this->expectExceptionMessage('Could not render template "invalid.html.twig" into PDF part "index.html".'); + + $client = $this->createMock(GotenbergClientInterface::class); + $builder = new HtmlPdfBuilder($client, self::FIXTURE_DIR, self::$twig); + + $builder->content('invalid.html.twig'); + } + + public function testInvalidExtraHttpHeaders(): void + { + $this->expectException(ExtraHttpHeadersJsonEncodingException::class); + $this->expectExceptionMessage('Could not encode extra HTTP headers into JSON'); + + $client = $this->createMock(GotenbergClientInterface::class); + $builder = new HtmlPdfBuilder($client, self::FIXTURE_DIR); + $builder->contentFile('content.html'); + // @phpstan-ignore-next-line + $builder->extraHttpHeaders([ + 'invalid' => tmpfile(), + ]); + + $builder->getMultipartFormData(); + } + + /** + * @return array + */ + private static function getUserConfig(): array + { + return [ + 'paper_width' => 33.1, + 'paper_height' => 46.8, + 'margin_top' => 1, + 'margin_bottom' => 1, + 'margin_left' => 1, + 'margin_right' => 1, + 'prefer_css_page_size' => true, + 'print_background' => true, + 'omit_background' => true, + 'landscape' => true, + 'scale' => 1.5, + 'native_page_ranges' => '1-5', + 'wait_delay' => '10s', + 'wait_for_expression' => 'window.globalVar === "ready"', + 'emulated_media_type' => 'screen', + 'user_agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML => like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + 'extra_http_headers' => [ + 'MyHeader' => 'Value', + 'User-Agent' => 'MyValue', + ], + 'fail_on_console_exceptions' => true, + 'pdf_format' => 'PDF/A-1a', + 'pdf_universal_access' => true, + ]; + } +} diff --git a/tests/Builder/OfficePdfBuilderTest.php b/tests/Builder/LibreOfficePdfBuilderTest.php similarity index 57% rename from tests/Builder/OfficePdfBuilderTest.php rename to tests/Builder/LibreOfficePdfBuilderTest.php index eeadf860..6d7377d1 100644 --- a/tests/Builder/OfficePdfBuilderTest.php +++ b/tests/Builder/LibreOfficePdfBuilderTest.php @@ -4,15 +4,13 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; -use Sensiolabs\GotenbergBundle\Builder\OfficePdfBuilder; +use Sensiolabs\GotenbergBundle\Builder\LibreOfficePdfBuilder; +use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; use Symfony\Component\Mime\Part\DataPart; -#[CoversClass(OfficePdfBuilder::class)] -final class OfficePdfBuilderTest extends TestCase +#[CoversClass(LibreOfficePdfBuilder::class)] +final class LibreOfficePdfBuilderTest extends AbstractBuilderTestCase { - use BuilderTestTrait; - private const OFFICE_DOCUMENTS_DIR = 'assets/office'; /** @@ -30,16 +28,18 @@ public static function provideValidOfficeFiles(): iterable #[DataProvider('provideValidOfficeFiles')] public function testOfficeFiles(string $filePath, string $contentType): void { - $builder = new OfficePdfBuilder($this->getGotenbergMock(), $this->getTwig(), self::FIXTURE_DIR); - $builder->officeFile($filePath); + $client = $this->createMock(GotenbergClientInterface::class); + $builder = new LibreOfficePdfBuilder($client, self::FIXTURE_DIR); + $builder->files($filePath); - $multipart = $builder->getMultipartFormData(); - $itemOffice = $multipart[array_key_first($multipart)]; + $multipartFormData = $builder->getMultipartFormData(); - self::assertArrayHasKey('files', $itemOffice); - self::assertInstanceOf(DataPart::class, $itemOffice['files']); + self::assertCount(1, $multipartFormData); - $dataPart = $itemOffice['files']; - self::assertEquals($contentType, $dataPart->getContentType()); + self::assertArrayHasKey(0, $multipartFormData); + self::assertIsArray($multipartFormData[0]); + self::assertArrayHasKey('files', $multipartFormData[0]); + self::assertInstanceOf(DataPart::class, $multipartFormData[0]['files']); + self::assertSame($contentType, $multipartFormData[0]['files']->getContentType()); } } diff --git a/tests/Builder/MarkdownPdfBuilderTest.php b/tests/Builder/MarkdownPdfBuilderTest.php index 31d35253..32f52d19 100644 --- a/tests/Builder/MarkdownPdfBuilderTest.php +++ b/tests/Builder/MarkdownPdfBuilderTest.php @@ -3,27 +3,37 @@ namespace Sensiolabs\GotenbergBundle\Tests\Builder; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\TestCase; use Sensiolabs\GotenbergBundle\Builder\MarkdownPdfBuilder; +use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; use Symfony\Component\Mime\Part\DataPart; #[CoversClass(MarkdownPdfBuilder::class)] -final class MarkdownPdfBuilderTest extends TestCase +final class MarkdownPdfBuilderTest extends AbstractBuilderTestCase { - use BuilderTestTrait; - public function testMarkdownFile(): void { - $builder = new MarkdownPdfBuilder($this->getGotenbergMock(), $this->getTwig(), self::FIXTURE_DIR); - $builder->markdownFile('assets/file.md'); + $client = $this->createMock(GotenbergClientInterface::class); + $builder = new MarkdownPdfBuilder($client, self::FIXTURE_DIR); + $builder + ->wrapperFile('template.html') + ->files('assets/file.md') + ; + + $multipartFormData = $builder->getMultipartFormData(); - $multipart = $builder->getMultipartFormData(); - $itemMarkdown = $multipart[array_key_first($multipart)]; + self::assertCount(2, $multipartFormData); - self::assertArrayHasKey('files', $itemMarkdown); - self::assertInstanceOf(DataPart::class, $itemMarkdown['files']); + self::assertArrayHasKey(0, $multipartFormData); + self::assertIsArray($multipartFormData[0]); + self::assertArrayHasKey('files', $multipartFormData[0]); + self::assertInstanceOf(DataPart::class, $multipartFormData[0]['files']); + self::assertSame('index.html', $multipartFormData[0]['files']->getFilename()); - $dataPart = $itemMarkdown['files']; - self::assertEquals('text/markdown', $dataPart->getContentType()); + self::assertArrayHasKey(1, $multipartFormData); + self::assertIsArray($multipartFormData[1]); + self::assertArrayHasKey('files', $multipartFormData[1]); + self::assertInstanceOf(DataPart::class, $multipartFormData[1]['files']); + self::assertSame('file.md', $multipartFormData[1]['files']->getFilename()); + self::assertSame('text/markdown', $multipartFormData[1]['files']->getContentType()); } } diff --git a/tests/Builder/TwigPdfBuilderTest.php b/tests/Builder/TwigPdfBuilderTest.php deleted file mode 100644 index 1f7bdcdf..00000000 --- a/tests/Builder/TwigPdfBuilderTest.php +++ /dev/null @@ -1,120 +0,0 @@ - - */ - private static function getUserConfig(): array - { - return [ - 'paper_width' => 33.1, - 'paper_height' => 46.8, - 'margin_top' => 1, - 'margin_bottom' => 1, - 'margin_left' => 1, - 'margin_right' => 1, - 'prefer_css_page_size' => true, - 'print_background' => true, - 'omit_background' => true, - 'landscape' => true, - 'scale' => 1.5, - 'native_page_ranges' => '1-5', - 'wait_delay' => '10s', - 'wait_for_expression' => 'window.globalVar === "ready"', - 'emulated_media_type' => 'screen', - 'user_agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML => like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', - 'extra_http_headers' => [ - 'MyHeader' => 'Value', - 'User-Agent' => 'MyValue', - ], - 'fail_on_console_exceptions' => true, - 'pdf_format' => 'PDF/A-1a', - 'pdf_universal_access' => true, - ]; - } - - public function testWithConfigurations(): void - { - $builder = new TwigPdfBuilder($this->getGotenbergMock(), $this->getTwig(), self::FIXTURE_DIR); - $builder->setConfigurations(self::getUserConfig()); - - self::assertEquals([ - ['paperWidth' => 33.1], - ['paperHeight' => 46.8], - ['marginTop' => 1.0], - ['marginBottom' => 1.0], - ['marginLeft' => 1.0], - ['marginRight' => 1.0], - ['preferCssPageSize' => true], - ['printBackground' => true], - ['omitBackground' => true], - ['landscape' => true], - ['scale' => 1.5], - ['nativePageRanges' => '1-5'], - ['waitDelay' => '10s'], - ['waitForExpression' => 'window.globalVar === "ready"'], - ['emulatedMediaType' => 'screen'], - ['userAgent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML => like Gecko) Version/11.0 Mobile/15A372 Safari/604.1'], - ['extraHttpHeaders' => '{"MyHeader":"Value","User-Agent":"MyValue"}'], - ['failOnConsoleExceptions' => true], - ['pdfa' => 'PDF/A-1a'], - ['pdfua' => true], - ], $builder->getMultipartFormData()); - } - - public function testWithTemplate(): void - { - $builder = new TwigPdfBuilder($this->getGotenbergMock(), $this->getTwig(), self::FIXTURE_DIR); - $builder->content('content.html.twig'); - - $multipart = $builder->getMultipartFormData(); - $itemTemplate = $multipart[array_key_first($multipart)]; - - self::assertArrayHasKey('files', $itemTemplate); - self::assertInstanceOf(DataPart::class, $itemTemplate['files']); - - $dataPart = $itemTemplate['files']; - self::assertEquals('text/html', $dataPart->getContentType()); - } - - public function testWithAssets(): void - { - $builder = new TwigPdfBuilder($this->getGotenbergMock(), $this->getTwig(), self::FIXTURE_DIR); - $builder->assets('assets/logo.png'); - - $multipart = $builder->getMultipartFormData(); - $itemImage = $multipart[array_key_last($multipart)]; - - self::assertArrayHasKey('files', $itemImage); - self::assertInstanceOf(DataPart::class, $itemImage['files']); - - $dataPart = $itemImage['files']; - self::assertEquals('image/png', $dataPart->getContentType()); - } - - public function testWithHeader(): void - { - $builder = new TwigPdfBuilder($this->getGotenbergMock(), $this->getTwig(), self::FIXTURE_DIR); - $builder->header('header.html.twig'); - - $multipart = $builder->getMultipartFormData(); - $itemTemplate = $multipart[array_key_last($multipart)]; - - self::assertArrayHasKey('files', $itemTemplate); - self::assertInstanceOf(DataPart::class, $itemTemplate['files']); - - $dataPart = $itemTemplate['files']; - self::assertEquals('text/html', $dataPart->getContentType()); - } -} diff --git a/tests/Builder/UrlPdfBuilderTest.php b/tests/Builder/UrlPdfBuilderTest.php new file mode 100644 index 00000000..c3759f8e --- /dev/null +++ b/tests/Builder/UrlPdfBuilderTest.php @@ -0,0 +1,25 @@ +createMock(GotenbergClientInterface::class); + $builder = new UrlPdfBuilder($client, self::FIXTURE_DIR); + $builder->url('https://google.com'); + + $multipartFormData = $builder->getMultipartFormData(); + + self::assertCount(1, $multipartFormData); + self::assertArrayHasKey(0, $multipartFormData); + self::assertSame(['url' => 'https://google.com'], $multipartFormData[0]); + } +} diff --git a/tests/Client/GotenbergClientMock.php b/tests/Client/GotenbergClientMock.php index bea6de1c..7c67f1c8 100644 --- a/tests/Client/GotenbergClientMock.php +++ b/tests/Client/GotenbergClientMock.php @@ -3,15 +3,14 @@ namespace Sensiolabs\GotenbergBundle\Tests\Client; use Sensiolabs\GotenbergBundle\Client\GotenbergClient; -use Sensiolabs\GotenbergBundle\Tests\Builder\BuilderInterfaceMock; +use Sensiolabs\GotenbergBundle\Client\PdfResponse; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\HttpFoundation\Response; -use Symfony\Contracts\HttpClient\ResponseInterface; final class GotenbergClientMock { - public static function defaultResponse(): ResponseInterface + public static function defaultResponse(): PdfResponse { /** @var string $stream */ $stream = file_get_contents(__DIR__.'/../Fixtures/pdf/simple_pdf.pdf'); @@ -27,6 +26,6 @@ public static function defaultResponse(): ResponseInterface $mockClient = new MockHttpClient([$mockResponse]); $gotenbergClient = new GotenbergClient('http://localhost:3000', $mockClient); - return $gotenbergClient->post(BuilderInterfaceMock::getDefault()); + return $gotenbergClient->call('/forms/chromium/convert/url', []); } } diff --git a/tests/Client/GotenbergClientTest.php b/tests/Client/GotenbergClientTest.php index e178ab26..03e757a5 100644 --- a/tests/Client/GotenbergClientTest.php +++ b/tests/Client/GotenbergClientTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Sensiolabs\GotenbergBundle\Client\GotenbergClient; -use Sensiolabs\GotenbergBundle\Tests\Builder\BuilderInterfaceMock; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\HttpFoundation\HeaderBag; @@ -18,7 +17,7 @@ #[UsesClass(HeaderBag::class)] final class GotenbergClientTest extends TestCase { - public function testPostRequest(): void + public function testCall(): void { /** @var string $stream */ $stream = file_get_contents(__DIR__.'/../Fixtures/pdf/simple_pdf.pdf'); @@ -34,11 +33,9 @@ public function testPostRequest(): void $mockClient = new MockHttpClient([$mockResponse]); $gotenbergClient = new GotenbergClient('http://localhost:3000', $mockClient); - $response = $gotenbergClient->post(BuilderInterfaceMock::getDefault()); + $response = $gotenbergClient->call('/forms/chromium/convert/url', []); - $header = new HeaderBag($response->getHeaders()); - - self::assertEquals(Response::HTTP_OK, $response->getStatusCode()); - self::assertEquals('application/pdf', $header->get('content-type')); + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('application/pdf', $response->headers->get('content-type')); } } diff --git a/tests/Client/PdfResponseTest.php b/tests/Client/PdfResponseTest.php index 43e6c29a..af32ef7c 100644 --- a/tests/Client/PdfResponseTest.php +++ b/tests/Client/PdfResponseTest.php @@ -22,7 +22,7 @@ protected function tearDown(): void public function testSaveToMethod(): void { - $pdfResponse = new PdfResponse(GotenbergClientMock::defaultResponse()); + $pdfResponse = GotenbergClientMock::defaultResponse(); $location = $pdfResponse->saveTo(__DIR__.'/../Fixtures/pdf/generated.pdf'); self::assertFileEquals(__DIR__.'/../Fixtures/pdf/simple_pdf.pdf', $location); diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index a181b1a8..60516245 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -5,14 +5,10 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Sensiolabs\GotenbergBundle\Builder\BuilderTrait; use Sensiolabs\GotenbergBundle\DependencyInjection\Configuration; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; -/** - * @phpstan-import-type ConfigOptions from BuilderTrait - */ #[CoversClass(Configuration::class)] final class ConfigurationTest extends TestCase { diff --git a/tests/DependencyInjection/SensiolabsGotenbergExtensionTest.php b/tests/DependencyInjection/SensiolabsGotenbergExtensionTest.php index 7f52c0fd..6714483f 100644 --- a/tests/DependencyInjection/SensiolabsGotenbergExtensionTest.php +++ b/tests/DependencyInjection/SensiolabsGotenbergExtensionTest.php @@ -22,7 +22,7 @@ public function testGotenbergConfiguredWithValidConfig(): void $gotenbergDefinition = $containerBuilder->getDefinition('sensiolabs_gotenberg'); $arguments = $gotenbergDefinition->getArguments(); - self::assertEquals( + self::assertSame( [ 'paper_width' => 33.1, 'paper_height' => 46.8, @@ -45,7 +45,7 @@ public function testGotenbergConfiguredWithValidConfig(): void 'pdf_format' => 'PDF/A-1a', 'pdf_universal_access' => true, ], - $arguments[2], + $arguments[1], ); } @@ -59,7 +59,7 @@ public function testGotenbergConfiguredWithNoConfig(): void $gotenbergDefinition = $containerBuilder->getDefinition('sensiolabs_gotenberg'); $arguments = $gotenbergDefinition->getArguments(); - self::assertEquals([], $arguments[2]); + self::assertSame([], $arguments[1]); } public function testGotenbergClientConfiguredWithDefaultConfig(): void @@ -72,7 +72,7 @@ public function testGotenbergClientConfiguredWithDefaultConfig(): void $gotenbergDefinition = $containerBuilder->getDefinition('sensiolabs_gotenberg.client'); $arguments = $gotenbergDefinition->getArguments(); - self::assertEquals('http://localhost:3000', $arguments[0]); + self::assertSame('http://localhost:3000', $arguments[0]); } public function testGotenbergClientConfiguredWithValidConfig(): void @@ -87,7 +87,7 @@ public function testGotenbergClientConfiguredWithValidConfig(): void $gotenbergDefinition = $containerBuilder->getDefinition('sensiolabs_gotenberg.client'); $arguments = $gotenbergDefinition->getArguments(); - self::assertEquals('https://sensiolabs.com', $arguments[0]); + self::assertSame('https://sensiolabs.com', $arguments[0]); } /** diff --git a/tests/Fixtures/templates/invalid.html.twig b/tests/Fixtures/templates/invalid.html.twig new file mode 100644 index 00000000..91f558fb --- /dev/null +++ b/tests/Fixtures/templates/invalid.html.twig @@ -0,0 +1,11 @@ + + + + + My PDF + + +

{{ Hello world! }}

+ + + diff --git a/tests/Pdf/GotenbergTest.php b/tests/Pdf/GotenbergTest.php index 6da17c45..d7e247e2 100644 --- a/tests/Pdf/GotenbergTest.php +++ b/tests/Pdf/GotenbergTest.php @@ -4,8 +4,9 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Sensiolabs\GotenbergBundle\Client\GotenbergClient; +use Sensiolabs\GotenbergBundle\Client\GotenbergClientInterface; use Sensiolabs\GotenbergBundle\Pdf\Gotenberg; +use Symfony\Component\Mime\Part\DataPart; use Twig\Environment; #[CoversClass(Gotenberg::class)] @@ -13,47 +14,105 @@ final class GotenbergTest extends TestCase { public function testUrlBuilderFactory(): void { - $gotenbergClient = $this->createMock(GotenbergClient::class); - $twig = $this->createMock(Environment::class); + $gotenbergClient = $this->createMock(GotenbergClientInterface::class); - $gotenberg = new Gotenberg($gotenbergClient, $twig, ['native_page_ranges' => '1-5'], __DIR__.'/../Fixtures'); - $urlBuilder = $gotenberg->url(); + $gotenberg = new Gotenberg( + $gotenbergClient, + ['native_page_ranges' => '1-5'], + __DIR__.'/../Fixtures', + ); + $builder = $gotenberg->url(); + $builder->url('https://google.com'); - self::assertEquals([['nativePageRanges' => '1-5']], $urlBuilder->getMultipartFormData()); + self::assertSame([['nativePageRanges' => '1-5'], ['url' => 'https://google.com']], $builder->getMultipartFormData()); } - public function testTwigBuilderFactory(): void + public function testHtmlBuilderFactory(): void { - $gotenbergClient = $this->createMock(GotenbergClient::class); + $gotenbergClient = $this->createMock(GotenbergClientInterface::class); $twig = $this->createMock(Environment::class); - $gotenberg = new Gotenberg($gotenbergClient, $twig, ['margin_top' => 3, 'margin_bottom' => 1], __DIR__.'/../Fixtures'); - $twigBuilder = $gotenberg->twig(); + $gotenberg = new Gotenberg( + $gotenbergClient, + ['margin_top' => 3, 'margin_bottom' => 1], + __DIR__.'/../Fixtures', + $twig, + ); + $builder = $gotenberg->html(); + $builder->contentFile('content.html'); + $multipartFormData = $builder->getMultipartFormData(); + + self::assertCount(3, $multipartFormData); + + self::assertArrayHasKey(0, $multipartFormData); + self::assertSame(['marginTop' => 3.0], $multipartFormData[0]); + + self::assertArrayHasKey(1, $multipartFormData); + self::assertSame(['marginBottom' => 1.0], $multipartFormData[1]); - self::assertEquals([['marginTop' => 3], ['marginBottom' => 1]], $twigBuilder->getMultipartFormData()); + self::assertArrayHasKey(2, $multipartFormData); + self::assertIsArray($multipartFormData[2]); + self::assertCount(1, $multipartFormData[2]); + self::assertArrayHasKey('files', $multipartFormData[2]); + self::assertInstanceOf(DataPart::class, $multipartFormData[2]['files']); + self::assertSame('index.html', $multipartFormData[2]['files']->getFilename()); } public function testMarkdownBuilderFactory(): void { - $gotenbergClient = $this->createMock(GotenbergClient::class); + $gotenbergClient = $this->createMock(GotenbergClientInterface::class); $twig = $this->createMock(Environment::class); - $gotenberg = new Gotenberg($gotenbergClient, $twig, [], __DIR__.'/../Fixtures'); - $markdownBuilder = $gotenberg->markdown(); + $gotenberg = new Gotenberg( + $gotenbergClient, + [], + __DIR__.'/../Fixtures', + $twig, + ); + $builder = $gotenberg->markdown(); + $builder->files('assets/file.md'); + $builder->wrapperFile('wrapper.html'); + $multipartFormData = $builder->getMultipartFormData(); - self::assertTrue(method_exists($markdownBuilder, 'markdownFile')); - self::assertEquals([], $markdownBuilder->getMultipartFormData()); + self::assertCount(2, $multipartFormData); + + self::assertArrayHasKey(0, $multipartFormData); + self::assertIsArray($multipartFormData[0]); + self::assertArrayHasKey('files', $multipartFormData[0]); + self::assertInstanceOf(DataPart::class, $multipartFormData[0]['files']); + self::assertSame('file.md', $multipartFormData[0]['files']->getFilename()); + + self::assertArrayHasKey(1, $multipartFormData); + self::assertIsArray($multipartFormData[1]); + self::assertArrayHasKey('files', $multipartFormData[1]); + self::assertInstanceOf(DataPart::class, $multipartFormData[1]['files']); + self::assertSame('index.html', $multipartFormData[1]['files']->getFilename()); } public function testOfficeBuilderFactory(): void { - $gotenbergClient = $this->createMock(GotenbergClient::class); + $gotenbergClient = $this->createMock(GotenbergClientInterface::class); $twig = $this->createMock(Environment::class); - $gotenberg = new Gotenberg($gotenbergClient, $twig, ['paper_width' => 11.7, 'paper_height' => 16.54], __DIR__.'/../Fixtures'); - $officeBuilder = $gotenberg->office(); + $gotenberg = new Gotenberg( + $gotenbergClient, + ['native_page_ranges' => '1-5'], + __DIR__.'/../Fixtures', + $twig, + ); + $builder = $gotenberg->office(); + $builder->files('assets/office/document.odt'); + $multipartFormData = $builder->getMultipartFormData(); + + self::assertCount(2, $multipartFormData); + + self::assertArrayHasKey(0, $multipartFormData); + self::assertIsArray($multipartFormData[0]); + self::assertArrayHasKey('files', $multipartFormData[0]); + self::assertInstanceOf(DataPart::class, $multipartFormData[0]['files']); + self::assertSame('document.odt', $multipartFormData[0]['files']->getFilename()); - self::assertTrue(method_exists($officeBuilder, 'officeFile')); - self::assertEquals([['paperWidth' => 11.7], ['paperHeight' => 16.54]], $officeBuilder->getMultipartFormData()); + self::assertArrayHasKey(1, $multipartFormData); + self::assertSame(['nativePageRanges' => '1-5'], $multipartFormData[1]); } }