diff --git a/README.md b/README.md index cd9cafd..bd3ae25 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Use the compiler to generate a reusable XObject result. Consumers that prefer ar ```php use LibreSign\XObjectTemplate\Dto\CompileRequest; use LibreSign\XObjectTemplate\Integration\XObjectPayloadAdapter; +use LibreSign\XObjectTemplate\Pdf\SinglePagePdfExporter; use LibreSign\XObjectTemplate\XObjectTemplateCompiler; $compiler = new XObjectTemplateCompiler(); @@ -32,8 +33,19 @@ $result = $compiler->compile(new CompileRequest( )); $payload = (new XObjectPayloadAdapter())->toXObjectPayload($result); +$pdf = (new SinglePagePdfExporter())->export($result); + +file_put_contents(__DIR__ . '/build/preview.pdf', $pdf); ``` +### Standalone PDF export + +`SinglePagePdfExporter` wraps a compiled XObject result into a one-page PDF whose `MediaBox` matches the compiled `bbox` size exactly. + +- The page size is derived from `$result->bbox` +- Non-zero bounding boxes are translated back to the page origin automatically +- Local PNG and JPEG image sources are embedded into the standalone PDF during export + ### Output contract - `$result->contentStream`: PDF operators ready for a Form XObject stream @@ -41,6 +53,7 @@ $payload = (new XObjectPayloadAdapter())->toXObjectPayload($result); - `$result->bbox`: bounding box as `[x1, y1, x2, y2]` - `$result->metadata`: render diagnostics such as `line_count`, `image_count`, `node_count`, and `render_ms` - `$payload`: transport-agnostic array with `stream`, `resources`, and `bbox` +- `$pdf`: standalone PDF bytes ready to save, stream, or attach to preview workflows ## Supported HTML/CSS subset diff --git a/src/Pdf/EmbeddedPdfImage.php b/src/Pdf/EmbeddedPdfImage.php new file mode 100644 index 0000000..0726e96 --- /dev/null +++ b/src/Pdf/EmbeddedPdfImage.php @@ -0,0 +1,21 @@ + $dictionary + */ + public function __construct( + public array $dictionary, + public string $stream, + public ?self $softMask = null, + ) { + } +} diff --git a/src/Pdf/FilesystemPdfImageEmbedder.php b/src/Pdf/FilesystemPdfImageEmbedder.php new file mode 100644 index 0000000..c59a7e2 --- /dev/null +++ b/src/Pdf/FilesystemPdfImageEmbedder.php @@ -0,0 +1,367 @@ + $this->embedJpeg($contents, $imageInfo), + 'image/png' => $this->embedPng($contents), + default => throw new InvalidArgumentException( + sprintf('Unsupported image format "%s".', $mime), + ), + }; + } + + /** + * @param array $imageInfo + */ + private function embedJpeg(string $contents, array $imageInfo): EmbeddedPdfImage + { + $width = $imageInfo[0] ?? null; + $height = $imageInfo[1] ?? null; + if (!is_int($width) || !is_int($height)) { + throw new InvalidArgumentException('JPEG metadata must expose width and height.'); + } + + $channels = $imageInfo['channels'] ?? 3; + if (!is_int($channels)) { + $channels = 3; + } + + $colorSpace = match ($channels) { + 1 => '/DeviceGray', + 4 => '/DeviceCMYK', + default => '/DeviceRGB', + }; + + return new EmbeddedPdfImage( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => $width, + 'Height' => $height, + 'ColorSpace' => $colorSpace, + 'BitsPerComponent' => 8, + 'Filter' => '/DCTDecode', + ], + stream: $contents, + ); + } + + private function embedPng(string $contents): EmbeddedPdfImage + { + $png = $this->parsePng($contents); + [$colorSpace, $colorCount, $hasAlpha] = $this->describePngColorType($png['colorType']); + + if ($hasAlpha === false) { + return new EmbeddedPdfImage( + dictionary: $this->createImageDictionary($png['width'], $png['height'], $colorSpace, $colorCount), + stream: $png['idat'], + ); + } + + $bytesPerPixel = $colorCount + 1; + $rowLength = $png['width'] * $bytesPerPixel; + $unfilteredRows = $this->unfilterPngScanlines($png['idat'], $png['height'], $rowLength, $bytesPerPixel); + + $colorScanlines = ''; + $alphaScanlines = ''; + foreach ($unfilteredRows as $row) { + $colorRow = ''; + $alphaRow = ''; + for ($offset = 0; $offset < strlen($row); $offset += $bytesPerPixel) { + $pixel = substr($row, $offset, $bytesPerPixel); + $colorRow .= substr($pixel, 0, $colorCount); + $alphaRow .= $pixel[$bytesPerPixel - 1]; + } + + $colorScanlines .= "\x00" . $colorRow; + $alphaScanlines .= "\x00" . $alphaRow; + } + + return new EmbeddedPdfImage( + dictionary: $this->createImageDictionary($png['width'], $png['height'], $colorSpace, $colorCount), + stream: gzcompress($colorScanlines), + softMask: new EmbeddedPdfImage( + dictionary: $this->createImageDictionary($png['width'], $png['height'], '/DeviceGray', 1), + stream: gzcompress($alphaScanlines), + ), + ); + } + + /** + * @return array{0: string, 1: int, 2: bool} + */ + private function describePngColorType(int $colorType): array + { + return match ($colorType) { + 0 => ['/DeviceGray', 1, false], + 2 => ['/DeviceRGB', 3, false], + 4 => ['/DeviceGray', 1, true], + 6 => ['/DeviceRGB', 3, true], + default => throw new InvalidArgumentException(sprintf('Unsupported PNG color type %d.', $colorType)), + }; + } + + /** + * @return array{width: int, height: int, colorType: int, idat: string} + */ + private function parsePng(string $contents): array + { + $this->assertPngSignature($contents); + + $offset = 8; + $header = null; + $idat = ''; + + while ($offset + 8 <= strlen($contents)) { + ['data' => $data, 'type' => $type] = $this->readPngChunk($contents, $offset); + + if ($type === 'IHDR') { + $header = $this->parsePngHeader($data); + } + + if ($type === 'IDAT') { + $idat .= $data; + } + + if ($type === 'IEND') { + break; + } + } + + if ($header === null) { + throw new InvalidArgumentException('PNG metadata is incomplete.'); + } + + $this->assertSupportedPngHeader($header); + + if ($idat === '') { + throw new InvalidArgumentException('PNG image data is missing.'); + } + + return [ + 'width' => $header['width'], + 'height' => $header['height'], + 'colorType' => $header['colorType'], + 'idat' => $idat, + ]; + } + + private function assertPngSignature(string $contents): void + { + if (str_starts_with($contents, "\x89PNG\r\n\x1a\n") === false) { + throw new InvalidArgumentException('Invalid PNG signature.'); + } + } + + /** + * @return array{data: string, type: string} + */ + private function readPngChunk(string $contents, int &$offset): array + { + $chunkLength = unpack('Nvalue', substr($contents, $offset, 4)); + $offset += 4; + $type = substr($contents, $offset, 4); + $offset += 4; + + if ($chunkLength === false || !isset($chunkLength['value'])) { + throw new InvalidArgumentException('Invalid PNG chunk length.'); + } + + $data = substr($contents, $offset, $chunkLength['value']); + if (strlen($data) !== $chunkLength['value']) { + throw new InvalidArgumentException('PNG chunk data is truncated.'); + } + + $offset += $chunkLength['value'] + 4; + + return [ + 'data' => $data, + 'type' => $type, + ]; + } + + /** + * @return array{ + * width: int, + * height: int, + * bitDepth: int, + * colorType: int, + * compression: int, + * filter: int, + * interlace: int + * } + */ + private function parsePngHeader(string $data): array + { + $header = unpack( + 'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace', + $data, + ); + if ($header === false) { + throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); + } + + return $header; + } + + /** + * @param array{ + * width: int, + * height: int, + * bitDepth: int, + * colorType: int, + * compression: int, + * filter: int, + * interlace: int + * } $header + */ + private function assertSupportedPngHeader(array $header): void + { + if ($header['bitDepth'] !== 8) { + throw new InvalidArgumentException(sprintf('Unsupported PNG bit depth %d.', $header['bitDepth'])); + } + + if ($header['compression'] !== 0 || $header['filter'] !== 0) { + throw new InvalidArgumentException('Unsupported PNG compression or filter method.'); + } + + if ($header['interlace'] !== 0) { + throw new InvalidArgumentException('Interlaced PNG images are not supported.'); + } + } + + /** + * @return array + */ + private function createImageDictionary(int $width, int $height, string $colorSpace, int $colorCount): array + { + return [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => $width, + 'Height' => $height, + 'ColorSpace' => $colorSpace, + 'BitsPerComponent' => 8, + 'Filter' => '/FlateDecode', + 'DecodeParms' => [ + 'Predictor' => 15, + 'Colors' => $colorCount, + 'BitsPerComponent' => 8, + 'Columns' => $width, + ], + ]; + } + + /** + * @return list + */ + private function unfilterPngScanlines(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + $inflated = gzuncompress($idat); + if ($inflated === false) { + throw new InvalidArgumentException('PNG image data could not be decompressed.'); + } + + $rows = []; + $offset = 0; + $previousRow = str_repeat("\x00", $rowLength); + + for ($rowIndex = 0; $rowIndex < $height; $rowIndex++) { + if (!isset($inflated[$offset])) { + throw new InvalidArgumentException('PNG scanlines are truncated.'); + } + + $filterType = ord($inflated[$offset]); + $offset++; + $filteredRow = substr($inflated, $offset, $rowLength); + if (strlen($filteredRow) !== $rowLength) { + throw new InvalidArgumentException('PNG row data is truncated.'); + } + + $offset += $rowLength; + $row = $this->unfilterPngRow($filterType, $filteredRow, $previousRow, $bytesPerPixel); + $rows[] = $row; + $previousRow = $row; + } + + return $rows; + } + + private function unfilterPngRow( + int $filterType, + string $filteredRow, + string $previousRow, + int $bytesPerPixel, + ): string { + $row = ''; + $rowLength = strlen($filteredRow); + + for ($index = 0; $index < $rowLength; $index++) { + $rawByte = ord($filteredRow[$index]); + $left = $index >= $bytesPerPixel ? ord($row[$index - $bytesPerPixel]) : 0; + $above = ord($previousRow[$index]); + $upperLeft = $index >= $bytesPerPixel ? ord($previousRow[$index - $bytesPerPixel]) : 0; + + $decodedByte = match ($filterType) { + 0 => $rawByte, + 1 => ($rawByte + $left) & 0xff, + 2 => ($rawByte + $above) & 0xff, + 3 => ($rawByte + intdiv($left + $above, 2)) & 0xff, + 4 => ($rawByte + $this->paethPredictor($left, $above, $upperLeft)) & 0xff, + default => throw new InvalidArgumentException( + sprintf('Unsupported PNG row filter %d.', $filterType), + ), + }; + + $row .= chr($decodedByte); + } + + return $row; + } + + private function paethPredictor(int $left, int $above, int $upperLeft): int + { + $prediction = $left + $above - $upperLeft; + $leftDistance = abs($prediction - $left); + $aboveDistance = abs($prediction - $above); + $upperLeftDistance = abs($prediction - $upperLeft); + + if ($leftDistance <= $aboveDistance && $leftDistance <= $upperLeftDistance) { + return $left; + } + + if ($aboveDistance <= $upperLeftDistance) { + return $above; + } + + return $upperLeft; + } +} diff --git a/src/Pdf/PdfImageEmbedderInterface.php b/src/Pdf/PdfImageEmbedderInterface.php new file mode 100644 index 0000000..a00da16 --- /dev/null +++ b/src/Pdf/PdfImageEmbedderInterface.php @@ -0,0 +1,13 @@ +bbox; + $width = $maxX - $minX; + $height = $maxY - $minY; + + if ($width <= 0.0 || $height <= 0.0) { + throw new InvalidArgumentException('CompileResult bbox must describe a positive area.'); + } + + $objects = []; + $catalogReference = $this->reserveObject($objects); + $pagesReference = $this->reserveObject($objects); + $pageReference = $this->reserveObject($objects); + $pageContentReference = $this->reserveObject($objects); + $formReference = $this->reserveObject($objects); + + $fontReferences = $this->createFontObjects($objects, $result->resources['Font'] ?? []); + $imageReferences = $this->createImageObjects($objects, $result->resources['XObject'] ?? []); + + $objects[$catalogReference] = $this->serializeDictionary([ + 'Type' => '/Catalog', + 'Pages' => $this->asReference($pagesReference), + ]); + + $objects[$pagesReference] = $this->serializeDictionary([ + 'Type' => '/Pages', + 'Count' => 1, + 'Kids' => [$this->asReference($pageReference)], + ]); + + $objects[$pageReference] = $this->serializeDictionary([ + 'Type' => '/Page', + 'Parent' => $this->asReference($pagesReference), + 'MediaBox' => [0.0, 0.0, $width, $height], + 'Resources' => [ + 'XObject' => [ + 'Fm0' => $this->asReference($formReference), + ], + ], + 'Contents' => $this->asReference($pageContentReference), + ]); + + $pageStream = sprintf( + 'q 1 0 0 1 %s %s cm /Fm0 Do Q', + $this->formatNumber(-$minX), + $this->formatNumber(-$minY), + ); + $objects[$pageContentReference] = $this->serializeStreamObject([], $pageStream); + + $formResources = []; + if ($fontReferences !== []) { + $formResources['Font'] = $fontReferences; + } + + if ($imageReferences !== []) { + $formResources['XObject'] = $imageReferences; + } + + $objects[$formReference] = $this->serializeStreamObject([ + 'Type' => '/XObject', + 'Subtype' => '/Form', + 'FormType' => 1, + 'BBox' => $result->bbox, + 'Resources' => $formResources, + ], $result->contentStream); + + return $this->renderDocument($objects, $catalogReference); + } + + /** + * @param array $objects + * @param array $fontResources + * @return array + */ + private function createFontObjects(array &$objects, array $fontResources): array + { + $fontReferences = []; + + foreach ($fontResources as $alias => $fontResource) { + if (!is_array($fontResource)) { + throw new InvalidArgumentException(sprintf('Font resource "%s" must be an array.', $alias)); + } + + $reference = $this->reserveObject($objects); + $objects[$reference] = $this->serializeDictionary($fontResource); + $fontReferences[$alias] = $this->asReference($reference); + } + + return $fontReferences; + } + + /** + * @param array $objects + * @param array $xObjects + * @return array + */ + private function createImageObjects(array &$objects, array $xObjects): array + { + $imageReferences = []; + + foreach ($xObjects as $alias => $resource) { + if (!is_array($resource)) { + throw new InvalidArgumentException(sprintf('XObject resource "%s" must be an array.', $alias)); + } + + if (($resource['Subtype'] ?? null) !== '/Image') { + throw new InvalidArgumentException(sprintf('Unsupported XObject subtype for "%s".', $alias)); + } + + $source = $resource['Source'] ?? null; + if (!is_string($source) || $source === '') { + throw new InvalidArgumentException( + sprintf('Image resource "%s" must expose a non-empty Source.', $alias), + ); + } + + $embeddedImage = $this->imageEmbedder->embed($source); + $softMaskReference = null; + if ($embeddedImage->softMask !== null) { + $softMaskRefId = $this->reserveObject($objects); + $objects[$softMaskRefId] = $this->serializeStreamObject( + $embeddedImage->softMask->dictionary, + $embeddedImage->softMask->stream, + ); + $softMaskReference = $this->asReference($softMaskRefId); + } + + $dictionary = $embeddedImage->dictionary; + if ($softMaskReference !== null) { + $dictionary['SMask'] = $softMaskReference; + } + + $reference = $this->reserveObject($objects); + $objects[$reference] = $this->serializeStreamObject($dictionary, $embeddedImage->stream); + $imageReferences[$alias] = $this->asReference($reference); + } + + return $imageReferences; + } + + /** + * @param array $objects + */ + private function reserveObject(array &$objects): int + { + $reference = count($objects) + 1; + $objects[$reference] = null; + + return $reference; + } + + /** + * @param array $dictionary + */ + private function serializeStreamObject(array $dictionary, string $stream): string + { + $dictionary['Length'] = strlen($stream); + + return $this->serializeDictionary($dictionary) + . "\nstream\n" + . $stream + . "\nendstream"; + } + + /** + * @param array $dictionary + */ + private function serializeDictionary(array $dictionary): string + { + if ($dictionary === []) { + return '<< >>'; + } + + $entries = []; + foreach ($dictionary as $key => $value) { + $entries[] = sprintf('/%s %s', $key, $this->serializeValue($value)); + } + + return '<< ' . implode(' ', $entries) . ' >>'; + } + + private function serializeValue(mixed $value): string + { + if (is_array($value)) { + return $this->serializeArrayValue($value); + } + + if (is_int($value) || is_float($value)) { + return $this->formatNumber((float) $value); + } + + if (is_string($value)) { + return $this->serializeStringValue($value); + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + throw new InvalidArgumentException(sprintf('Unsupported PDF value type "%s".', get_debug_type($value))); + } + + private function serializeArrayValue(array $value): string + { + if ($value === []) { + return '[]'; + } + + if (array_is_list($value)) { + return '[' . implode(' ', array_map($this->serializeValue(...), $value)) . ']'; + } + + return $this->serializeDictionary($value); + } + + private function serializeStringValue(string $value): string + { + if ($this->isRawPdfValue($value)) { + return $value; + } + + return '(' . $this->escapeLiteralString($value) . ')'; + } + + private function renderDocument(array $objects, int $catalogReference): string + { + ksort($objects); + + $pdf = self::PDF_HEADER; + $offsets = []; + + foreach ($objects as $reference => $objectBody) { + if ($objectBody === null) { + throw new InvalidArgumentException(sprintf('PDF object %d was reserved but not written.', $reference)); + } + + $offsets[$reference] = strlen($pdf); + $pdf .= sprintf("%d 0 obj\n%s\nendobj\n", $reference, $objectBody); + } + + $xrefOffset = strlen($pdf); + $objectCount = count($objects); + + $pdf .= sprintf("xref\n0 %d\n", $objectCount + 1); + $pdf .= "0000000000 65535 f \n"; + for ($reference = 1; $reference <= $objectCount; $reference++) { + $pdf .= sprintf("%010d 00000 n \n", $offsets[$reference]); + } + + $pdf .= "trailer\n"; + $pdf .= $this->serializeDictionary([ + 'Size' => $objectCount + 1, + 'Root' => $this->asReference($catalogReference), + ]); + $pdf .= sprintf("\nstartxref\n%d\n%%%%EOF", $xrefOffset); + + return $pdf; + } + + private function asReference(int $reference): string + { + return sprintf('%d 0 R', $reference); + } + + private function isRawPdfValue(string $value): bool + { + return str_starts_with($value, '/') + || preg_match('/^\d+ 0 R$/', $value) === 1; + } + + private function escapeLiteralString(string $value): string + { + return str_replace( + ['\\', '(', ')'], + ['\\\\', '\\(', '\\)'], + $value, + ); + } + + private function formatNumber(float $value): string + { + $formatted = rtrim(rtrim(sprintf('%.6F', $value), '0'), '.'); + if ($formatted === '' || $formatted === '-0') { + return '0'; + } + + return $formatted; + } +} diff --git a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php new file mode 100644 index 0000000..3a7d304 --- /dev/null +++ b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php @@ -0,0 +1,270 @@ + */ + private array $temporaryFiles = []; + + protected function tearDown(): void + { + foreach ($this->temporaryFiles as $temporaryFile) { + @unlink($temporaryFile); + } + + $this->temporaryFiles = []; + } + + public function testEmbedReturnsPredictorBackedImageForOpaqueRgbPng(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', $this->createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + )); + + $image = $embedder->embed($pngPath); + + self::assertSame('/XObject', $image->dictionary['Type']); + self::assertSame('/Image', $image->dictionary['Subtype']); + self::assertSame(1, $image->dictionary['Width']); + self::assertSame(1, $image->dictionary['Height']); + self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + self::assertSame(8, $image->dictionary['BitsPerComponent']); + self::assertSame('/FlateDecode', $image->dictionary['Filter']); + self::assertSame([ + 'Predictor' => 15, + 'Colors' => 3, + 'BitsPerComponent' => 8, + 'Columns' => 1, + ], $image->dictionary['DecodeParms']); + self::assertNull($image->softMask); + self::assertNotSame('', $image->stream); + } + + public function testEmbedCreatesSoftMaskForRgbaPng(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', $this->createPng( + width: 1, + height: 1, + colorType: 6, + scanlines: "\x00\xff\x00\x00\x80", + )); + + $image = $embedder->embed($pngPath); + + self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + self::assertNotNull($image->softMask); + self::assertSame('/XObject', $image->softMask->dictionary['Type']); + self::assertSame('/Image', $image->softMask->dictionary['Subtype']); + self::assertSame('/DeviceGray', $image->softMask->dictionary['ColorSpace']); + self::assertSame(1, $image->softMask->dictionary['Width']); + self::assertSame(1, $image->softMask->dictionary['Height']); + self::assertSame('/FlateDecode', $image->softMask->dictionary['Filter']); + } + + #[DataProvider('rgbaFilterProvider')] + public function testEmbedSupportsAllRgbaPredictorFilters(int $filterType): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', $this->createPng( + width: 1, + height: 1, + colorType: 6, + scanlines: chr($filterType) . "\xff\x00\x00\x80", + )); + + $image = $embedder->embed($pngPath); + + self::assertNotNull($image->softMask); + self::assertSame("\x00\xff\x00\x00", gzuncompress($image->stream)); + self::assertSame("\x00\x80", gzuncompress($image->softMask->stream)); + } + + #[DataProvider('unsupportedPngHeaderProvider')] + public function testEmbedRejectsUnsupportedPngHeaders(int $bitDepth, int $interlace, string $expectedMessage): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', $this->createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + bitDepth: $bitDepth, + interlace: $interlace, + )); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + $embedder->embed($pngPath); + } + + public function testEmbedRejectsUnsupportedFormats(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $gifPath = $this->createTemporaryFile('gif', 'GIF89a'); + + $this->expectException(\InvalidArgumentException::class); + + $embedder->embed($gifPath); + } + + public function testEmbedSupportsJpegStreams(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $jpegContents = base64_decode( + '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof' + . 'Hh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwh' + . 'MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAAR' + . 'CAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAA' + . 'AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK' + . 'FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG' + . 'h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl' + . '5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREA' + . 'AgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk' + . 'NOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE' + . 'hYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk' + . '5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDi6KKK+ZP3E//Z', + true, + ); + if ($jpegContents === false) { + self::fail('Failed to decode the embedded JPEG fixture.'); + } + + $jpegPath = $this->createTemporaryFile('jpg', $jpegContents); + + $image = $embedder->embed($jpegPath); + + self::assertSame('/DCTDecode', $image->dictionary['Filter']); + self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + self::assertSame(1, $image->dictionary['Width']); + self::assertSame(1, $image->dictionary['Height']); + self::assertSame($jpegContents, $image->stream); + self::assertNull($image->softMask); + } + + public function testEmbedRejectsUnreadableFiles(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be a readable file'); + + $embedder->embed('/tmp/does-not-exist-preview.png'); + } + + public function testEmbedRejectsUnknownBinaryPayloads(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $binaryPath = $this->createTemporaryFile('bin', 'not-an-image'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to detect the image format'); + + $embedder->embed($binaryPath); + } + + public function testEmbedRejectsUnsupportedRowFilters(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', $this->createPng( + width: 1, + height: 1, + colorType: 6, + scanlines: "\x05\xff\x00\x00\x80", + )); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported PNG row filter 5.'); + + $embedder->embed($pngPath); + } + + /** + * @return iterable + */ + public static function rgbaFilterProvider(): iterable + { + yield 'sub filter' => ['filterType' => 1]; + yield 'up filter' => ['filterType' => 2]; + yield 'average filter' => ['filterType' => 3]; + yield 'paeth filter' => ['filterType' => 4]; + } + + /** + * @return iterable + */ + public static function unsupportedPngHeaderProvider(): iterable + { + yield 'unsupported bit depth' => [ + 'bitDepth' => 16, + 'interlace' => 0, + 'expectedMessage' => 'Unsupported PNG bit depth 16.', + ]; + + yield 'unsupported interlace' => [ + 'bitDepth' => 8, + 'interlace' => 1, + 'expectedMessage' => 'Interlaced PNG images are not supported.', + ]; + } + + private function createTemporaryFile(string $extension, string $contents): string + { + $temporaryFile = tempnam(sys_get_temp_dir(), 'xot_'); + if ($temporaryFile === false) { + self::fail('Failed to create a temporary file for image export tests.'); + } + + $pathWithExtension = $temporaryFile . '.' . $extension; + rename($temporaryFile, $pathWithExtension); + file_put_contents($pathWithExtension, $contents); + $this->temporaryFiles[] = $pathWithExtension; + + return $pathWithExtension; + } + + private function createPng( + int $width, + int $height, + int $colorType, + string $scanlines, + int $bitDepth = 8, + int $interlace = 0, + ): string { + $ihdr = pack('NNCCCCC', $width, $height, $bitDepth, $colorType, 0, 0, $interlace); + $idat = gzcompress($scanlines); + + return "\x89PNG\r\n\x1a\n" + . $this->createChunk('IHDR', $ihdr) + . $this->createChunk('IDAT', $idat) + . $this->createChunk('IEND', ''); + } + + private function createChunk(string $type, string $data): string + { + $crc = crc32($type . $data); + if ($crc < 0) { + $crc += 4_294_967_296; + } + + return pack('N', strlen($data)) + . $type + . $data + . pack('N', $crc); + } +} diff --git a/tests/Unit/Pdf/SinglePagePdfExporterTest.php b/tests/Unit/Pdf/SinglePagePdfExporterTest.php new file mode 100644 index 0000000..b36842d --- /dev/null +++ b/tests/Unit/Pdf/SinglePagePdfExporterTest.php @@ -0,0 +1,289 @@ +export(new CompileResult( + contentStream: "q\nBT\n/F1 10 Tf\n0 0 0 rg\n8 72 Td\n(Rendered for Alice) Tj\nET\nQ", + resources: [ + 'Font' => [ + 'F1' => [ + 'Type' => '/Font', + 'Subtype' => '/Type1', + 'BaseFont' => '/Helvetica', + ], + ], + ], + bbox: [12.5, 4.0, 252.5, 88.0], + )); + + self::assertStringStartsWith('%PDF-1.4', $pdf); + self::assertStringContainsString('/Type /Catalog', $pdf); + self::assertStringContainsString('/Type /Page', $pdf); + self::assertStringContainsString('/MediaBox [0 0 240 84]', $pdf); + self::assertStringContainsString('/Subtype /Form', $pdf); + self::assertStringContainsString('/BBox [12.5 4 252.5 88]', $pdf); + self::assertStringContainsString('q 1 0 0 1 -12.5 -4 cm /Fm0 Do Q', $pdf); + self::assertStringContainsString('/BaseFont /Helvetica', $pdf); + self::assertStringContainsString('(Rendered for Alice) Tj', $pdf); + } + + public function testExportUsesInjectedImageEmbedderForImageResources(): void + { + $embedder = new class () implements PdfImageEmbedderInterface + { + /** @var list */ + public array $sources = []; + + public function embed(string $source): EmbeddedPdfImage + { + $this->sources[] = $source; + + return new EmbeddedPdfImage( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => 1, + 'Height' => 1, + 'ColorSpace' => '/DeviceRGB', + 'BitsPerComponent' => 8, + 'Filter' => '/FlateDecode', + 'DecodeParms' => [ + 'Predictor' => 15, + 'Colors' => 3, + 'BitsPerComponent' => 8, + 'Columns' => 1, + ], + ], + stream: gzcompress("\x00\xff\x00\x00"), + ); + } + }; + + $exporter = new SinglePagePdfExporter($embedder); + + $pdf = $exporter->export(new CompileResult( + contentStream: 'q 18 0 0 18 0 45 cm /Im0 Do Q', + resources: [ + 'Font' => [], + 'XObject' => [ + 'Im0' => [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Source' => '/tmp/example-image.png', + 'Width' => 18.0, + 'Height' => 18.0, + ], + ], + ], + bbox: [0.0, 0.0, 240.0, 84.0], + )); + + self::assertSame(['/tmp/example-image.png'], $embedder->sources); + self::assertStringContainsString('/Subtype /Image', $pdf); + self::assertStringContainsString('/ColorSpace /DeviceRGB', $pdf); + self::assertStringContainsString( + '/DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 1 >>', + $pdf, + ); + self::assertStringContainsString('/Im0', $pdf); + self::assertStringContainsString('/Im0 Do', $pdf); + } + + public function testExportSerializesSoftMasksLiteralStringsAndBooleans(): void + { + $exporter = new SinglePagePdfExporter(new class () implements PdfImageEmbedderInterface + { + public function embed(string $source): EmbeddedPdfImage + { + return new EmbeddedPdfImage( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => 1, + 'Height' => 1, + 'ColorSpace' => '/DeviceRGB', + 'BitsPerComponent' => 8, + 'Filter' => '/FlateDecode', + 'Note' => 'Preview (QA)', + 'Interpolate' => true, + ], + stream: gzcompress("\x00\xff\x00\x00"), + softMask: new EmbeddedPdfImage( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => 1, + 'Height' => 1, + 'ColorSpace' => '/DeviceGray', + 'BitsPerComponent' => 8, + 'Filter' => '/FlateDecode', + ], + stream: gzcompress("\x00\x80"), + ), + ); + } + }); + + $pdf = $exporter->export(new CompileResult( + contentStream: 'q 10 0 0 10 0 0 cm /Im0 Do Q', + resources: [ + 'Font' => [], + 'XObject' => [ + 'Im0' => [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Source' => '/tmp/mask-preview.png', + 'Width' => 10.0, + 'Height' => 10.0, + ], + ], + ], + bbox: [0.0, 0.0, 40.0, 40.0], + )); + + self::assertStringContainsString('/SMask', $pdf); + self::assertStringContainsString('/Note (Preview \(QA\))', $pdf); + self::assertStringContainsString('/Interpolate true', $pdf); + } + + #[DataProvider('invalidCompileResultProvider')] + public function testExportRejectsInvalidCompileResults(CompileResult $result, string $expectedMessage): void + { + $exporter = new SinglePagePdfExporter(new class () implements PdfImageEmbedderInterface + { + public function embed(string $source): EmbeddedPdfImage + { + return new EmbeddedPdfImage( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => 1, + 'Height' => 1, + 'ColorSpace' => '/DeviceRGB', + 'BitsPerComponent' => 8, + 'Filter' => '/FlateDecode', + ], + stream: gzcompress("\x00\xff\x00\x00"), + ); + } + }); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + $exporter->export($result); + } + + /** + * @return iterable + */ + public static function invalidCompileResultProvider(): iterable + { + yield 'bbox without positive area' => [ + 'result' => new CompileResult( + contentStream: 'BT ET', + resources: ['Font' => []], + bbox: [0.0, 0.0, 0.0, 40.0], + ), + 'expectedMessage' => 'CompileResult bbox must describe a positive area.', + ]; + + yield 'font resource must be an array' => [ + 'result' => new CompileResult( + contentStream: 'BT ET', + resources: ['Font' => ['F1' => '/Helvetica']], + bbox: [0.0, 0.0, 40.0, 40.0], + ), + 'expectedMessage' => 'Font resource "F1" must be an array.', + ]; + + yield 'unsupported dictionary value type' => [ + 'result' => new CompileResult( + contentStream: 'BT ET', + resources: [ + 'Font' => [ + 'F1' => [ + 'Type' => '/Font', + 'Subtype' => '/Type1', + 'Meta' => new \stdClass(), + ], + ], + ], + bbox: [0.0, 0.0, 40.0, 40.0], + ), + 'expectedMessage' => 'Unsupported PDF value type "stdClass".', + ]; + + yield 'xobject resource must be an array' => [ + 'result' => new CompileResult( + contentStream: 'BT ET', + resources: [ + 'Font' => [], + 'XObject' => ['Im0' => '/Image'], + ], + bbox: [0.0, 0.0, 40.0, 40.0], + ), + 'expectedMessage' => 'XObject resource "Im0" must be an array.', + ]; + + yield 'unsupported xobject subtype' => [ + 'result' => new CompileResult( + contentStream: 'BT ET', + resources: [ + 'Font' => [], + 'XObject' => [ + 'Im0' => [ + 'Type' => '/XObject', + 'Subtype' => '/Form', + 'Source' => '/tmp/form.xobject', + ], + ], + ], + bbox: [0.0, 0.0, 40.0, 40.0], + ), + 'expectedMessage' => 'Unsupported XObject subtype for "Im0".', + ]; + + yield 'missing image source' => [ + 'result' => new CompileResult( + contentStream: 'BT ET', + resources: [ + 'Font' => [], + 'XObject' => [ + 'Im0' => [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Source' => '', + ], + ], + ], + bbox: [0.0, 0.0, 40.0, 40.0], + ), + 'expectedMessage' => 'Image resource "Im0" must expose a non-empty Source.', + ]; + } +}