diff --git a/src/Layout/TextLineBreaker.php b/src/Layout/TextLineBreaker.php index 9eafd4e..86798ef 100644 --- a/src/Layout/TextLineBreaker.php +++ b/src/Layout/TextLineBreaker.php @@ -65,7 +65,7 @@ public function wrap( $lines[] = $currentLine; } - return $lines === [] ? [$text] : $lines; + return $lines; } private function fitsOnCurrentLine( @@ -98,14 +98,9 @@ private function appendBrokenWord( string &$currentLine, ): void { $segments = $this->breakWord($word, $maxWidth, $fontAlias, $fontSize, $hyphens); - $lastIndex = count($segments) - 1; - - foreach ($segments as $index => $segment) { - if ($index === $lastIndex) { - $currentLine = $segment; - continue; - } + $currentLine = array_pop($segments) ?? ''; + foreach ($segments as $segment) { $lines[] = $segment; } } @@ -120,7 +115,7 @@ private function breakWord( float $fontSize, string $hyphens, ): array { - if ($hyphens === 'none') { + if ($hyphens === 'none' || ($hyphens !== 'manual' && $hyphens !== 'auto')) { return [$word]; } @@ -129,10 +124,6 @@ private function breakWord( return $manualSegments; } - if ($hyphens !== 'auto') { - return [$word]; - } - return $this->breakWordAutomatically($word, $maxWidth, $fontAlias, $fontSize); } @@ -146,15 +137,17 @@ private function resolveManualBreaks( float $fontSize, string $hyphens, ): ?array { - if ($hyphens !== 'manual' || !str_contains($word, "\u{00AD}")) { + if ($hyphens !== 'manual') { + return null; + } + + if (!str_contains($word, "\u{00AD}")) { return null; } $manualBreaks = explode("\u{00AD}", $word); - return count($manualBreaks) > 1 - ? $this->packManualSegments($manualBreaks, $maxWidth, $fontAlias, $fontSize) - : null; + return $this->packManualSegments($manualBreaks, $maxWidth, $fontAlias, $fontSize); } /** @@ -166,64 +159,67 @@ private function breakWordAutomatically( string $fontAlias, float $fontSize, ): array { + $characters = $this->splitCharacters($word); + if ($characters === []) { + return [$word]; + } + $segments = []; - $remaining = $this->splitCharacters($word); + $hyphenWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, '-'); + $offset = 0; + $characterCount = count($characters); - while ($remaining !== []) { - ['segment' => $segment, 'consumed' => $consumed] = $this->resolveAutoSegment( - $remaining, + while (isset($characters[$offset])) { + $segment = $this->resolveAutoSegment( + array_slice($characters, $offset), $maxWidth, $fontAlias, $fontSize, + $hyphenWidth, ); - if ($consumed <= 0) { - break; - } - - $remaining = array_slice($remaining, $consumed); - $segments[] = $remaining === [] ? $segment : ($segment . '-'); + $segmentCharacters = $this->splitCharacters($segment); + $fallbackCount = count($this->splitCharacters($characters[$offset])); + $consumedCount = max(count($segmentCharacters) + $fallbackCount - 1, $fallbackCount); + $offset = min($offset + $consumedCount, $characterCount); + $segments[] = !isset($characters[$offset]) ? $segment : ($segment . '-'); } - return $segments === [] ? [$word] : $segments; + return $segments; } /** * @param list $remaining - * @return array{segment: string, consumed: int} + * @return string */ private function resolveAutoSegment( array $remaining, float $maxWidth, string $fontAlias, float $fontSize, - ): array { + float $hyphenWidth, + ): string { $segment = ''; - $consumed = 0; $remainingCount = count($remaining); foreach ($remaining as $index => $character) { $candidate = $segment . $character; - $isLastCharacter = $index === ($remainingCount - 1); - $candidateWidth = $this->fontMetrics->measureString( - $fontAlias, - $fontSize, - $candidate . ($isLastCharacter ? '' : '-'), - ); + $hasMoreCharacters = ($index + 1) < $remainingCount; + $candidateWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) + + ($hasMoreCharacters ? $hyphenWidth : 0.0); if ($candidateWidth > $maxWidth && $segment !== '') { break; } if ($candidateWidth > $maxWidth) { - return ['segment' => $character, 'consumed' => 1]; + return $character; } $segment = $candidate; - $consumed = $index + 1; } - return ['segment' => $segment, 'consumed' => $consumed]; + return $segment; } /** @@ -239,13 +235,15 @@ private function packManualSegments( $packed = []; $current = ''; $lastIndex = count($segments) - 1; + $hyphenWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, '-'); foreach ($segments as $index => $segment) { $candidate = $current . $segment; - $candidateWithHyphen = $candidate . ($index === $lastIndex ? '' : '-'); + $candidateWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) + + ($index === $lastIndex ? 0.0 : $hyphenWidth); if ( $current !== '' - && $this->fontMetrics->measureString($fontAlias, $fontSize, $candidateWithHyphen) > $maxWidth + && $candidateWidth > $maxWidth ) { $packed[] = $current . '-'; $current = $segment; diff --git a/src/Layout/TextOverflowTruncator.php b/src/Layout/TextOverflowTruncator.php index 9f72425..5578a8f 100644 --- a/src/Layout/TextOverflowTruncator.php +++ b/src/Layout/TextOverflowTruncator.php @@ -36,15 +36,13 @@ public function forceEllipsis( float $fontSize, ): string { $ellipsis = '...'; + $ellipsisWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $ellipsis); $characters = $this->splitCharacters($text); - while ($characters !== []) { - $candidate = implode('', $characters) . $ellipsis; - if ($this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) <= $maxWidth) { - return rtrim(implode('', $characters)) . $ellipsis; + foreach ($this->buildCandidates($characters) as $candidate) { + if (($this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) + $ellipsisWidth) <= $maxWidth) { + return rtrim($candidate) . $ellipsis; } - - array_pop($characters); } return $ellipsis; @@ -59,4 +57,21 @@ private function splitCharacters(string $text): array return $characters === false ? [] : $characters; } + + /** + * @param list $characters + * @return list + */ + private function buildCandidates(array $characters): array + { + $candidates = []; + $candidate = ''; + + foreach ($characters as $character) { + $candidate .= $character; + $candidates[] = $candidate; + } + + return array_reverse($candidates); + } } diff --git a/src/Pdf/FilesystemImageSourceReader.php b/src/Pdf/FilesystemImageSourceReader.php new file mode 100644 index 0000000..40b0c87 --- /dev/null +++ b/src/Pdf/FilesystemImageSourceReader.php @@ -0,0 +1,47 @@ +warningConverter = $warningConverter ?? new PhpWarningToExceptionConverter(); + } + + public function read(string $source): string + { + $this->assertReadableSource($source); + + $contents = $this->warningConverter->run( + static fn (): string|false => file_get_contents($source), + sprintf('Failed to read image source "%s".', $source), + ); + if (!is_string($contents)) { + throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source)); + } + + return $contents; + } + + private function assertReadableSource(string $source): void + { + if (!is_file($source)) { + throw new InvalidArgumentException(sprintf('Image source "%s" must be an existing file.', $source)); + } + + if (!is_readable($source)) { + throw new InvalidArgumentException(sprintf('Image source "%s" must be readable.', $source)); + } + } +} diff --git a/src/Pdf/FilesystemImageSourceReaderInterface.php b/src/Pdf/FilesystemImageSourceReaderInterface.php new file mode 100644 index 0000000..f1591d8 --- /dev/null +++ b/src/Pdf/FilesystemImageSourceReaderInterface.php @@ -0,0 +1,14 @@ +sourceReader = $sourceReader ?? new FilesystemImageSourceReader(); + $this->metadataInspector = $metadataInspector ?? new ImageMetadataInspector(); + $this->jpegImageFactory = $jpegImageFactory ?? new JpegPdfImageFactory(); + $this->pngImageFactory = $pngImageFactory ?? new PngPdfImageFactory(); + } + public function embed(string $source): EmbeddedPdfImage { - if (!is_file($source) || !is_readable($source)) { - throw new InvalidArgumentException(sprintf('Image source "%s" must be a readable file.', $source)); - } - - $contents = file_get_contents($source); - if ($contents === false) { - throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source)); - } - - $imageInfo = getimagesizefromstring($contents); - if ($imageInfo === false || !isset($imageInfo['mime'])) { - throw new InvalidArgumentException(sprintf('Unable to detect the image format for "%s".', $source)); - } - - $mime = $imageInfo['mime']; + $contents = $this->sourceReader->read($source); + $imageInfo = $this->metadataInspector->detect($contents, $source); + $mime = $this->metadataInspector->resolveMimeType($imageInfo, $source); return match ($mime) { - 'image/jpeg' => $this->embedJpeg($contents, $imageInfo), - 'image/png' => $this->embedPng($contents), + 'image/jpeg' => $this->jpegImageFactory->create($contents, $imageInfo), + 'image/png' => $this->pngImageFactory->create($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/ImageMetadataInspector.php b/src/Pdf/ImageMetadataInspector.php new file mode 100644 index 0000000..a2d8831 --- /dev/null +++ b/src/Pdf/ImageMetadataInspector.php @@ -0,0 +1,50 @@ + + */ + public function detect(string $contents, string $source): array + { + $imageInfo = getimagesizefromstring($contents); + if (!is_array($imageInfo)) { + throw new InvalidArgumentException(sprintf('Unable to detect the image format for "%s".', $source)); + } + + return $imageInfo; + } + + /** + * @param array $imageInfo + */ + public function resolveMimeType(array $imageInfo, string $source): string + { + if (!array_key_exists('mime', $imageInfo)) { + throw new InvalidArgumentException(sprintf( + 'Image metadata for "%s" does not expose a mime type.', + $source, + )); + } + + $mime = $imageInfo['mime']; + if (!is_string($mime)) { + throw new InvalidArgumentException(sprintf( + 'Image metadata for "%s" must expose the mime type as a string.', + $source, + )); + } + + return $mime; + } +} diff --git a/src/Pdf/ImageMetadataInspectorInterface.php b/src/Pdf/ImageMetadataInspectorInterface.php new file mode 100644 index 0000000..3d08641 --- /dev/null +++ b/src/Pdf/ImageMetadataInspectorInterface.php @@ -0,0 +1,22 @@ + + */ + public function detect(string $contents, string $source): array; + + /** + * @param array $imageInfo + */ + public function resolveMimeType(array $imageInfo, string $source): string; +} diff --git a/src/Pdf/Jpeg/JpegPdfImageFactory.php b/src/Pdf/Jpeg/JpegPdfImageFactory.php new file mode 100644 index 0000000..caca658 --- /dev/null +++ b/src/Pdf/Jpeg/JpegPdfImageFactory.php @@ -0,0 +1,51 @@ + $imageInfo + */ + public function create(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.'); + } + + return new EmbeddedPdfImage( + dictionary: [ + 'Type' => '/XObject', + 'Subtype' => '/Image', + 'Width' => $width, + 'Height' => $height, + 'ColorSpace' => $this->resolveColorSpace($imageInfo['channels'] ?? null), + 'BitsPerComponent' => 8, + 'Filter' => '/DCTDecode', + ], + stream: $contents, + ); + } + + private function resolveColorSpace(mixed $channels): string + { + return match ($channels) { + 1 => '/DeviceGray', + 4 => '/DeviceCMYK', + default => '/DeviceRGB', + }; + } +} diff --git a/src/Pdf/Jpeg/JpegPdfImageFactoryInterface.php b/src/Pdf/Jpeg/JpegPdfImageFactoryInterface.php new file mode 100644 index 0000000..a5dbf1a --- /dev/null +++ b/src/Pdf/Jpeg/JpegPdfImageFactoryInterface.php @@ -0,0 +1,19 @@ + $imageInfo + */ + public function create(string $contents, array $imageInfo): EmbeddedPdfImage; +} diff --git a/src/Pdf/PhpWarningToExceptionConverter.php b/src/Pdf/PhpWarningToExceptionConverter.php new file mode 100644 index 0000000..9b9de6a --- /dev/null +++ b/src/Pdf/PhpWarningToExceptionConverter.php @@ -0,0 +1,34 @@ +colorCount = $colorCount; + $this->bytesPerPixel = $bytesPerPixel; + } +} diff --git a/src/Pdf/Png/PngParser.php b/src/Pdf/Png/PngParser.php new file mode 100644 index 0000000..3318ff5 --- /dev/null +++ b/src/Pdf/Png/PngParser.php @@ -0,0 +1,165 @@ +assertPngSignature($contents); + + $contentLength = strlen($contents); + $offset = 8; + $header = null; + $idat = ''; + + while (($contentLength - $offset) >= 12) { + ['data' => $data, 'type' => $type] = $this->readChunk($contents, $offset); + + if ($type === 'IHDR') { + $header = $this->parseHeader($data); + } + + if ($type === 'IDAT') { + $idat .= $data; + } + + if ($type === 'IEND') { + if ($offset !== $contentLength) { + throw new InvalidArgumentException('PNG data after IEND is not supported.'); + } + + if ($header === null) { + throw new InvalidArgumentException('PNG metadata is incomplete.'); + } + + $this->assertSupportedHeader($header); + + if ($idat === '') { + throw new InvalidArgumentException('PNG image data is missing.'); + } + + return new ParsedPngImage( + width: $header['width'], + height: $header['height'], + colorType: $header['colorType'], + idat: $idat, + ); + } + } + + throw new InvalidArgumentException('PNG trailer chunk is missing.'); + } + + /** + * @return array{data: string, type: string} + */ + public function readChunk(string $contents, int &$offset): array + { + $chunkLengthBytes = substr($contents, $offset, 4); + $chunkLength = $this->parseChunkLength($chunkLengthBytes); + + $offset += 4; + $type = substr($contents, $offset, 4); + $offset += 4; + + if (strlen($type) !== 4) { + throw new InvalidArgumentException('Invalid PNG chunk type.'); + } + + $data = substr($contents, $offset, $chunkLength); + if (strlen($data) !== $chunkLength) { + throw new InvalidArgumentException('PNG chunk data is truncated.'); + } + + $offset += $chunkLength + 4; + + return [ + 'data' => $data, + 'type' => $type, + ]; + } + + /** + * @return array{ + * width: int, + * height: int, + * bitDepth: int, + * colorType: int, + * compression: int, + * filter: int, + * interlace: int + * } + */ + public function parseHeader(string $data): array + { + if (strlen($data) !== 13) { + throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); + } + + $header = unpack( + 'Nwidth/Nheight/CbitDepth/CcolorType/Ccompression/Cfilter/Cinterlace', + $data, + ); + if (!is_array($header)) { + throw new InvalidArgumentException('Unable to parse the PNG IHDR chunk.'); + } + + return $header; + } + + public function parseChunkLength(string $chunkLengthBytes): int + { + if (strlen($chunkLengthBytes) !== 4) { + throw new InvalidArgumentException('Invalid PNG chunk length.'); + } + + return (ord($chunkLengthBytes[0]) << 24) + | (ord($chunkLengthBytes[1]) << 16) + | (ord($chunkLengthBytes[2]) << 8) + | ord($chunkLengthBytes[3]); + } + + private function assertPngSignature(string $contents): void + { + if (str_starts_with($contents, "\x89PNG\r\n\x1a\n") === false) { + throw new InvalidArgumentException('Invalid PNG signature.'); + } + } + + /** + * @param array{ + * width: int, + * height: int, + * bitDepth: int, + * colorType: int, + * compression: int, + * filter: int, + * interlace: int + * } $header + */ + private function assertSupportedHeader(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.'); + } + } +} diff --git a/src/Pdf/Png/PngParserInterface.php b/src/Pdf/Png/PngParserInterface.php new file mode 100644 index 0000000..8f39b75 --- /dev/null +++ b/src/Pdf/Png/PngParserInterface.php @@ -0,0 +1,16 @@ +parser = $parser ?? new PngParser(); + $this->scanlineUnfilterer = $scanlineUnfilterer ?? new PngScanlineUnfilterer(); + } + + public function create(string $contents): EmbeddedPdfImage + { + $png = $this->parser->parse($contents); + $colorType = $this->describeColorType($png->colorType); + + if ($colorType->hasAlpha === false) { + return new EmbeddedPdfImage( + dictionary: $this->createImageDictionary( + $png->width, + $png->height, + $colorType->colorSpace, + $colorType->colorCount, + ), + stream: $png->idat, + ); + } + + [$colorScanlines, $alphaScanlines] = $this->splitAlphaScanlines($png, $colorType); + + return new EmbeddedPdfImage( + dictionary: $this->createImageDictionary( + $png->width, + $png->height, + $colorType->colorSpace, + $colorType->colorCount, + ), + stream: $this->compressScanlines($colorScanlines), + softMask: new EmbeddedPdfImage( + dictionary: $this->createImageDictionary($png->width, $png->height, '/DeviceGray', 1), + stream: $this->compressScanlines($alphaScanlines), + ), + ); + } + + private function describeColorType(int $colorType): PngColorTypeDescription + { + return match ($colorType) { + 0 => new PngColorTypeDescription('/DeviceGray', 1, 1, false), + 2 => new PngColorTypeDescription('/DeviceRGB', 3, 3, false), + 4 => new PngColorTypeDescription('/DeviceGray', 1, 2, true), + 6 => new PngColorTypeDescription('/DeviceRGB', 3, 4, true), + default => throw new InvalidArgumentException(sprintf('Unsupported PNG color type %d.', $colorType)), + }; + } + + /** + * @return array{0: string, 1: string} + */ + private function splitAlphaScanlines(ParsedPngImage $png, PngColorTypeDescription $colorType): array + { + $colorCount = $colorType->colorCount; + $bytesPerPixel = $colorType->bytesPerPixel; + $rowLength = $png->width * $bytesPerPixel; + $unfilteredRows = $this->scanlineUnfilterer->unfilter( + $png->idat, + $png->height, + $rowLength, + $bytesPerPixel, + ); + + $colorScanlines = ''; + $alphaScanlines = ''; + foreach ($unfilteredRows as $row) { + $colorRow = ''; + $alphaRow = ''; + foreach (str_split($row, $bytesPerPixel) as $pixel) { + if (strlen($pixel) !== $bytesPerPixel) { + throw new InvalidArgumentException('PNG row data is truncated.'); + } + + $colorRow .= substr($pixel, 0, $colorCount); + $alphaRow .= $pixel[$bytesPerPixel - 1]; + } + + $colorScanlines .= "\x00" . $colorRow; + $alphaScanlines .= "\x00" . $alphaRow; + } + + return [$colorScanlines, $alphaScanlines]; + } + + /** + * @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, + ], + ]; + } + + private function compressScanlines(string $scanlines): string + { + $compressed = gzcompress($scanlines); + if (!is_string($compressed)) { + throw new InvalidArgumentException('PNG scanlines could not be compressed.'); + } + + return $compressed; + } +} diff --git a/src/Pdf/Png/PngPdfImageFactoryInterface.php b/src/Pdf/Png/PngPdfImageFactoryInterface.php new file mode 100644 index 0000000..5180a98 --- /dev/null +++ b/src/Pdf/Png/PngPdfImageFactoryInterface.php @@ -0,0 +1,16 @@ +warningConverter = $warningConverter ?? new PhpWarningToExceptionConverter(); + } + + /** + * @return list + */ + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + $inflated = $this->warningConverter->run( + static fn (): string|false => gzuncompress($idat), + 'PNG image data could not be decompressed.', + ); + if (!is_string($inflated)) { + 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->unfilterRow($filterType, $filteredRow, $previousRow, $bytesPerPixel); + $rows[] = $row; + $previousRow = $row; + } + + return $rows; + } + + public function unfilterRow( + int $filterType, + string $filteredRow, + string $previousRow, + int $bytesPerPixel, + ): string { + $row = ''; + $paddedPreviousRow = str_repeat("\x00", $bytesPerPixel) . $previousRow; + + foreach (str_split($filteredRow) as $index => $rawByteCharacter) { + $rawByte = ord($rawByteCharacter); + $left = $index >= $bytesPerPixel ? ord($row[$index - $bytesPerPixel]) : 0; + $above = ord($previousRow[$index]); + $upperLeft = ord($paddedPreviousRow[$index]); + + $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; + } + + public 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); + + $bestDistance = min($leftDistance, $aboveDistance, $upperLeftDistance); + + if ($bestDistance === $leftDistance) { + return $left; + } + + if ($bestDistance === $aboveDistance) { + return $above; + } + + return $upperLeft; + } +} diff --git a/src/Pdf/Png/PngScanlineUnfiltererInterface.php b/src/Pdf/Png/PngScanlineUnfiltererInterface.php new file mode 100644 index 0000000..98eaa7f --- /dev/null +++ b/src/Pdf/Png/PngScanlineUnfiltererInterface.php @@ -0,0 +1,17 @@ + + */ + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array; +} diff --git a/src/Pdf/SinglePagePdfExporter.php b/src/Pdf/SinglePagePdfExporter.php index 2b4c917..bd0e0fd 100644 --- a/src/Pdf/SinglePagePdfExporter.php +++ b/src/Pdf/SinglePagePdfExporter.php @@ -207,8 +207,12 @@ private function serializeValue(mixed $value): string return $this->serializeArrayValue($value); } - if (is_int($value) || is_float($value)) { - return $this->formatNumber((float) $value); + if (is_int($value)) { + return (string) $value; + } + + if (is_float($value)) { + return $this->formatNumber($value); } if (is_string($value)) { @@ -265,8 +269,8 @@ private function renderDocument(array $objects, int $catalogReference): string $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]); + foreach ($offsets as $offset) { + $pdf .= sprintf("%010d 00000 n \n", $offset); } $pdf .= "trailer\n"; diff --git a/src/Pdf/StandardFontMetrics.php b/src/Pdf/StandardFontMetrics.php index bb69291..06d747a 100644 --- a/src/Pdf/StandardFontMetrics.php +++ b/src/Pdf/StandardFontMetrics.php @@ -55,7 +55,15 @@ public function measureString(string $fontAlias, float $fontSize, string $text): float { - if ($text === '' || $fontSize <= 0.0) { + if ($text === '') { + return 0.0; + } + + if ($fontSize === 0.0) { + return 0.0; + } + + if (max($fontSize, 0.0) !== $fontSize) { return 0.0; } diff --git a/src/Pdf/WarningToExceptionConverterInterface.php b/src/Pdf/WarningToExceptionConverterInterface.php new file mode 100644 index 0000000..0ead2e4 --- /dev/null +++ b/src/Pdf/WarningToExceptionConverterInterface.php @@ -0,0 +1,14 @@ + $idatChunks + */ + public static function createPngFromCompressedIdatChunks( + int $width, + int $height, + int $colorType, + array $idatChunks, + int $bitDepth = 8, + int $interlace = 0, + int $compression = 0, + int $filter = 0, + ): string { + $ihdr = pack('NNCCCCC', $width, $height, $bitDepth, $colorType, $compression, $filter, $interlace); + $png = "\x89PNG\r\n\x1a\n" . self::createChunk('IHDR', $ihdr); + + foreach ($idatChunks as $idatChunk) { + $png .= self::createChunk('IDAT', $idatChunk); + } + + return $png . self::createChunk('IEND', ''); } + public static function compressScanlines(string $scanlines): string + { + $idat = gzcompress($scanlines); + if ($idat === false) { + throw new InvalidArgumentException('Failed to compress PNG scanlines.'); + } + + return $idat; + } + /** * @param callable(int, int, int, int): array{0: int|float, 1: int|float, 2: int|float, 3: int|float} $pixelRenderer */ @@ -56,7 +98,7 @@ public static function createRgbaPngFromPixelRenderer( return self::createPng($width, $height, 6, $scanlines); } - private static function createChunk(string $type, string $data): string + public static function createChunk(string $type, string $data): string { $crc = crc32($type . $data); if ($crc < 0) { diff --git a/tests/Support/UsesTemporaryFiles.php b/tests/Support/UsesTemporaryFiles.php new file mode 100644 index 0000000..9ecd811 --- /dev/null +++ b/tests/Support/UsesTemporaryFiles.php @@ -0,0 +1,48 @@ + */ + private array $temporaryFiles = []; + + protected function tearDownTemporaryFiles(): void + { + foreach ($this->temporaryFiles as $temporaryFile) { + if (is_file($temporaryFile)) { + unlink($temporaryFile); + } + } + + $this->temporaryFiles = []; + } + + protected function createTemporaryFile(string $extension, string $contents): string + { + $temporaryFile = tempnam(sys_get_temp_dir(), 'xot_'); + if ($temporaryFile === false) { + throw new RuntimeException('Failed to create a temporary file for image export tests.'); + } + + $pathWithExtension = $temporaryFile . '.' . $extension; + if (rename($temporaryFile, $pathWithExtension) === false) { + throw new RuntimeException('Failed to rename the temporary image fixture.'); + } + + if (file_put_contents($pathWithExtension, $contents) === false) { + throw new RuntimeException('Failed to write the temporary image fixture.'); + } + + $this->temporaryFiles[] = $pathWithExtension; + + return $pathWithExtension; + } +} diff --git a/tests/Unit/Layout/StructuredLayoutRendererTest.php b/tests/Unit/Layout/StructuredLayoutRendererTest.php index 25e78fb..f89ebfb 100644 --- a/tests/Unit/Layout/StructuredLayoutRendererTest.php +++ b/tests/Unit/Layout/StructuredLayoutRendererTest.php @@ -16,6 +16,20 @@ final class StructuredLayoutRendererTest extends TestCase { + public function testLayoutUsesBreakNodesAsTwelvePointFlowSpacing(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node(tag: 'br', text: '', attributes: []), + $this->textNode('After'), + ], 100.0, 100.0); + + self::assertCount(1, $result->lines); + self::assertSame('After', $result->lines[0]->text); + self::assertSame(76.0, $result->lines[0]->y); + } + public function testLayoutKeepsAbsoluteNodesOutOfFlowAndAccumulatesFlowHeights(): void { $renderer = $this->createRenderer(); @@ -115,6 +129,104 @@ public function testLayoutUsesAutoFlexHeightToPositionFollowingSiblings(): void self::assertSame(63.0, $result->lines[0]->y); } + public function testLayoutUsesAutoHeightForEmptyFlexContainersWithoutClipping(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'display:flex;width:100;padding:2 0 3 0;background-color:#abcdef'], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame(100.0, $result->decorations[0]->width); + self::assertSame(5.0, $result->decorations[0]->height); + self::assertSame(75.0, $result->decorations[0]->y); + } + + public function testLayoutUsesFixedHeightFallbackForEmptyFlexContainersInsideClipBoxes(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'overflow:hidden;width:40;height:20'], + children: [ + new Node( + tag: 'div', + text: '', + attributes: [ + 'style' => 'display:flex;width:40;height:2;padding:2 0 3 0;' + . 'background-color:#abcdef', + ], + ), + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame(40.0, $result->decorations[0]->width); + self::assertSame(2.0, $result->decorations[0]->height); + self::assertSame(78.0, $result->decorations[0]->y); + } + + public function testLayoutUsesPaddingHeightForEmptyFlexContainersInsideClipBoxesWithoutExplicitHeight(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'overflow:hidden;width:40;height:20'], + children: [ + new Node( + tag: 'div', + text: '', + attributes: [ + 'style' => 'display:flex;width:40;padding:2 0 3 0;background-color:#abcdef', + ], + ), + ], + ), + ], 120.0, 80.0); + + self::assertCount(1, $result->decorations); + self::assertSame(40.0, $result->decorations[0]->width); + self::assertSame(5.0, $result->decorations[0]->height); + self::assertSame(75.0, $result->decorations[0]->y); + } + + public function testLayoutSupportsAbsolutelyPositionedChildrenInsideFlexContainers(): void + { + $renderer = $this->createRenderer(); + + $result = $renderer->layout([ + new Node( + tag: 'div', + text: '', + attributes: ['style' => 'display:flex;width:40;height:20'], + children: [ + $this->imageNode('/absolute.png', 'position:absolute;left:5;top:2;width:10;height:10'), + $this->imageNode('/flow.png', 'width:10;height:10'), + ], + ), + ], 40.0, 40.0); + + self::assertCount(2, $result->images); + self::assertSame('/absolute.png', $result->images[0]->source); + self::assertSame(5.0, $result->images[0]->x); + self::assertSame(28.0, $result->images[0]->y); + self::assertSame('/flow.png', $result->images[1]->source); + self::assertSame(0.0, $result->images[1]->x); + self::assertSame(30.0, $result->images[1]->y); + } + public function testLayoutAccumulatesParentTextAndChildHeightBeforeFollowingNodes(): void { $renderer = $this->createRenderer(); diff --git a/tests/Unit/Layout/TextLineBreakerTest.php b/tests/Unit/Layout/TextLineBreakerTest.php new file mode 100644 index 0000000..96f2aa0 --- /dev/null +++ b/tests/Unit/Layout/TextLineBreakerTest.php @@ -0,0 +1,209 @@ +wrap('alpha beta', 0.0, 'F1', 10.0, 'auto', 'normal')); + self::assertSame(['alpha beta'], $breaker->wrap('alpha beta', 100.0, 'F1', 10.0, 'auto', 'nowrap')); + } + + public function testWrapPreservesWhitespaceOnlyInput(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame([" \t "], $breaker->wrap(" \t ", 20.0, 'F1', 10.0, 'auto', 'normal')); + } + + public function testWrapKeepsWordsOnTheSameLineWhenTheyStillFit(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame(['a b'], $breaker->wrap('a b', 20.0, 'F5', 10.0, 'none', 'normal')); + } + + public function testWrapKeepsLongWordsUnbrokenWhenHyphenationIsDisabled(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame(['abcdef'], $breaker->wrap('abcdef', 5.0, 'F1', 10.0, 'none', 'normal')); + } + + public function testBreakWordKeepsWordUnchangedWhenHyphenationIsDisabled(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abcdef'], + $this->invokeBreakerMethod($breaker, 'breakWord', 'abcdef', 5.0, 'F1', 10.0, 'none'), + ); + } + + public function testBreakWordKeepsWordUnchangedForUnsupportedHyphenationMode(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abcdef'], + $this->invokeBreakerMethod($breaker, 'breakWord', 'abcdef', 5.0, 'F1', 10.0, 'inherit'), + ); + } + + public function testWrapKeepsAppendingAfterManualHyphenBreaks(): void + { + $metrics = new StandardFontMetrics(); + $breaker = new TextLineBreaker($metrics); + $maxWidth = max( + $metrics->measureString('F1', 10.0, 'hyphen-'), + $metrics->measureString('F1', 10.0, 'ation test'), + ); + + self::assertSame( + ['hyphen-', 'ation test'], + $breaker->wrap("hyphen\u{00AD}ation test", $maxWidth, 'F1', 10.0, 'manual', 'normal'), + ); + } + + public function testWrapContinuesUsingTheLastBrokenSegmentAsTheCurrentLine(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abcd-', 'ef gh'], + $breaker->wrap('abcdef gh', 30.0, 'F5', 10.0, 'auto', 'normal'), + ); + } + + public function testWrapAutomaticallyHyphenatesFixedWidthWords(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abc-', 'def'], + $breaker->wrap('abcdef', 24.0, 'F5', 10.0, 'auto', 'normal'), + ); + } + + public function testWrapAutomaticallyHyphenatesIntoMultipleSegmentsAndLeavesLastSegmentPlain(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['ab-', 'cd-', 'efg'], + $breaker->wrap('abcdefg', 18.0, 'F5', 10.0, 'auto', 'normal'), + ); + } + + public function testWrapPacksManualSegmentsInOrderAtExactWidthBoundary(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['abc-', 'de'], + $breaker->wrap("ab\u{00AD}c\u{00AD}de", 24.0, 'F5', 10.0, 'manual', 'normal'), + ); + } + + public function testResolveManualBreaksOnlyRunsForManualSoftHyphenatedWords(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertNull( + $this->invokeBreakerMethod($breaker, 'resolveManualBreaks', "ab\u{00AD}cd", 24.0, 'F5', 10.0, 'auto'), + ); + self::assertNull( + $this->invokeBreakerMethod($breaker, 'resolveManualBreaks', 'abcd', 24.0, 'F5', 10.0, 'manual'), + ); + self::assertSame( + ['ab-', 'cd'], + $this->invokeBreakerMethod($breaker, 'resolveManualBreaks', "ab\u{00AD}cd", 18.0, 'F5', 10.0, 'manual'), + ); + } + + public function testPackManualSegmentsContinuesAcrossMultipleOverflowsAndKeepsLastSegmentPlain(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['ab-', 'cd-', 'efg'], + $this->invokeBreakerMethod($breaker, 'packManualSegments', ['ab', 'cd', 'ef', 'g'], 18.0, 'F5', 10.0), + ); + } + + public function testPackManualSegmentsReturnsFallbackForEmptySegmentList(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + [''], + $this->invokeBreakerMethod($breaker, 'packManualSegments', [], 18.0, 'F5', 10.0), + ); + } + + public function testSplitWordsDropsEmptyChunksAndReindexesValues(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['alpha', 'beta'], + $this->invokeBreakerMethod($breaker, 'splitWords', " alpha\n\tbeta "), + ); + } + + public function testWrapFallsBackToSingleCharactersWhenTheFirstAutoSegmentDoesNotFit(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame( + ['a-', 'b'], + $breaker->wrap('ab', 5.0, 'F1', 10.0, 'auto', 'normal'), + ); + } + + public function testWrapReturnsInvalidUtf8WordsUnchangedWhenCharacterSplittingFails(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + $invalidUtf8 = "\xc3\x28"; + + self::assertSame([$invalidUtf8], $breaker->wrap($invalidUtf8, 1.0, 'F1', 10.0, 'auto', 'normal')); + } + + public function testSplitCharactersReturnsEmptyListForInvalidUtf8(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + + self::assertSame([], $this->invokeBreakerMethod($breaker, 'splitCharacters', "\xc3\x28")); + } + + public function testBreakWordAutomaticallyReturnsInvalidUtf8WordWhenCharacterSplittingFails(): void + { + $breaker = new TextLineBreaker(new StandardFontMetrics()); + $invalidUtf8 = "\xc3\x28"; + + self::assertSame( + [$invalidUtf8], + $this->invokeBreakerMethod($breaker, 'breakWordAutomatically', $invalidUtf8, 1.0, 'F1', 10.0), + ); + } + + private function invokeBreakerMethod(TextLineBreaker $breaker, string $method, mixed ...$arguments): mixed + { + $reflectionMethod = new ReflectionMethod($breaker, $method); + + return $reflectionMethod->invoke($breaker, ...$arguments); + } +} diff --git a/tests/Unit/Layout/TextOverflowTruncatorTest.php b/tests/Unit/Layout/TextOverflowTruncatorTest.php new file mode 100644 index 0000000..615f988 --- /dev/null +++ b/tests/Unit/Layout/TextOverflowTruncatorTest.php @@ -0,0 +1,74 @@ +truncateWithEllipsis($text, $metrics->measureString('F1', 10.0, $text), 'F1', 10.0), + ); + } + + public function testTruncateWithEllipsisShortensUntilTheCandidateFits(): void + { + $metrics = new StandardFontMetrics(); + $truncator = new TextOverflowTruncator($metrics); + + self::assertSame( + 'ab...', + $truncator->truncateWithEllipsis('abcdef', $metrics->measureString('F5', 10.0, 'ab...'), 'F5', 10.0), + ); + } + + public function testForceEllipsisTrimsTrailingWhitespaceBeforeAppendingTheMarker(): void + { + $metrics = new StandardFontMetrics(); + $truncator = new TextOverflowTruncator($metrics); + + self::assertSame( + 'word...', + $truncator->forceEllipsis('word ', $metrics->measureString('F1', 10.0, 'word ...'), 'F1', 10.0), + ); + } + + public function testForceEllipsisFallsBackToOnlyTheMarkerWhenNothingFits(): void + { + $truncator = new TextOverflowTruncator(new StandardFontMetrics()); + + self::assertSame('...', $truncator->forceEllipsis('text', 1.0, 'F1', 10.0)); + } + + public function testForceEllipsisReturnsOnlyTheMarkerForInvalidUtf8Input(): void + { + $truncator = new TextOverflowTruncator(new StandardFontMetrics()); + + self::assertSame('...', $truncator->forceEllipsis("\xc3\x28", 10.0, 'F1', 10.0)); + } + + public function testForceEllipsisUsesSuffixWidthWhenCheckingFit(): void + { + $metrics = new StandardFontMetrics(); + $truncator = new TextOverflowTruncator($metrics); + + self::assertSame( + 'i...', + $truncator->forceEllipsis('iW', $metrics->measureString('F1', 10.0, 'i...'), 'F1', 10.0), + ); + } +} diff --git a/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php b/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php new file mode 100644 index 0000000..e434fb1 --- /dev/null +++ b/tests/Unit/Pdf/FilesystemImageSourceReaderTest.php @@ -0,0 +1,70 @@ +tearDownTemporaryFiles(); + } + + public function testReadReturnsContentsForReadableFiles(): void + { + $reader = new FilesystemImageSourceReader(); + $path = $this->createTemporaryFile('png', 'contents'); + + self::assertSame('contents', $reader->read($path)); + } + + public function testReadUsesInjectedWarningConverter(): void + { + $reader = new FilesystemImageSourceReader(new class implements WarningToExceptionConverterInterface { + public function run(callable $operation, string $message): mixed + { + return 'converted-contents'; + } + }); + $path = $this->createTemporaryFile('png', 'disk-contents'); + + self::assertSame('converted-contents', $reader->read($path)); + } + + public function testReadRejectsMissingSources(): void + { + $reader = new FilesystemImageSourceReader(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be an existing file'); + + $reader->read('/tmp/does-not-exist-preview.png'); + } + + public function testReadRejectsUnreadableSources(): void + { + $reader = new FilesystemImageSourceReader(); + $path = $this->createTemporaryFile('png', 'contents'); + chmod($path, 0); + + try { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Image source "%s" must be readable.', $path)); + + $reader->read($path); + } finally { + chmod($path, 0644); + } + } +} diff --git a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php index eda85dc..6d8a145 100644 --- a/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php +++ b/tests/Unit/Pdf/FilesystemPdfImageEmbedderTest.php @@ -7,24 +7,108 @@ namespace LibreSign\XObjectTemplate\Tests\Unit\Pdf; +use LibreSign\XObjectTemplate\Pdf\EmbeddedPdfImage; use LibreSign\XObjectTemplate\Pdf\FilesystemPdfImageEmbedder; +use LibreSign\XObjectTemplate\Pdf\FilesystemImageSourceReaderInterface; +use LibreSign\XObjectTemplate\Pdf\ImageMetadataInspectorInterface; +use LibreSign\XObjectTemplate\Pdf\Jpeg\JpegPdfImageFactoryInterface; +use LibreSign\XObjectTemplate\Pdf\Png\PngPdfImageFactoryInterface; use LibreSign\XObjectTemplate\Tests\Support\PngFixtureFactory; +use LibreSign\XObjectTemplate\Tests\Support\UsesTemporaryFiles; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use ReflectionMethod; final class FilesystemPdfImageEmbedderTest extends TestCase { - /** @var list */ - private array $temporaryFiles = []; + use UsesTemporaryFiles; protected function tearDown(): void { - foreach ($this->temporaryFiles as $temporaryFile) { - @unlink($temporaryFile); - } + $this->tearDownTemporaryFiles(); + } + + public function testEmbedUsesInjectedCollaboratorsForJpegImages(): void + { + $expectedImage = new EmbeddedPdfImage(['Type' => '/Image'], 'jpeg-stream'); + $embedder = new FilesystemPdfImageEmbedder( + new class implements FilesystemImageSourceReaderInterface { + public function read(string $source): string + { + return 'jpeg-binary'; + } + }, + new class implements ImageMetadataInspectorInterface { + public function detect(string $contents, string $source): array + { + return [0 => 1, 1 => 1, 'mime' => 'image/jpeg']; + } + + public function resolveMimeType(array $imageInfo, string $source): string + { + return 'image/jpeg'; + } + }, + new class ($expectedImage) implements JpegPdfImageFactoryInterface { + public function __construct(private readonly EmbeddedPdfImage $expectedImage) + { + } + + public function create(string $contents, array $imageInfo): EmbeddedPdfImage + { + return $this->expectedImage; + } + }, + new class implements PngPdfImageFactoryInterface { + public function create(string $contents): EmbeddedPdfImage + { + throw new \RuntimeException('PNG factory should not be used for JPEG images.'); + } + }, + ); + + self::assertSame($expectedImage, $embedder->embed('/tmp/virtual-image.jpg')); + } + + public function testEmbedUsesInjectedCollaboratorsForPngImages(): void + { + $expectedImage = new EmbeddedPdfImage(['Type' => '/Image'], 'png-stream'); + $embedder = new FilesystemPdfImageEmbedder( + new class implements FilesystemImageSourceReaderInterface { + public function read(string $source): string + { + return 'not-a-real-png'; + } + }, + new class implements ImageMetadataInspectorInterface { + public function detect(string $contents, string $source): array + { + return [0 => 1, 1 => 1, 'mime' => 'image/png']; + } + + public function resolveMimeType(array $imageInfo, string $source): string + { + return 'image/png'; + } + }, + new class implements JpegPdfImageFactoryInterface { + public function create(string $contents, array $imageInfo): EmbeddedPdfImage + { + throw new \RuntimeException('JPEG factory should not be used for PNG images.'); + } + }, + new class ($expectedImage) implements PngPdfImageFactoryInterface { + public function __construct(private readonly EmbeddedPdfImage $expectedImage) + { + } + + public function create(string $contents): EmbeddedPdfImage + { + return $this->expectedImage; + } + }, + ); - $this->temporaryFiles = []; + self::assertSame($expectedImage, $embedder->embed('/tmp/virtual-image.png')); } public function testEmbedReturnsPredictorBackedImageForOpaqueRgbPng(): void @@ -78,6 +162,45 @@ public function testEmbedCreatesSoftMaskForRgbaPng(): void self::assertSame('/FlateDecode', $image->softMask->dictionary['Filter']); } + public function testEmbedSupportsOpaqueGrayscalePng(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 0, + scanlines: "\x00\x80", + )); + + $image = $embedder->embed($pngPath); + + self::assertSame('/DeviceGray', $image->dictionary['ColorSpace']); + self::assertSame(1, $image->dictionary['DecodeParms']['Colors']); + self::assertSame(8, $image->dictionary['DecodeParms']['BitsPerComponent']); + self::assertNull($image->softMask); + self::assertSame("\x00\x80", gzuncompress($image->stream)); + } + + public function testEmbedCreatesSoftMaskForGrayAlphaPng(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 4, + scanlines: "\x00\x80\x40", + )); + + $image = $embedder->embed($pngPath); + + self::assertSame('/DeviceGray', $image->dictionary['ColorSpace']); + self::assertNotNull($image->softMask); + self::assertSame(1, $image->softMask->dictionary['DecodeParms']['Colors']); + self::assertSame(8, $image->softMask->dictionary['BitsPerComponent']); + self::assertSame("\x00\x80", gzuncompress($image->stream)); + self::assertSame("\x00\x40", gzuncompress($image->softMask->stream)); + } + #[DataProvider('rgbaFilterProvider')] public function testEmbedSupportsAllRgbaPredictorFilters(int $filterType): void { @@ -118,9 +241,15 @@ public function testEmbedRejectsUnsupportedPngHeaders(int $bitDepth, int $interl public function testEmbedRejectsUnsupportedFormats(): void { $embedder = new FilesystemPdfImageEmbedder(); - $gifPath = $this->createTemporaryFile('gif', 'GIF89a'); + $gifContents = base64_decode('R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=', true); + if ($gifContents === false) { + self::fail('Failed to decode the embedded GIF fixture.'); + } + + $gifPath = $this->createTemporaryFile('gif', $gifContents); $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported image format "image/gif".'); $embedder->embed($gifPath); } @@ -151,8 +280,11 @@ public function testEmbedSupportsJpegStreams(): void $image = $embedder->embed($jpegPath); + self::assertSame('/XObject', $image->dictionary['Type']); + self::assertSame('/Image', $image->dictionary['Subtype']); self::assertSame('/DCTDecode', $image->dictionary['Filter']); self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + self::assertSame(8, $image->dictionary['BitsPerComponent']); self::assertSame(1, $image->dictionary['Width']); self::assertSame(1, $image->dictionary['Height']); self::assertSame($jpegContents, $image->stream); @@ -164,7 +296,7 @@ public function testEmbedRejectsUnreadableFiles(): void $embedder = new FilesystemPdfImageEmbedder(); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('must be a readable file'); + $this->expectExceptionMessage('must be an existing file'); $embedder->embed('/tmp/does-not-exist-preview.png'); } @@ -196,47 +328,116 @@ public function testEmbedRejectsUnsupportedRowFilters(): void $embedder->embed($pngPath); } - public function testUnfilterPngRowSupportsAverageFilterWithMultiPixelContext(): void + public function testEmbedRejectsTrailingDataAfterIendChunk(): void { $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngRow'); - - $decodedRow = $method->invoke( - $embedder, - 3, - "\x55\x5a\x5f\x64\x3c\x3c\x3c\x3c", - "\x0a\x14\x1e\x28\x32\x3c\x46\x50", - 4, + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ); + $trailingDataPath = $this->createTemporaryFile( + 'png', + $png . PngFixtureFactory::createChunk('tEXt', 'tail'), ); - self::assertSame("\x5a\x64\x6e\x78\x82\x8c\x96\xa0", $decodedRow); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG data after IEND is not supported.'); + + $embedder->embed($trailingDataPath); } - public function testUnfilterPngRowSupportsPaethFilterWithMultiPixelContext(): void + public function testEmbedRejectsUnsupportedPngCompressionAndFilterMethods(): void { $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'unfilterPngRow'); - - $decodedRow = $method->invoke( - $embedder, - 4, - "\x0a\x0a\x0a\x0a\x0a\x14\x1e\x28", - "\x0a\x14\x1e\x28\x3c\x46\x50\x5a", - 4, - ); + $compressionPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + compression: 1, + )); + + try { + $embedder->embed($compressionPath); + self::fail('Expected unsupported compression to be rejected.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('Unsupported PNG compression or filter method.', $exception->getMessage()); + } + + $filterPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + filter: 1, + )); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported PNG compression or filter method.'); + + $embedder->embed($filterPath); + } + + public function testEmbedRejectsUnsupportedPngColorTypes(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 3, + scanlines: "\x00\x00", + )); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported PNG color type 3.'); + + $embedder->embed($pngPath); + } + + public function testEmbedConcatenatesMultipleIdatChunks(): void + { + $embedder = new FilesystemPdfImageEmbedder(); + $compressed = PngFixtureFactory::compressScanlines("\x00\xff\x00\x00"); + $splitAt = intdiv(strlen($compressed), 2); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createPngFromCompressedIdatChunks( + width: 1, + height: 1, + colorType: 2, + idatChunks: [ + substr($compressed, 0, $splitAt), + substr($compressed, $splitAt), + ], + )); + + $image = $embedder->embed($pngPath); - self::assertSame("\x14\x1e\x28\x32\x46\x5a\x6e\x82", $decodedRow); + self::assertSame($compressed, $image->stream); } - public function testPaethPredictorSelectsExpectedNeighborAcrossTieCases(): void + public function testEmbedSeparatesMultiRowRgbaPixelsIntoColorAndAlphaStreams(): void { $embedder = new FilesystemPdfImageEmbedder(); - $method = new ReflectionMethod($embedder, 'paethPredictor'); + $pngPath = $this->createTemporaryFile('png', PngFixtureFactory::createRgbaPngFromPixelRenderer( + width: 2, + height: 2, + pixelRenderer: static fn (int $x, int $y): array => match ([$x, $y]) { + [0, 0] => [255, 0, 0, 64], + [1, 0] => [0, 255, 0, 128], + [0, 1] => [0, 0, 255, 192], + default => [255, 255, 255, 255], + }, + )); + + $image = $embedder->embed($pngPath); - self::assertSame(20, $method->invoke($embedder, 20, 20, 10)); - self::assertSame(50, $method->invoke($embedder, 50, 10, 20)); - self::assertSame(50, $method->invoke($embedder, 10, 50, 20)); - self::assertSame(20, $method->invoke($embedder, 10, 30, 20)); + self::assertNotNull($image->softMask); + self::assertSame( + "\x00\xff\x00\x00\x00\xff\x00\x00\x00\x00\xff\xff\xff\xff", + gzuncompress($image->stream), + ); + self::assertSame("\x00\x40\x80\x00\xc0\xff", gzuncompress($image->softMask->stream)); } /** @@ -267,19 +468,4 @@ public static function unsupportedPngHeaderProvider(): iterable '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; - } } diff --git a/tests/Unit/Pdf/ImageMetadataInspectorTest.php b/tests/Unit/Pdf/ImageMetadataInspectorTest.php new file mode 100644 index 0000000..757242b --- /dev/null +++ b/tests/Unit/Pdf/ImageMetadataInspectorTest.php @@ -0,0 +1,62 @@ +detect($png, 'fixture.png'); + + self::assertSame(1, $imageInfo[0]); + self::assertSame(1, $imageInfo[1]); + self::assertSame('image/png', $inspector->resolveMimeType($imageInfo, 'fixture.png')); + } + + public function testDetectRejectsUnknownBinaryPayloads(): void + { + $inspector = new ImageMetadataInspector(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to detect the image format for "fixture.bin".'); + + $inspector->detect('not-an-image', 'fixture.bin'); + } + + public function testResolveMimeTypeRejectsMissingMimeKey(): void + { + $inspector = new ImageMetadataInspector(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Image metadata for "fixture.png" does not expose a mime type.'); + + $inspector->resolveMimeType([0 => 1, 1 => 1], 'fixture.png'); + } + + public function testResolveMimeTypeRejectsNonStringMimeValues(): void + { + $inspector = new ImageMetadataInspector(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Image metadata for "fixture.png" must expose the mime type as a string.'); + + $inspector->resolveMimeType(['mime' => 123], 'fixture.png'); + } +} diff --git a/tests/Unit/Pdf/Jpeg/JpegPdfImageFactoryTest.php b/tests/Unit/Pdf/Jpeg/JpegPdfImageFactoryTest.php new file mode 100644 index 0000000..ba7d557 --- /dev/null +++ b/tests/Unit/Pdf/Jpeg/JpegPdfImageFactoryTest.php @@ -0,0 +1,74 @@ +create('jpeg-binary', [0 => 4, 1 => 3, 'channels' => $channels]); + + self::assertSame($expectedColorSpace, $image->dictionary['ColorSpace']); + self::assertSame(4, $image->dictionary['Width']); + self::assertSame(3, $image->dictionary['Height']); + self::assertSame('jpeg-binary', $image->stream); + } + + public function testCreateRejectsMissingDimensions(): void + { + $factory = new JpegPdfImageFactory(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('JPEG metadata must expose width and height.'); + + $factory->create('jpeg-binary', ['channels' => 3]); + } + + public function testCreateRejectsMissingWidthOrHeightIndividually(): void + { + $factory = new JpegPdfImageFactory(); + + foreach ([[1 => 3, 'channels' => 3], [0 => 4, 'channels' => 3]] as $metadata) { + try { + $factory->create('jpeg-binary', $metadata); + self::fail('Expected missing JPEG dimensions to be rejected.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('JPEG metadata must expose width and height.', $exception->getMessage()); + } + } + } + + public function testCreateDefaultsMissingChannelMetadataToRgb(): void + { + $factory = new JpegPdfImageFactory(); + + $image = $factory->create('jpeg-binary', [0 => 4, 1 => 3]); + + self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + } + + /** + * @return iterable + */ + public static function jpegChannelProvider(): iterable + { + yield 'grayscale' => ['channels' => 1, 'expectedColorSpace' => '/DeviceGray']; + yield 'rgb default' => ['channels' => 3, 'expectedColorSpace' => '/DeviceRGB']; + yield 'cmyk' => ['channels' => 4, 'expectedColorSpace' => '/DeviceCMYK']; + yield 'invalid channel metadata defaults to rgb' => ['channels' => '4', 'expectedColorSpace' => '/DeviceRGB']; + } +} diff --git a/tests/Unit/Pdf/PhpWarningToExceptionConverterTest.php b/tests/Unit/Pdf/PhpWarningToExceptionConverterTest.php new file mode 100644 index 0000000..c5828c2 --- /dev/null +++ b/tests/Unit/Pdf/PhpWarningToExceptionConverterTest.php @@ -0,0 +1,42 @@ +run(static fn (): string => 'ok', 'unused')); + } + + public function testRunConvertsWarningsToInvalidArgumentExceptions(): void + { + $converter = new PhpWarningToExceptionConverter(); + + try { + $converter->run( + static function (): void { + trigger_error('warning-from-test', E_USER_WARNING); + }, + 'converted message', + ); + self::fail('Expected the warning to be converted into an exception.'); + } catch (\InvalidArgumentException $exception) { + self::assertSame('converted message', $exception->getMessage()); + self::assertSame(0, $exception->getCode()); + self::assertInstanceOf(ErrorException::class, $exception->getPrevious()); + self::assertSame(0, $exception->getPrevious()?->getCode()); + } + } +} diff --git a/tests/Unit/Pdf/Png/PngColorTypeDescriptionTest.php b/tests/Unit/Pdf/Png/PngColorTypeDescriptionTest.php new file mode 100644 index 0000000..687f355 --- /dev/null +++ b/tests/Unit/Pdf/Png/PngColorTypeDescriptionTest.php @@ -0,0 +1,66 @@ +colorSpace); + self::assertSame($colorCount, $description->colorCount); + self::assertSame($bytesPerPixel, $description->bytesPerPixel); + self::assertSame($hasAlpha, $description->hasAlpha); + } + + /** + * @return iterable + */ + public static function provideSupportedColorLayouts(): iterable + { + yield 'grayscale' => ['/DeviceGray', 1, 1, false]; + yield 'rgb' => ['/DeviceRGB', 3, 3, false]; + yield 'grayscale with alpha' => ['/DeviceGray', 1, 2, true]; + yield 'rgb with alpha' => ['/DeviceRGB', 3, 4, true]; + } + + #[DataProvider('provideInvalidColorLayouts')] + public function testConstructorRejectsInvalidColorLayouts( + int $colorCount, + int $bytesPerPixel, + bool $hasAlpha, + string $message, + ): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + new PngColorTypeDescription('/DeviceRGB', $colorCount, $bytesPerPixel, $hasAlpha); + } + + /** + * @return iterable + */ + public static function provideInvalidColorLayouts(): iterable + { + yield 'unsupported color count' => [2, 2, false, 'PNG color count must be 1 or 3.']; + yield 'non-positive bytes per pixel' => [1, 0, false, 'PNG bytes per pixel must be positive.']; + yield 'opaque layout mismatch' => [1, 2, false, 'PNG color layout is inconsistent.']; + yield 'alpha layout mismatch' => [3, 3, true, 'PNG color layout is inconsistent.']; + } +} diff --git a/tests/Unit/Pdf/Png/PngParserTest.php b/tests/Unit/Pdf/Png/PngParserTest.php new file mode 100644 index 0000000..3ca591e --- /dev/null +++ b/tests/Unit/Pdf/Png/PngParserTest.php @@ -0,0 +1,175 @@ +parse($png); + + self::assertSame(1, $parsed->width); + self::assertSame(1, $parsed->height); + self::assertSame(2, $parsed->colorType); + self::assertSame($compressed, $parsed->idat); + } + + public function testParseRejectsInvalidSignature(): void + { + $parser = new PngParser(); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PNG signature.'); + + $parser->parse('BROKEN!!' . substr($png, 8)); + } + + public function testParseRejectsTrailingDataAfterIendChunk(): void + { + $parser = new PngParser(); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ) . PngFixtureFactory::createChunk('tEXt', 'tail'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG data after IEND is not supported.'); + + $parser->parse($png); + } + + public function testParseRejectsTrailingBytesAfterIend(): void + { + $parser = new PngParser(); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ) . "\x00\x00\x00"; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG data after IEND is not supported.'); + + $parser->parse($png); + } + + public function testReadChunkRejectsTruncatedLengthField(): void + { + $parser = new PngParser(); + $offset = 0; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PNG chunk length.'); + + $parser->readChunk("\x00\x00\x00", $offset); + } + + public function testParseChunkLengthPreservesAllFourBytes(): void + { + $parser = new PngParser(); + + self::assertSame(0x01020304, $parser->parseChunkLength("\x01\x02\x03\x04")); + } + + public function testParseRejectsMissingMetadataWhenIhdrChunkIsAbsent(): void + { + $parser = new PngParser(); + $png = "\x89PNG\r\n\x1a\n" + . PngFixtureFactory::createChunk('IDAT', PngFixtureFactory::compressScanlines("\x00\xff\x00\x00")) + . PngFixtureFactory::createChunk('IEND', ''); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG metadata is incomplete.'); + + $parser->parse($png); + } + + public function testParseRejectsMissingImageDataWhenIdatChunkIsAbsent(): void + { + $parser = new PngParser(); + $ihdr = pack('NNCCCCC', 1, 1, 8, 2, 0, 0, 0); + $png = "\x89PNG\r\n\x1a\n" + . PngFixtureFactory::createChunk('IHDR', $ihdr) + . PngFixtureFactory::createChunk('IEND', ''); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG image data is missing.'); + + $parser->parse($png); + } + + public function testReadChunkRejectsInvalidChunkTypeLength(): void + { + $parser = new PngParser(); + $offset = 0; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PNG chunk type.'); + + $parser->readChunk(pack('N', 0) . 'ABC', $offset); + } + + public function testReadChunkRejectsTruncatedChunkPayload(): void + { + $parser = new PngParser(); + $offset = 0; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG chunk data is truncated.'); + + $parser->readChunk(pack('N', 1) . 'IHDR', $offset); + } + + public function testParseHeaderRejectsUnexpectedHeaderLength(): void + { + $parser = new PngParser(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to parse the PNG IHDR chunk.'); + + $parser->parseHeader('short-header'); + } + + public function testParseRejectsMissingTrailerChunkWhenTrailingBytesAreTooShort(): void + { + $parser = new PngParser(); + $png = PngFixtureFactory::createPng( + width: 1, + height: 1, + colorType: 2, + scanlines: "\x00\xff\x00\x00", + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG trailer chunk is missing.'); + + $parser->parse(substr($png, 0, -12) . "\x00\x00\x00"); + } +} diff --git a/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php b/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php new file mode 100644 index 0000000..f767cfa --- /dev/null +++ b/tests/Unit/Pdf/Png/PngPdfImageFactoryTest.php @@ -0,0 +1,88 @@ +create('not-a-real-png'); + + self::assertSame('/DeviceRGB', $image->dictionary['ColorSpace']); + self::assertSame('opaque-idat', $image->stream); + self::assertNull($image->softMask); + } + + public function testCreateUsesInjectedScanlineUnfiltererForAlphaImages(): void + { + $factory = new PngPdfImageFactory( + new class implements PngParserInterface { + public function parse(string $contents): ParsedPngImage + { + return new ParsedPngImage(1, 1, 6, 'ignored-compressed-idat'); + } + }, + new class implements PngScanlineUnfiltererInterface { + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + return ["\xff\x00\x00\x80"]; + } + }, + ); + + $image = $factory->create('not-a-real-png'); + + self::assertNotNull($image->softMask); + self::assertSame("\x00\xff\x00\x00", gzuncompress($image->stream)); + self::assertSame("\x00\x80", gzuncompress($image->softMask->stream)); + } + + public function testCreateRejectsUnsupportedColorTypes(): void + { + $factory = new PngPdfImageFactory( + new class implements PngParserInterface { + public function parse(string $contents): ParsedPngImage + { + return new ParsedPngImage(1, 1, 3, 'opaque-idat'); + } + }, + new class implements PngScanlineUnfiltererInterface { + public function unfilter(string $idat, int $height, int $rowLength, int $bytesPerPixel): array + { + throw new \RuntimeException('Unsupported color types should fail before unfiltering.'); + } + }, + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported PNG color type 3.'); + + $factory->create('not-a-real-png'); + } +} diff --git a/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php b/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php new file mode 100644 index 0000000..111ea11 --- /dev/null +++ b/tests/Unit/Pdf/Png/PngScanlineUnfiltererTest.php @@ -0,0 +1,201 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG image data could not be decompressed.'); + + $unfilterer->unfilter('not-compressed', 1, 1, 1); + } + + public function testUnfilterUsesInjectedWarningConverterResult(): void + { + $unfilterer = new PngScanlineUnfilterer(new class implements WarningToExceptionConverterInterface { + public function run(callable $operation, string $message): mixed + { + return "\x00\x7f"; + } + }); + + self::assertSame(["\x7f"], $unfilterer->unfilter('ignored-idat', 1, 1, 1)); + } + + public function testUnfilterRejectsMissingRowFilterBytes(): void + { + $unfilterer = new PngScanlineUnfilterer(); + $compressed = gzcompress(''); + if (!is_string($compressed)) { + self::fail('Failed to compress empty scanlines fixture.'); + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG scanlines are truncated.'); + + $unfilterer->unfilter($compressed, 1, 1, 1); + } + + public function testUnfilterRejectsMissingRowPayloadBytes(): void + { + $unfilterer = new PngScanlineUnfilterer(); + $compressed = gzcompress("\x00"); + if (!is_string($compressed)) { + self::fail('Failed to compress truncated row scanlines fixture.'); + } + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PNG row data is truncated.'); + + $unfilterer->unfilter($compressed, 1, 1, 1); + } + + public function testUnfilterRowSupportsSubFilterWithMultiPixelContext(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 1, + "\x05\x06\x02\x03", + "\x00\x00\x00\x00", + 2, + ); + + self::assertSame("\x05\x06\x07\x09", $decodedRow); + } + + public function testUnfilterRowSupportsUpFilterWithPriorRowContext(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 2, + "\x05\x06\x07\x08", + "\x01\x02\x03\x04", + 2, + ); + + self::assertSame("\x06\x08\x0a\x0c", $decodedRow); + } + + public function testUnfilterRowSupportsAverageFilterWithMultiPixelContext(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 3, + "\x55\x5a\x5f\x64\x3c\x3c\x3c\x3c", + "\x0a\x14\x1e\x28\x32\x3c\x46\x50", + 4, + ); + + self::assertSame("\x5a\x64\x6e\x78\x82\x8c\x96\xa0", $decodedRow); + } + + public function testUnfilterRowSupportsPaethFilterWithMultiPixelContext(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 4, + "\x0a\x0a\x0a\x0a\x0a\x14\x1e\x28", + "\x0a\x14\x1e\x28\x3c\x46\x50\x5a", + 4, + ); + + self::assertSame("\x14\x1e\x28\x32\x46\x5a\x6e\x82", $decodedRow); + } + + public function testUnfilterRowUsesPreviousUpperLeftAtBoundary(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 4, + "\xff\x00", + "\x01\x01", + 1, + ); + + self::assertSame('0000', bin2hex($decodedRow)); + } + + public function testUnfilterRowUsesZeroUpperLeftFallbackForFirstByte(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + $decodedRow = $unfilterer->unfilterRow( + 4, + "\x00", + "\x01", + 1, + ); + + self::assertSame("\x01", $decodedRow); + } + + public function testPaethPredictorSelectsExpectedNeighborAcrossTieCases(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + self::assertSame(20, $unfilterer->paethPredictor(20, 20, 10)); + self::assertSame(50, $unfilterer->paethPredictor(50, 10, 20)); + self::assertSame(50, $unfilterer->paethPredictor(10, 50, 20)); + self::assertSame(20, $unfilterer->paethPredictor(10, 30, 20)); + self::assertSame(0, $unfilterer->paethPredictor(0, 3, 2)); + self::assertSame(3, $unfilterer->paethPredictor(0, 3, 1)); + } + + public function testPaethPredictorMatchesReferenceImplementationAcrossRepresentativeRange(): void + { + $unfilterer = new PngScanlineUnfilterer(); + + for ($left = 0; $left <= 15; $left++) { + for ($above = 0; $above <= 15; $above++) { + for ($upperLeft = 0; $upperLeft <= 15; $upperLeft++) { + self::assertSame( + $this->referencePaethPredictor($left, $above, $upperLeft), + $unfilterer->paethPredictor($left, $above, $upperLeft), + sprintf( + 'Failed for left=%d, above=%d, upperLeft=%d.', + $left, + $above, + $upperLeft, + ), + ); + } + } + } + } + + private function referencePaethPredictor(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/tests/Unit/Pdf/SinglePagePdfExporterTest.php b/tests/Unit/Pdf/SinglePagePdfExporterTest.php index 25226ac..6f31680 100644 --- a/tests/Unit/Pdf/SinglePagePdfExporterTest.php +++ b/tests/Unit/Pdf/SinglePagePdfExporterTest.php @@ -13,6 +13,7 @@ use LibreSign\XObjectTemplate\Pdf\SinglePagePdfExporter; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; final class SinglePagePdfExporterTest extends TestCase { @@ -308,6 +309,54 @@ public function embed(string $source): EmbeddedPdfImage self::assertStringContainsString($expectedFormStreamFragment, $pdf); } + public function testSerializeValueFormatsNumbersListsAndRawPdfValues(): void + { + $exporter = new SinglePagePdfExporter(); + + self::assertSame('3', $this->invokeExporterMethod($exporter, 'serializeValue', 3)); + self::assertSame('12.5', $this->invokeExporterMethod($exporter, 'serializeValue', 12.5)); + self::assertSame( + '[/Name 2 0 R (text) true 3]', + $this->invokeExporterMethod($exporter, 'serializeValue', ['/Name', '2 0 R', 'text', true, 3]), + ); + } + + public function testRenderDocumentSortsObjectsAndRejectsReservedGaps(): void + { + $exporter = new SinglePagePdfExporter(); + $rendered = $this->invokeExporterMethod($exporter, 'renderDocument', [ + 2 => '<< /Type /Pages /Count 0 >>', + 1 => '<< /Type /Catalog /Pages 2 0 R >>', + ], 1); + + self::assertStringContainsString( + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj", + (string) $rendered, + ); + self::assertStringContainsString("xref\n0 3\n", $rendered); + self::assertStringContainsString( + sprintf( + "0000000000 65535 f \n%010d 00000 n \n%010d 00000 n \n", + strpos((string) $rendered, "1 0 obj\n"), + strpos((string) $rendered, "2 0 obj\n"), + ), + (string) $rendered, + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PDF object 2 was reserved but not written.'); + + $this->invokeExporterMethod($exporter, 'renderDocument', [1 => '<< /Type /Catalog >>', 2 => null], 1); + } + + #[DataProvider('rawPdfValueProvider')] + public function testIsRawPdfValueMatchesOnlyPdfNamesAndReferences(string $value, bool $expected): void + { + $exporter = new SinglePagePdfExporter(); + + self::assertSame($expected, $this->invokeExporterMethod($exporter, 'isRawPdfValue', $value)); + } + #[DataProvider('invalidCompileResultProvider')] public function testExportRejectsInvalidCompileResults(CompileResult $result, string $expectedMessage): void { @@ -433,4 +482,23 @@ public static function invalidCompileResultProvider(): iterable 'expectedMessage' => 'Image resource "Im0" must expose a non-empty Source.', ]; } + + /** + * @return iterable + */ + public static function rawPdfValueProvider(): iterable + { + yield 'name' => ['value' => '/Helvetica', 'expected' => true]; + yield 'reference' => ['value' => '12 0 R', 'expected' => true]; + yield 'missing start anchor' => ['value' => 'prefix 12 0 R', 'expected' => false]; + yield 'missing end anchor' => ['value' => '12 0 R suffix', 'expected' => false]; + yield 'literal string' => ['value' => 'hello', 'expected' => false]; + } + + private function invokeExporterMethod(SinglePagePdfExporter $exporter, string $method, mixed ...$arguments): mixed + { + $reflectionMethod = new ReflectionMethod($exporter, $method); + + return $reflectionMethod->invoke($exporter, ...$arguments); + } } diff --git a/tests/Unit/Pdf/StandardFontMetricsTest.php b/tests/Unit/Pdf/StandardFontMetricsTest.php index 57b845c..5532025 100644 --- a/tests/Unit/Pdf/StandardFontMetricsTest.php +++ b/tests/Unit/Pdf/StandardFontMetricsTest.php @@ -12,6 +12,15 @@ final class StandardFontMetricsTest extends TestCase { + public function testMeasureStringReturnsZeroForEmptyTextAndNonPositiveSizes(): void + { + $metrics = new StandardFontMetrics(); + + self::assertSame(0.0, $metrics->measureString('F1', 10.0, '')); + self::assertSame(0.0, $metrics->measureString('F1', 0.0, 'abc')); + self::assertSame(0.0, $metrics->measureString('F1', -5.0, 'abc')); + } + public function testMeasureStringDistinguishesNarrowAndWideGlyphsInProportionalFonts(): void { $metrics = new StandardFontMetrics(); @@ -28,8 +37,34 @@ public function testMeasureStringUsesFixedWidthMetricsForCourierFonts(): void $narrow = $metrics->measureString('F5', 10.0, 'iiii'); $wide = $metrics->measureString('F5', 10.0, 'WWWW'); + $obliqueWide = $metrics->measureString('F6', 10.0, 'WWWW'); self::assertEqualsWithDelta($narrow, $wide, 0.0001); + self::assertEqualsWithDelta($wide, $obliqueWide, 0.0001); + } + + public function testMeasureStringUsesTimesMetricsForTimesAliases(): void + { + $metrics = new StandardFontMetrics(); + + self::assertSame( + $metrics->measureString('F3', 10.0, 'A'), + $metrics->measureString('F4', 10.0, 'A'), + ); + self::assertNotSame( + $metrics->measureString('F1', 10.0, 'A'), + $metrics->measureString('F3', 10.0, 'A'), + ); + } + + public function testMeasureStringUsesExpectedBuiltInGlyphWidths(): void + { + $metrics = new StandardFontMetrics(); + + self::assertEqualsWithDelta(0.3335, $metrics->measureString('F1', 0.5, 'A'), 0.0001); + self::assertEqualsWithDelta(0.667, $metrics->measureString('F1', 1.0, 'A'), 0.0001); + self::assertEqualsWithDelta(6.67, $metrics->measureString('F1', 10.0, 'A'), 0.0001); + self::assertEqualsWithDelta(7.22, $metrics->measureString('F3', 10.0, 'A'), 0.0001); } public function testMeasureStringFallsBackToReasonableWidthForUnknownGlyphs(): void @@ -38,4 +73,16 @@ public function testMeasureStringFallsBackToReasonableWidthForUnknownGlyphs(): v self::assertGreaterThan(0.0, $metrics->measureString('F1', 10.0, "😀")); } + + public function testMeasureStringSplitsUnicodeCharactersAndHandlesInvalidUtf8(): void + { + $metrics = new StandardFontMetrics(); + + self::assertEqualsWithDelta( + $metrics->measureString('F1', 10.0, 'A') + $metrics->measureString('F1', 10.0, "😀"), + $metrics->measureString('F1', 10.0, "A😀"), + 0.0001, + ); + self::assertSame(0.0, $metrics->measureString('F1', 10.0, "\xc3\x28")); + } } diff --git a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php index 7ce473c..5b77f38 100644 --- a/tests/Unit/Pdf/TemplateDocumentBuilderTest.php +++ b/tests/Unit/Pdf/TemplateDocumentBuilderTest.php @@ -15,6 +15,7 @@ use LibreSign\XObjectTemplate\Pdf\TemplateDocumentBuilder; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; final class TemplateDocumentBuilderTest extends TestCase { @@ -483,6 +484,88 @@ public function testBuildContentStreamRendersFillOnlyStrokeOnlyAndEmptyDecoratio $this->assertStringNotContainsString(' RG', $emptyStream); } + public function testBuildDecorationCommandRequiresStrokeColorAndPositiveStrokeWidth(): void + { + $builder = new TemplateDocumentBuilder(); + $method = new ReflectionMethod($builder, 'buildDecorationCommand'); + + self::assertSame( + 'q\nQ', + $method->invoke( + $builder, + new LayoutDecoration(x: 1.0, y: 2.0, width: 5.0, height: 6.0, strokeWidth: 1.5), + ), + ); + self::assertSame( + 'q\nQ', + $method->invoke( + $builder, + new LayoutDecoration(x: 1.0, y: 2.0, width: 5.0, height: 6.0, strokeColor: '', strokeWidth: 1.5), + ), + ); + self::assertSame( + 'q\nQ', + $method->invoke($builder, new LayoutDecoration( + x: 1.0, + y: 2.0, + width: 5.0, + height: 6.0, + strokeColor: '#040506', + strokeWidth: 0.0, + )), + ); + } + + public function testBuildDecorationPathClampsRadiusByWidthHalfOnNarrowDecorations(): void + { + $builder = new TemplateDocumentBuilder(); + $method = new ReflectionMethod($builder, 'buildDecorationPath'); + + self::assertSame( + implode("\n", [ + '4.000000 0.000000 m', + '4.000000 0.000000 l', + '6.209139 0.000000 8.000000 1.790861 8.000000 4.000000 c', + '8.000000 16.000000 l', + '8.000000 18.209139 6.209139 20.000000 4.000000 20.000000 c', + '4.000000 20.000000 l', + '1.790861 20.000000 0.000000 18.209139 0.000000 16.000000 c', + '0.000000 4.000000 l', + '0.000000 1.790861 1.790861 0.000000 4.000000 0.000000 c', + 'h', + ]), + $method->invoke( + $builder, + new LayoutDecoration(x: 0.0, y: 0.0, width: 8.0, height: 20.0, borderRadius: 10.0), + ), + ); + } + + public function testBuildDecorationPathClampsRadiusByHeightHalfOnShortDecorations(): void + { + $builder = new TemplateDocumentBuilder(); + $method = new ReflectionMethod($builder, 'buildDecorationPath'); + + self::assertSame( + implode("\n", [ + '4.000000 0.000000 m', + '16.000000 0.000000 l', + '18.209139 0.000000 20.000000 1.790861 20.000000 4.000000 c', + '20.000000 4.000000 l', + '20.000000 6.209139 18.209139 8.000000 16.000000 8.000000 c', + '4.000000 8.000000 l', + '1.790861 8.000000 0.000000 6.209139 0.000000 4.000000 c', + '0.000000 4.000000 l', + '0.000000 1.790861 1.790861 0.000000 4.000000 0.000000 c', + 'h', + ]), + $method->invoke( + $builder, + new LayoutDecoration(x: 0.0, y: 0.0, width: 20.0, height: 8.0, borderRadius: 10.0), + ), + ); + } + public function testBuildResourcesExposesImageDictionaryAndCustomFontsFromDerivedBuilder(): void { $builder = (new TemplateDocumentBuilder())->withFontResources([ diff --git a/tests/Unit/XObjectTemplateCompilerTest.php b/tests/Unit/XObjectTemplateCompilerTest.php index 13b831d..5d6b625 100644 --- a/tests/Unit/XObjectTemplateCompilerTest.php +++ b/tests/Unit/XObjectTemplateCompilerTest.php @@ -129,4 +129,22 @@ public function testCompilerConstructorKeepsProvidedParserAndLayoutInstances(): self::assertSame($htmlParser, $htmlParserProperty->getValue($compiler)); self::assertSame($layoutEngine, $layoutEngineProperty->getValue($compiler)); } + + public function testCompilerConstructorKeepsProvidedContextInterpolatorInstance(): void + { + $contextInterpolatorClass = \LibreSign\XObjectTemplate\Html\HtmlContextInterpolator::class; + $contextInterpolator = new $contextInterpolatorClass(); + $compiler = (new \ReflectionClass(XObjectTemplateCompiler::class))->newInstanceArgs([ + null, + null, + null, + null, + null, + $contextInterpolator, + ]); + + $contextInterpolatorProperty = new ReflectionProperty($compiler, 'contextInterpolator'); + + self::assertSame($contextInterpolator, $contextInterpolatorProperty->getValue($compiler)); + } }