From 815e9d0fd33e01d584ef2305a24d8694276b6c50 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 27 Apr 2026 20:37:50 +0200 Subject: [PATCH 1/3] feat: Incomplete modern implementation of ISO 32000-2 PDF Enough features for the jonah and mnemo cases but far from complete --- .gitignore | 7 + .horde.yml | 2 +- composer.json | 29 +- phpunit.xml.dist | 36 +- src/Action.php | 10 + src/Annotation.php | 12 + src/Border.php | 84 ++ src/CellNextPosition.php | 12 + src/Color.php | 71 ++ src/ColorModel.php | 13 + src/ColorSpace.php | 12 + src/ContentStream.php | 13 + src/ContentStreamBuilder.php | 301 +++++ src/CoreFont.php | 120 ++ src/CustomPageFormat.php | 21 + src/Destination.php | 15 + src/DeviceCmyk.php | 18 + src/DeviceGray.php | 18 + src/DeviceRgb.php | 18 + src/DocumentCatalog.php | 48 + src/DocumentInfo.php | 17 + src/DocumentState.php | 13 + src/Font.php | 20 + src/FontEncoding.php | 13 + src/FontStyle.php | 13 + src/GoToAction.php | 17 + src/HeaderFooterHandler.php | 12 + src/ImageXObject.php | 20 + src/JpegParser.php | 51 + src/LayoutMode.php | 13 + src/LineCap.php | 12 + src/LineDashPattern.php | 31 + src/LinkAnnotation.php | 23 + src/Orientation.php | 11 + src/Page.php | 54 + src/PageFormat.php | 28 + src/PageTree.php | 29 + src/PdfException.php | 9 + src/PdfSerializer.php | 395 +++++++ src/PdfVersion.php | 18 + src/PdfWriter.php | 1008 +++++++++++++++++ src/PngParser.php | 130 +++ src/Rectangle.php | 54 + src/ResourceDictionary.php | 60 + src/ShapeStyle.php | 21 + src/TextAlign.php | 13 + src/Type1Font.php | 78 ++ src/Unit.php | 23 + src/UriAction.php | 17 + src/ViewerPreferences.php | 14 + src/WriterOptions.php | 65 ++ src/ZoomMode.php | 13 + test/Horde/Pdf/AllTests.php | 3 - test/Horde/Pdf/bootstrap.php | 3 - test/unit/AnnotationTest.php | 72 ++ test/unit/BorderTest.php | 77 ++ test/unit/ColorSpaceImplTest.php | 40 + test/unit/ColorTest.php | 77 ++ test/unit/ContentStreamBuilderTest.php | 305 +++++ test/unit/CoreFontTest.php | 119 ++ test/unit/DocumentCatalogTest.php | 169 +++ test/unit/DocumentInfoTest.php | 40 + test/unit/EnumTest.php | 165 +++ test/unit/FeatureParityTest.php | 857 ++++++++++++++ test/unit/HeaderFooterStylesPdf.php | 53 + test/unit/ImageXObjectTest.php | 30 + test/unit/JpegParserTest.php | 67 ++ test/unit/PageNumberFooter.php | 25 + test/unit/PdfSerializerTest.php | 431 +++++++ test/unit/PdfWriterParityTest.php | 541 +++++++++ test/unit/PdfWriterTest.php | 593 ++++++++++ test/unit/PngParserTest.php | 88 ++ test/unit/RectangleTest.php | 63 ++ test/unit/Type1FontTest.php | 115 ++ test/unit/ViewerPreferencesTest.php | 33 + test/unit/WriterOptionsTest.php | 116 ++ test/{Horde/Pdf => unit}/WriterTest.php | 145 +-- test/{Horde/Pdf => unit}/fixtures/20k_c1.txt | 0 test/{Horde/Pdf => unit}/fixtures/20k_c2.txt | 0 .../Pdf => unit}/fixtures/auto_break.pdf | Bin .../Pdf => unit}/fixtures/change_page.pdf | Bin .../fixtures/header_footer_styles.pdf | Bin .../fixtures/hello_world_compressed.pdf | Bin .../fixtures/hello_world_uncompressed.pdf | Bin .../Pdf => unit}/fixtures/horde-power1.png | Bin test/{Horde/Pdf => unit}/fixtures/links.pdf | Bin .../Pdf => unit}/fixtures/text_color.pdf | Bin .../Pdf => unit}/fixtures/underline.pdf | Bin 88 files changed, 7241 insertions(+), 141 deletions(-) create mode 100644 src/Action.php create mode 100644 src/Annotation.php create mode 100644 src/Border.php create mode 100644 src/CellNextPosition.php create mode 100644 src/Color.php create mode 100644 src/ColorModel.php create mode 100644 src/ColorSpace.php create mode 100644 src/ContentStream.php create mode 100644 src/ContentStreamBuilder.php create mode 100644 src/CoreFont.php create mode 100644 src/CustomPageFormat.php create mode 100644 src/Destination.php create mode 100644 src/DeviceCmyk.php create mode 100644 src/DeviceGray.php create mode 100644 src/DeviceRgb.php create mode 100644 src/DocumentCatalog.php create mode 100644 src/DocumentInfo.php create mode 100644 src/DocumentState.php create mode 100644 src/Font.php create mode 100644 src/FontEncoding.php create mode 100644 src/FontStyle.php create mode 100644 src/GoToAction.php create mode 100644 src/HeaderFooterHandler.php create mode 100644 src/ImageXObject.php create mode 100644 src/JpegParser.php create mode 100644 src/LayoutMode.php create mode 100644 src/LineCap.php create mode 100644 src/LineDashPattern.php create mode 100644 src/LinkAnnotation.php create mode 100644 src/Orientation.php create mode 100644 src/Page.php create mode 100644 src/PageFormat.php create mode 100644 src/PageTree.php create mode 100644 src/PdfException.php create mode 100644 src/PdfSerializer.php create mode 100644 src/PdfVersion.php create mode 100644 src/PdfWriter.php create mode 100644 src/PngParser.php create mode 100644 src/Rectangle.php create mode 100644 src/ResourceDictionary.php create mode 100644 src/ShapeStyle.php create mode 100644 src/TextAlign.php create mode 100644 src/Type1Font.php create mode 100644 src/Unit.php create mode 100644 src/UriAction.php create mode 100644 src/ViewerPreferences.php create mode 100644 src/WriterOptions.php create mode 100644 src/ZoomMode.php delete mode 100644 test/Horde/Pdf/AllTests.php delete mode 100644 test/Horde/Pdf/bootstrap.php create mode 100644 test/unit/AnnotationTest.php create mode 100644 test/unit/BorderTest.php create mode 100644 test/unit/ColorSpaceImplTest.php create mode 100644 test/unit/ColorTest.php create mode 100644 test/unit/ContentStreamBuilderTest.php create mode 100644 test/unit/CoreFontTest.php create mode 100644 test/unit/DocumentCatalogTest.php create mode 100644 test/unit/DocumentInfoTest.php create mode 100644 test/unit/EnumTest.php create mode 100644 test/unit/FeatureParityTest.php create mode 100644 test/unit/HeaderFooterStylesPdf.php create mode 100644 test/unit/ImageXObjectTest.php create mode 100644 test/unit/JpegParserTest.php create mode 100644 test/unit/PageNumberFooter.php create mode 100644 test/unit/PdfSerializerTest.php create mode 100644 test/unit/PdfWriterParityTest.php create mode 100644 test/unit/PdfWriterTest.php create mode 100644 test/unit/PngParserTest.php create mode 100644 test/unit/RectangleTest.php create mode 100644 test/unit/Type1FontTest.php create mode 100644 test/unit/ViewerPreferencesTest.php create mode 100644 test/unit/WriterOptionsTest.php rename test/{Horde/Pdf => unit}/WriterTest.php (63%) rename test/{Horde/Pdf => unit}/fixtures/20k_c1.txt (100%) rename test/{Horde/Pdf => unit}/fixtures/20k_c2.txt (100%) rename test/{Horde/Pdf => unit}/fixtures/auto_break.pdf (100%) rename test/{Horde/Pdf => unit}/fixtures/change_page.pdf (100%) rename test/{Horde/Pdf => unit}/fixtures/header_footer_styles.pdf (100%) rename test/{Horde/Pdf => unit}/fixtures/hello_world_compressed.pdf (100%) rename test/{Horde/Pdf => unit}/fixtures/hello_world_uncompressed.pdf (100%) rename test/{Horde/Pdf => unit}/fixtures/horde-power1.png (100%) rename test/{Horde/Pdf => unit}/fixtures/links.pdf (100%) rename test/{Horde/Pdf => unit}/fixtures/text_color.pdf (100%) rename test/{Horde/Pdf => unit}/fixtures/underline.pdf (100%) diff --git a/.gitignore b/.gitignore index 4e78ae3..01fdce2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,13 @@ nbproject/ # Ignore ALL config files conf.php +# Composer +/vendor/ +composer.lock + +# PHPUnit +.phpunit.cache/ + # Ignore testing files run-tests.log /test/*/*/*.diff diff --git a/.horde.yml b/.horde.yml index a6ca767..a1f0444 100644 --- a/.horde.yml +++ b/.horde.yml @@ -38,7 +38,7 @@ license: uri: http://www.horde.org/licenses/lgpl21 dependencies: required: - php: ^7.4 || ^8 + php: ^8.1 composer: horde/exception: ^3 horde/util: ^3 diff --git a/composer.json b/composer.json index c869636..732571f 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,4 @@ { - "minimum-stability": "dev", "name": "horde/pdf", "description": "PDF writer library", "type": "library", @@ -22,25 +21,31 @@ "role": "lead" } ], - "time": "2021-07-04", - "repositories": [ - { - "type": "composer", - "url": "https://horde-satis.maintaina.com/" - } - ], + "time": "2026-04-26", + "repositories": [], "require": { - "php": "^7.4 || ^8", - "horde/exception": "^3 || dev-FRAMEWORK_6_0", - "horde/util": "^3 || dev-FRAMEWORK_6_0" + "php": "^8.1", + "horde/exception": "^3", + "horde/util": "^3" }, "require-dev": {}, "suggest": { - "horde/test": "^3 || dev-FRAMEWORK_6_0" + "horde/test": "^3" }, "autoload": { "psr-0": { "Horde_Pdf": "lib/" + }, + "psr-4": { + "Horde\\Pdf\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "Horde\\Pdf\\Test\\": "test/" + } + }, + "config": { + "allow-plugins": {} } } \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f724485..90868eb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,12 +1,24 @@ - - - - test - - - - - lib - - - \ No newline at end of file + + + + + test/unit + + + + + lib + src + + + diff --git a/src/Action.php b/src/Action.php new file mode 100644 index 0000000..67f9f2a --- /dev/null +++ b/src/Action.php @@ -0,0 +1,10 @@ +left; + } + + public function hasRight(): bool + { + return $this->right; + } + + public function hasTop(): bool + { + return $this->top; + } + + public function hasBottom(): bool + { + return $this->bottom; + } + + public function hasAny(): bool + { + return $this->left || $this->right || $this->top || $this->bottom; + } + + public function isFull(): bool + { + return $this->left && $this->right && $this->top && $this->bottom; + } +} diff --git a/src/CellNextPosition.php b/src/CellNextPosition.php new file mode 100644 index 0000000..c590e43 --- /dev/null +++ b/src/CellNextPosition.php @@ -0,0 +1,12 @@ +space; + } + + public function toPdfFillString(): string + { + return match ($this->space) { + ColorModel::Rgb, ColorModel::Hex => sprintf('%.3F %.3F %.3F rg', $this->c1, $this->c2, $this->c3), + ColorModel::Cmyk => sprintf('%.3F %.3F %.3F %.3F k', $this->c1, $this->c2, $this->c3, $this->c4), + ColorModel::Gray => sprintf('%.3F g', $this->c1), + }; + } + + public function toPdfStrokeString(): string + { + return match ($this->space) { + ColorModel::Rgb, ColorModel::Hex => sprintf('%.3F %.3F %.3F RG', $this->c1, $this->c2, $this->c3), + ColorModel::Cmyk => sprintf('%.3F %.3F %.3F %.3F K', $this->c1, $this->c2, $this->c3, $this->c4), + ColorModel::Gray => sprintf('%.3F G', $this->c1), + }; + } +} diff --git a/src/ColorModel.php b/src/ColorModel.php new file mode 100644 index 0000000..c5b07c1 --- /dev/null +++ b/src/ColorModel.php @@ -0,0 +1,13 @@ + */ + private array $operators = []; + + private bool $inTextObject = false; + private int $graphicsStateDepth = 0; + + /** @var array */ + private array $fontMap = []; + + /** @var array */ + private array $imageMap = []; + + private int $fontCounter = 0; + private int $imageCounter = 0; + + /** @var array pdfName → local name (F1, F2, ...) */ + private array $fontNameIndex = []; + + /** @var array spl_object_id → local name (I1, I2, ...) */ + private array $imageNameIndex = []; + + // --- Graphics state --- + + public function save(): self + { + $this->operators[] = 'q'; + $this->graphicsStateDepth++; + return $this; + } + + public function restore(): self + { + if ($this->graphicsStateDepth <= 0) { + throw new PdfException('Unbalanced restore: no matching save'); + } + $this->operators[] = 'Q'; + $this->graphicsStateDepth--; + return $this; + } + + // --- Line style --- + + public function setLineWidth(float $width): self + { + $this->operators[] = sprintf('%.2F w', $width); + return $this; + } + + public function setLineCap(LineCap $cap): self + { + $this->operators[] = sprintf('%d J', $cap->value); + return $this; + } + + public function setDashPattern(LineDashPattern $pattern): self + { + $this->operators[] = $pattern->toPdfString(); + return $this; + } + + // --- Color --- + + public function setFillColor(Color $color): self + { + $this->operators[] = $color->toPdfFillString(); + return $this; + } + + public function setStrokeColor(Color $color): self + { + $this->operators[] = $color->toPdfStrokeString(); + return $this; + } + + // --- Path construction --- + + public function moveTo(float $x, float $y): self + { + $this->operators[] = sprintf('%.2F %.2F m', $x, $y); + return $this; + } + + public function lineTo(float $x, float $y): self + { + $this->operators[] = sprintf('%.2F %.2F l', $x, $y); + return $this; + } + + public function curveTo( + float $x1, + float $y1, + float $x2, + float $y2, + float $x3, + float $y3, + ): self { + $this->operators[] = sprintf( + '%.2F %.2F %.2F %.2F %.2F %.2F c', + $x1, + $y1, + $x2, + $y2, + $x3, + $y3, + ); + return $this; + } + + public function rect(float $x, float $y, float $w, float $h): self + { + $this->operators[] = sprintf('%.2F %.2F %.2F %.2F re', $x, $y, $w, $h); + return $this; + } + + public function closePath(): self + { + $this->operators[] = 'h'; + return $this; + } + + // --- Path painting --- + + public function stroke(): self + { + $this->operators[] = 'S'; + return $this; + } + + public function fill(): self + { + $this->operators[] = 'f'; + return $this; + } + + public function fillAndStroke(): self + { + $this->operators[] = 'B'; + return $this; + } + + public function clip(): self + { + $this->operators[] = 'W n'; + return $this; + } + + // --- Text --- + + public function beginText(): self + { + if ($this->inTextObject) { + throw new PdfException('Already inside a text object'); + } + $this->operators[] = 'BT'; + $this->inTextObject = true; + return $this; + } + + public function endText(): self + { + if (!$this->inTextObject) { + throw new PdfException('Not inside a text object'); + } + $this->operators[] = 'ET'; + $this->inTextObject = false; + return $this; + } + + public function setFont(Font $font, float $size): self + { + $localName = $this->registerFont($font); + $this->operators[] = sprintf('/%s %.2F Tf', $localName, $size); + return $this; + } + + public function showText(string $text): self + { + if (!$this->inTextObject) { + throw new PdfException('showText must be called inside a text object (between beginText/endText)'); + } + $this->operators[] = sprintf('(%s) Tj', self::escapeString($text)); + return $this; + } + + public function moveTextPosition(float $tx, float $ty): self + { + $this->operators[] = sprintf('%.2F %.2F Td', $tx, $ty); + return $this; + } + + public function setCharSpacing(float $spacing): self + { + $this->operators[] = sprintf('%.2F Tc', $spacing); + return $this; + } + + public function setWordSpacing(float $spacing): self + { + $this->operators[] = sprintf('%.2F Tw', $spacing); + return $this; + } + + // --- Images --- + + public function drawImage( + ImageXObject $image, + float $x, + float $y, + float $w, + float $h, + ): self { + $localName = $this->registerImage($image); + $this->operators[] = sprintf( + 'q %.2F 0 0 %.2F %.2F %.2F cm /%s Do Q', + $w, + $h, + $x, + $y, + $localName, + ); + return $this; + } + + // --- Build --- + + public function build(): ContentStream + { + if ($this->inTextObject) { + throw new PdfException('Unclosed text object at build time'); + } + + if ($this->graphicsStateDepth !== 0) { + throw new PdfException( + sprintf('Unbalanced graphics state: %d save(s) without matching restore', $this->graphicsStateDepth) + ); + } + + $resources = new ResourceDictionary(); + + foreach ($this->fontMap as $localName => $font) { + $resources->addFont($localName, $font); + } + + foreach ($this->imageMap as $localName => $image) { + $resources->addImage($localName, $image); + } + + $operators = implode("\n", $this->operators); + + return new ContentStream($operators, $resources); + } + + // --- Private --- + + private function registerFont(Font $font): string + { + $key = $font->pdfName(); + + if (isset($this->fontNameIndex[$key])) { + return $this->fontNameIndex[$key]; + } + + $localName = 'F' . (++$this->fontCounter); + $this->fontNameIndex[$key] = $localName; + $this->fontMap[$localName] = $font; + + return $localName; + } + + private function registerImage(ImageXObject $image): string + { + $id = spl_object_id($image); + + if (isset($this->imageNameIndex[$id])) { + return $this->imageNameIndex[$id]; + } + + $localName = 'I' . (++$this->imageCounter); + $this->imageNameIndex[$id] = $localName; + $this->imageMap[$localName] = $image; + + return $localName; + } + + private static function escapeString(string $s): string + { + return str_replace( + ['\\', '(', ')'], + ['\\\\', '\\(', '\\)'], + $s, + ); + } +} diff --git a/src/CoreFont.php b/src/CoreFont.php new file mode 100644 index 0000000..8022f72 --- /dev/null +++ b/src/CoreFont.php @@ -0,0 +1,120 @@ + 'Courier', + self::CourierBold => 'Courier-Bold', + self::CourierItalic => 'Courier-Oblique', + self::CourierBoldItalic => 'Courier-BoldOblique', + self::Helvetica => 'Helvetica', + self::HelveticaBold => 'Helvetica-Bold', + self::HelveticaItalic => 'Helvetica-Oblique', + self::HelveticaBoldItalic => 'Helvetica-BoldOblique', + self::Times => 'Times-Roman', + self::TimesBold => 'Times-Bold', + self::TimesItalic => 'Times-Italic', + self::TimesBoldItalic => 'Times-BoldItalic', + self::Symbol => 'Symbol', + self::ZapfDingbats => 'ZapfDingbats', + }; + } + + public function family(): string + { + return match ($this) { + self::Courier, self::CourierBold, self::CourierItalic, self::CourierBoldItalic => 'courier', + self::Helvetica, self::HelveticaBold, self::HelveticaItalic, self::HelveticaBoldItalic => 'helvetica', + self::Times, self::TimesBold, self::TimesItalic, self::TimesBoldItalic => 'times', + self::Symbol => 'symbol', + self::ZapfDingbats => 'zapfdingbats', + }; + } + + /** + * @return array Character widths keyed by character. + */ + public function widths(): array + { + $className = $this->legacyClassName(); + $instance = new $className(); + $all = $instance->getWidths(); + + return $all[$this->value]; + } + + private function legacyClassName(): string + { + return match ($this) { + self::Courier => 'Horde_Pdf_Font_Courier', + self::CourierBold => 'Horde_Pdf_Font_Courierb', + self::CourierItalic => 'Horde_Pdf_Font_Courieri', + self::CourierBoldItalic => 'Horde_Pdf_Font_Courierbi', + self::Helvetica => 'Horde_Pdf_Font_Helvetica', + self::HelveticaBold => 'Horde_Pdf_Font_Helveticab', + self::HelveticaItalic => 'Horde_Pdf_Font_Helveticai', + self::HelveticaBoldItalic => 'Horde_Pdf_Font_Helveticabi', + self::Times => 'Horde_Pdf_Font_Times', + self::TimesBold => 'Horde_Pdf_Font_Timesb', + self::TimesItalic => 'Horde_Pdf_Font_Timesi', + self::TimesBoldItalic => 'Horde_Pdf_Font_Timesbi', + self::Symbol => 'Horde_Pdf_Font_Symbol', + self::ZapfDingbats => 'Horde_Pdf_Font_Zapfdingbats', + }; + } +} diff --git a/src/CustomPageFormat.php b/src/CustomPageFormat.php new file mode 100644 index 0000000..5b9874f --- /dev/null +++ b/src/CustomPageFormat.php @@ -0,0 +1,21 @@ +width, $this->height]; + } +} diff --git a/src/Destination.php b/src/Destination.php new file mode 100644 index 0000000..2f55e42 --- /dev/null +++ b/src/Destination.php @@ -0,0 +1,15 @@ +pageTree = new PageTree(); + } + + public function addPage(Page $page): void + { + $this->pageTree->addPage($page); + } + + public function setInfo(DocumentInfo $info): void + { + $this->info = $info; + } + + public function setViewerPreferences(ViewerPreferences $prefs): void + { + $this->viewerPreferences = $prefs; + } + + public function pageTree(): PageTree + { + return $this->pageTree; + } + + public function info(): ?DocumentInfo + { + return $this->info; + } + + public function viewerPreferences(): ?ViewerPreferences + { + return $this->viewerPreferences; + } +} diff --git a/src/DocumentInfo.php b/src/DocumentInfo.php new file mode 100644 index 0000000..94e667f --- /dev/null +++ b/src/DocumentInfo.php @@ -0,0 +1,17 @@ + new DeviceGray(), + 4 => new DeviceCmyk(), + default => new DeviceRgb(), + }; + } +} diff --git a/src/LayoutMode.php b/src/LayoutMode.php new file mode 100644 index 0000000..3f28165 --- /dev/null +++ b/src/LayoutMode.php @@ -0,0 +1,13 @@ + $dashArray + */ + public function __construct( + public readonly array $dashArray, + public readonly float $dashPhase = 0.0, + ) {} + + public static function solid(): self + { + return new self([], 0.0); + } + + public function toPdfString(): string + { + $array = '[' . implode(' ', array_map( + static fn(float $v): string => sprintf('%.2F', $v), + $this->dashArray, + )) . ']'; + + return sprintf('%s %.2F d', $array, $this->dashPhase); + } +} diff --git a/src/LinkAnnotation.php b/src/LinkAnnotation.php new file mode 100644 index 0000000..fa6807e --- /dev/null +++ b/src/LinkAnnotation.php @@ -0,0 +1,23 @@ +rectangle; + } +} diff --git a/src/Orientation.php b/src/Orientation.php new file mode 100644 index 0000000..e4b8430 --- /dev/null +++ b/src/Orientation.php @@ -0,0 +1,11 @@ + */ + private array $contentStreams = []; + + /** @var array */ + private array $annotations = []; + + public function __construct( + public readonly Rectangle $mediaBox, + ) { + $this->resources = new ResourceDictionary(); + } + + public function addContentStream(ContentStream $stream): void + { + $this->contentStreams[] = $stream; + $this->resources->merge($stream->resources); + } + + public function addAnnotation(Annotation $annotation): void + { + $this->annotations[] = $annotation; + } + + public function resourceDictionary(): ResourceDictionary + { + return $this->resources; + } + + /** + * @return array + */ + public function contentStreams(): array + { + return $this->contentStreams; + } + + /** + * @return array + */ + public function annotations(): array + { + return $this->annotations; + } +} diff --git a/src/PageFormat.php b/src/PageFormat.php new file mode 100644 index 0000000..e5db0c6 --- /dev/null +++ b/src/PageFormat.php @@ -0,0 +1,28 @@ + [841.89, 1190.55], + self::A4 => [595.28, 841.89], + self::A5 => [420.94, 595.28], + self::Letter => [612.0, 792.0], + self::Legal => [612.0, 1008.0], + }; + } +} diff --git a/src/PageTree.php b/src/PageTree.php new file mode 100644 index 0000000..02df48b --- /dev/null +++ b/src/PageTree.php @@ -0,0 +1,29 @@ + */ + private array $pages = []; + + public function addPage(Page $page): void + { + $this->pages[] = $page; + } + + /** + * @return array + */ + public function pages(): array + { + return $this->pages; + } + + public function count(): int + { + return count($this->pages); + } +} diff --git a/src/PdfException.php b/src/PdfException.php new file mode 100644 index 0000000..65fb646 --- /dev/null +++ b/src/PdfException.php @@ -0,0 +1,9 @@ +pageTree(); + $pages = $pageTree->pages(); + + /** @var SplObjectStorage */ + $objectMap = new SplObjectStorage(); + + $objectNumber++; + $pageTreeObjNum = $objectNumber; + + $pageObjNums = []; + $streamObjNums = []; + foreach ($pages as $i => $page) { + $objectNumber++; + $pageObjNums[$i] = $objectNumber; + $objectNumber++; + $streamObjNums[$i] = $objectNumber; + $objectMap[$page] = $pageObjNums[$i]; + } + + $allFonts = []; + $allImages = []; + $fontObjNums = []; + $imageObjNums = []; + $pageResourceFontObjNums = []; + $pageResourceImageObjNums = []; + + foreach ($pages as $i => $page) { + $res = $page->resourceDictionary(); + $pageFontNums = []; + foreach ($res->fonts() as $localName => $font) { + $key = $font->pdfName(); + if (!isset($allFonts[$key])) { + $objectNumber++; + $allFonts[$key] = ['font' => $font, 'objNum' => $objectNumber]; + } + $pageFontNums[$localName] = $allFonts[$key]['objNum']; + } + $pageResourceFontObjNums[$i] = $pageFontNums; + + $pageImageNums = []; + foreach ($res->images() as $localName => $image) { + $key = spl_object_id($image); + if (!isset($allImages[$key])) { + $objectNumber++; + $allImages[$key] = ['image' => $image, 'objNum' => $objectNumber]; + if ($image->palette !== null) { + $objectNumber++; + $allImages[$key]['paletteObjNum'] = $objectNumber; + } + } + $pageImageNums[$localName] = $allImages[$key]['objNum']; + } + $pageResourceImageObjNums[$i] = $pageImageNums; + } + + $resourceDictObjNums = []; + foreach ($pages as $i => $page) { + $objectNumber++; + $resourceDictObjNums[$i] = $objectNumber; + } + + $objectNumber++; + $infoObjNum = $objectNumber; + + $objectNumber++; + $catalogObjNum = $objectNumber; + + $totalObjects = $objectNumber; + + $buffer .= $catalog->version->header() . "\n"; + $buffer .= "%\xE2\xE3\xCF\xD3\n"; + + foreach ($pages as $i => $page) { + $offsets[$pageObjNums[$i]] = strlen($buffer); + $buffer .= $pageObjNums[$i] . " 0 obj\n"; + $buffer .= "<mediaBox->toPdfArray() . "\n"; + $buffer .= "/Resources " . $resourceDictObjNums[$i] . " 0 R\n"; + + $annotations = $page->annotations(); + if (!empty($annotations)) { + $buffer .= '/Annots ['; + foreach ($annotations as $annot) { + $buffer .= $this->serializeAnnotation($annot, $objectMap); + } + $buffer .= "]\n"; + } + + $buffer .= "/Contents " . $streamObjNums[$i] . " 0 R>>\n"; + $buffer .= "endobj\n"; + + $streams = $page->contentStreams(); + $content = ''; + foreach ($streams as $stream) { + if ($content !== '') { + $content .= "\n"; + } + $content .= $stream->operators; + } + + $streamData = $this->compress ? @gzcompress($content) : false; + $useCompression = $streamData !== false && $this->compress; + if (!$useCompression) { + $streamData = $content; + } + + $offsets[$streamObjNums[$i]] = strlen($buffer); + $buffer .= $streamObjNums[$i] . " 0 obj\n"; + $filter = $useCompression ? '/Filter /FlateDecode ' : ''; + $buffer .= '<<' . $filter . '/Length ' . strlen($streamData) . ">>\n"; + $buffer .= "stream\n"; + $buffer .= $streamData . "\n"; + $buffer .= "endstream\n"; + $buffer .= "endobj\n"; + } + + foreach ($allFonts as $entry) { + $font = $entry['font']; + $objNum = $entry['objNum']; + $offsets[$objNum] = strlen($buffer); + $buffer .= $objNum . " 0 obj\n"; + $buffer .= "<pdfName() . "\n"; + $encoding = $font->encoding(); + if ($encoding === FontEncoding::WinAnsi) { + $buffer .= "/Encoding /WinAnsiEncoding\n"; + } + $buffer .= ">>\n"; + $buffer .= "endobj\n"; + } + + foreach ($allImages as $entry) { + $image = $entry['image']; + $objNum = $entry['objNum']; + $offsets[$objNum] = strlen($buffer); + $buffer .= $objNum . " 0 obj\n"; + $buffer .= "<width . "\n"; + $buffer .= "/Height " . $image->height . "\n"; + + if ($image->palette !== null) { + $paletteObjNum = $entry['paletteObjNum']; + $paletteEntries = (int) (strlen($image->palette) / 3) - 1; + $buffer .= "/ColorSpace [/Indexed /DeviceRGB " . $paletteEntries . " " . $paletteObjNum . " 0 R]\n"; + } else { + $buffer .= "/ColorSpace /" . $image->colorSpace->pdfName() . "\n"; + if ($image->colorSpace->pdfName() === 'DeviceCMYK') { + $buffer .= "/Decode [1 0 1 0 1 0 1 0]\n"; + } + } + + $buffer .= "/BitsPerComponent " . $image->bitsPerComponent . "\n"; + $buffer .= "/Filter /" . $image->filter . "\n"; + if ($image->decodeParms !== null) { + $buffer .= $image->decodeParms . "\n"; + } + if ($image->transparency !== null) { + $trns = ''; + foreach ($image->transparency as $t) { + $trns .= $t . ' ' . $t . ' '; + } + $buffer .= "/Mask [" . $trns . "]\n"; + } + $buffer .= "/Length " . strlen($image->data) . ">>\n"; + $buffer .= "stream\n"; + $buffer .= $image->data . "\n"; + $buffer .= "endstream\n"; + $buffer .= "endobj\n"; + + if ($image->palette !== null) { + $paletteObjNum = $entry['paletteObjNum']; + $offsets[$paletteObjNum] = strlen($buffer); + $buffer .= $paletteObjNum . " 0 obj\n"; + $palData = $this->compress ? @gzcompress($image->palette) : false; + $usePalCompression = $palData !== false && $this->compress; + if (!$usePalCompression) { + $palData = $image->palette; + } + $palFilter = $usePalCompression ? '/Filter /FlateDecode ' : ''; + $buffer .= '<<' . $palFilter . '/Length ' . strlen($palData) . ">>\n"; + $buffer .= "stream\n"; + $buffer .= $palData . "\n"; + $buffer .= "endstream\n"; + $buffer .= "endobj\n"; + } + } + + foreach ($pages as $i => $page) { + $offsets[$resourceDictObjNums[$i]] = strlen($buffer); + $buffer .= $resourceDictObjNums[$i] . " 0 obj\n"; + $buffer .= "< $objNum) { + $buffer .= " /" . $localName . " " . $objNum . " 0 R"; + } + $buffer .= " >>\n"; + } + if (!empty($pageResourceImageObjNums[$i])) { + $buffer .= "/XObject <<"; + foreach ($pageResourceImageObjNums[$i] as $localName => $objNum) { + $buffer .= " /" . $localName . " " . $objNum . " 0 R"; + } + $buffer .= " >>\n"; + } + $buffer .= ">>\n"; + $buffer .= "endobj\n"; + } + + $offsets[$pageTreeObjNum] = strlen($buffer); + $buffer .= $pageTreeObjNum . " 0 obj\n"; + $buffer .= "<info(); + if ($info !== null) { + if ($info->title !== null) { + $buffer .= "/Title " . self::textString($info->title) . "\n"; + } + if ($info->author !== null) { + $buffer .= "/Author " . self::textString($info->author) . "\n"; + } + if ($info->subject !== null) { + $buffer .= "/Subject " . self::textString($info->subject) . "\n"; + } + if ($info->keywords !== null) { + $buffer .= "/Keywords " . self::textString($info->keywords) . "\n"; + } + if ($info->creator !== null) { + $buffer .= "/Creator " . self::textString($info->creator) . "\n"; + } + if ($info->creationDate !== null) { + $buffer .= "/CreationDate " . self::textString($info->creationDate) . "\n"; + } + } + $buffer .= ">>\n"; + $buffer .= "endobj\n"; + + $offsets[$catalogObjNum] = strlen($buffer); + $buffer .= $catalogObjNum . " 0 obj\n"; + $buffer .= "<writeCatalogViewerPrefs($catalog, $buffer, $pages, $pageObjNums); + $buffer .= ">>\n"; + $buffer .= "endobj\n"; + + $xrefOffset = strlen($buffer); + $buffer .= "xref\n"; + $buffer .= "0 " . ($totalObjects + 1) . "\n"; + $buffer .= "0000000000 65535 f \n"; + for ($i = 1; $i <= $totalObjects; $i++) { + $buffer .= sprintf("%010d 00000 n \n", $offsets[$i]); + } + + $buffer .= "trailer\n"; + $buffer .= "<<\n"; + $buffer .= "/Size " . ($totalObjects + 1) . "\n"; + $buffer .= "/Root " . $catalogObjNum . " 0 R\n"; + $buffer .= "/Info " . $infoObjNum . " 0 R\n"; + $buffer .= ">>\n"; + $buffer .= "startxref\n"; + $buffer .= $xrefOffset . "\n"; + $buffer .= "%%EOF\n"; + + return $buffer; + } + + /** + * @param SplObjectStorage $objectMap + */ + private function serializeAnnotation(Annotation $annot, SplObjectStorage $objectMap): string + { + $rect = $annot->rect()->toPdfArray(); + $s = '<subtype() . ' /Rect ' . $rect . ' /Border [0 0 0] '; + + if ($annot instanceof LinkAnnotation) { + $target = $annot->target; + + if ($target instanceof UriAction) { + $s .= '/A <uri) . '>>>>'; + } elseif ($target instanceof Destination) { + $pageObjNum = $objectMap->contains($target->page) + ? $objectMap[$target->page] + : 0; + $s .= sprintf( + '/Dest [%d 0 R /XYZ %.2F %.2F null]>>', + $pageObjNum, + $target->left ?? 0, + $target->top, + ); + } elseif ($target instanceof GoToAction) { + $dest = $target->destination; + $pageObjNum = $objectMap->contains($dest->page) + ? $objectMap[$dest->page] + : 0; + $s .= sprintf( + '/Dest [%d 0 R /XYZ %.2F %.2F null]>>', + $pageObjNum, + $dest->left ?? 0, + $dest->top, + ); + } + } + + return $s; + } + + /** + * @param array $pages + * @param array $pageObjNums + */ + private function writeCatalogViewerPrefs( + DocumentCatalog $catalog, + string &$buffer, + array $pages, + array $pageObjNums, + ): void { + $prefs = $catalog->viewerPreferences(); + $firstPageRef = !empty($pageObjNums) ? ($pageObjNums[0] . ' 0 R') : '0 0 R'; + + if ($prefs === null) { + return; + } + + $zoom = $prefs->zoomMode; + $layout = $prefs->layoutMode; + + if ($zoom === ZoomMode::FullPage) { + $buffer .= "/OpenAction [" . $firstPageRef . " /Fit]\n"; + } elseif ($zoom === ZoomMode::FullWidth) { + $buffer .= "/OpenAction [" . $firstPageRef . " /FitH null]\n"; + } elseif ($zoom === ZoomMode::Real) { + $buffer .= "/OpenAction [" . $firstPageRef . " /XYZ null null 1]\n"; + } elseif ($prefs->zoomPercent !== null) { + $factor = $prefs->zoomPercent / 100; + $buffer .= sprintf("/OpenAction [%s /XYZ null null %.2F]\n", $firstPageRef, $factor); + } + + if ($layout === LayoutMode::Single) { + $buffer .= "/PageLayout /SinglePage\n"; + } elseif ($layout === LayoutMode::Continuous) { + $buffer .= "/PageLayout /OneColumn\n"; + } elseif ($layout === LayoutMode::Two) { + $buffer .= "/PageLayout /TwoColumnLeft\n"; + } + } + + private static function escapeString(string $s): string + { + return str_replace( + ['\\', '(', ')'], + ['\\\\', '\\(', '\\)'], + $s, + ); + } + + private static function textString(string $s): string + { + return '(' . self::escapeString($s) . ')'; + } +} diff --git a/src/PdfVersion.php b/src/PdfVersion.php new file mode 100644 index 0000000..74fd8e6 --- /dev/null +++ b/src/PdfVersion.php @@ -0,0 +1,18 @@ +value; + } +} diff --git a/src/PdfWriter.php b/src/PdfWriter.php new file mode 100644 index 0000000..dbda246 --- /dev/null +++ b/src/PdfWriter.php @@ -0,0 +1,1008 @@ + Raw PDF operator strings per page */ + private array $pageContent = []; + + /** @var array Resources per page */ + private array $pageResources = []; + + /** @var array Font counter per page for local names */ + private array $fontCounters = []; + + /** @var array> pdfName → local name per page */ + private array $fontNameMaps = []; + + /** @var array> localName → Font per page */ + private array $fontMaps = []; + + /** @var array Image counter per page */ + private array $imageCounters = []; + + /** @var array> object_id → local name per page */ + private array $imageNameMaps = []; + + /** @var array> localName → ImageXObject per page */ + private array $imageMaps = []; + + /** @var array */ + private array $pageOrientations = []; + + private bool $inFooter = false; + private ?HeaderFooterHandler $headerFooter; + private string $aliasNbPages = '{nb}'; + private bool $compress; + + private ?DocumentInfo $documentInfo = null; + private ?ViewerPreferences $viewerPreferences = null; + + /** @var array */ + private array $imageCache = []; + + public function __construct( + private readonly WriterOptions $options = new WriterOptions(), + ?HeaderFooterHandler $headerFooter = null, + bool $compress = true, + ) { + $this->headerFooter = $headerFooter; + $this->compress = $compress; + $this->scaleFactor = $options->unit->scaleFactor(); + + [$this->fwPt, $this->fhPt] = $options->formatDimensionsInPoints(); + + if ($options->orientation === Orientation::Landscape) { + [$this->fwPt, $this->fhPt] = [$this->fhPt, $this->fwPt]; + } + + $this->wPt = $this->fwPt; + $this->hPt = $this->fhPt; + $this->w = $this->wPt / $this->scaleFactor; + $this->h = $this->hPt / $this->scaleFactor; + + $this->defaultOrientation = $options->orientation; + $this->currentOrientation = $options->orientation; + + $margin = 28.35 / $this->scaleFactor; + $this->leftMargin = $margin; + $this->topMargin = $margin; + $this->rightMargin = $margin; + $this->cellMargin = $margin / 10.0; + + $this->lineWidth = 0.567 / $this->scaleFactor; + $this->pageBreakTrigger = $this->h - $this->breakMargin; + + $this->fillColor = Color::gray(1.0); + $this->textColor = Color::gray(0.0); + $this->drawColor = Color::gray(0.0); + + $this->fontSize = $this->fontSizePt / $this->scaleFactor; + } + + public static function fromLegacy(array $params = []): self + { + return new self(WriterOptions::fromLegacy($params)); + } + + // --- Document lifecycle --- + + public function open(): void + { + $this->state = DocumentState::Open; + } + + public function close(): void + { + if ($this->state === DocumentState::Closed) { + return; + } + + if ($this->pageNumber === 0) { + $this->addPage(); + } + + $this->finalizePage(); + $this->state = DocumentState::Closed; + } + + public function addPage(?Orientation $orientation = null): void + { + if ($this->state === DocumentState::Initial) { + $this->open(); + } + + if ($this->pageNumber > 0) { + $this->finalizePage(); + } + + $this->pageNumber++; + $orientation ??= $this->defaultOrientation; + $this->currentOrientation = $orientation; + $this->pageOrientations[$this->pageNumber] = $orientation; + + if ($orientation !== $this->defaultOrientation) { + $this->wPt = $this->fhPt; + $this->hPt = $this->fwPt; + } else { + $this->wPt = $this->fwPt; + $this->hPt = $this->fhPt; + } + $this->w = $this->wPt / $this->scaleFactor; + $this->h = $this->hPt / $this->scaleFactor; + $this->pageBreakTrigger = $this->h - $this->breakMargin; + + $this->pageContent[$this->pageNumber] = ''; + $this->fontCounters[$this->pageNumber] = 0; + $this->fontNameMaps[$this->pageNumber] = []; + $this->fontMaps[$this->pageNumber] = []; + $this->imageCounters[$this->pageNumber] = 0; + $this->imageNameMaps[$this->pageNumber] = []; + $this->imageMaps[$this->pageNumber] = []; + $this->state = DocumentState::PageOpen; + + $this->x = $this->leftMargin; + $this->y = $this->topMargin; + + $this->out(sprintf('%.2F w', $this->lineWidth * $this->scaleFactor)); + $this->out($this->drawColor->toPdfStrokeString()); + + if ($this->currentFont !== null) { + $localName = $this->registerFont($this->currentFont); + $this->out(sprintf('BT /%s %.2F Tf ET', $localName, $this->fontSizePt)); + } + + if ($this->headerFooter !== null) { + $this->headerFooter->writeHeader($this); + } + } + + public function getOutput(): string + { + if ($this->state !== DocumentState::Closed) { + $this->close(); + } + + $totalPages = $this->pageNumber; + $catalog = new DocumentCatalog(); + + for ($p = 1; $p <= $totalPages; $p++) { + $operators = str_replace( + $this->aliasNbPages, + (string) $totalPages, + $this->pageContent[$p], + ); + + $resources = new ResourceDictionary(); + foreach ($this->fontMaps[$p] as $localName => $font) { + $resources->addFont($localName, $font); + } + foreach ($this->imageMaps[$p] as $localName => $image) { + $resources->addImage($localName, $image); + } + + $cs = new ContentStream($operators, $resources); + $orientation = $this->pageOrientations[$p]; + $mediaBox = $this->mediaBoxForOrientation($orientation); + $page = new Page($mediaBox); + $page->addContentStream($cs); + $catalog->addPage($page); + } + + if ($this->documentInfo !== null) { + $catalog->setInfo($this->documentInfo); + } + if ($this->viewerPreferences !== null) { + $catalog->setViewerPreferences($this->viewerPreferences); + } + + return (new PdfSerializer(compress: $this->compress))->serialize($catalog); + } + + public function getPageNo(): int + { + return $this->pageNumber; + } + + // --- Margins & layout --- + + public function setMargins(float $left, float $top, ?float $right = null): void + { + $this->leftMargin = $left; + $this->topMargin = $top; + $this->rightMargin = $right ?? $left; + } + + public function setLeftMargin(float $margin): void + { + $this->leftMargin = $margin; + } + + public function setTopMargin(float $margin): void + { + $this->topMargin = $margin; + } + + public function setRightMargin(float $margin): void + { + $this->rightMargin = $margin; + } + + public function setAutoPageBreak(bool $auto, float $margin = 0): void + { + $this->autoPageBreak = $auto; + $this->breakMargin = $margin; + $this->pageBreakTrigger = $this->h - $margin; + } + + public function getPageWidth(): float + { + return $this->w - $this->rightMargin - $this->leftMargin; + } + + public function getPageHeight(): float + { + return $this->h - $this->topMargin - $this->breakMargin; + } + + // --- Cursor --- + + public function getX(): float + { + return $this->x; + } + + public function setX(float $x): void + { + $this->x = ($x >= 0) ? $x : $this->w + $x; + } + + public function getY(): float + { + return $this->y; + } + + public function setY(float $y): void + { + $this->x = $this->leftMargin; + $this->y = ($y >= 0) ? $y : $this->h + $y; + } + + public function setXY(float $x, float $y): void + { + $this->setX($x); + $this->y = ($y >= 0) ? $y : $this->h + $y; + } + + public function newLine(float $height = 0): void + { + $this->x = $this->leftMargin; + $this->y += ($height > 0) ? $height : $this->lastHeight; + } + + // --- Font --- + + public function setFont(string $family, string $style = '', ?float $size = null): void + { + if ($family === '') { + $family = $this->fontFamily; + } + + [$coreFont, $underline] = CoreFont::fromFamilyStyle($family, $style); + $this->currentFont = $coreFont->toFont(); + $this->fontFamily = $coreFont->family(); + $this->fontStyle = strtoupper(str_replace('U', '', $style)); + if ($this->fontStyle === 'IB') { + $this->fontStyle = 'BI'; + } + $this->underline = $underline; + + if ($size !== null && $size > 0) { + $this->fontSizePt = $size; + $this->fontSize = $size / $this->scaleFactor; + } + + if ($this->pageNumber > 0) { + $localName = $this->registerFont($this->currentFont); + $this->out(sprintf('BT /%s %.2F Tf ET', $localName, $this->fontSizePt)); + } + } + + public function setFontSize(float $size): void + { + $this->fontSizePt = $size; + $this->fontSize = $size / $this->scaleFactor; + + if ($this->pageNumber > 0 && $this->currentFont !== null) { + $localName = $this->registerFont($this->currentFont); + $this->out(sprintf('BT /%s %.2F Tf ET', $localName, $this->fontSizePt)); + } + } + + public function getStringWidth(string $text): float + { + if ($this->currentFont === null) { + return 0.0; + } + + return $this->currentFont->widthOfString($text, $this->fontSizePt) / $this->scaleFactor; + } + + // --- Color --- + + public function setFillColor(Color $color): void + { + $this->fillColor = $color; + $this->colorFlag = ($this->fillColor->toPdfFillString() !== $this->textColor->toPdfFillString()); + + if ($this->pageNumber > 0) { + $this->out($color->toPdfFillString()); + } + } + + public function setTextColor(Color $color): void + { + $this->textColor = $color; + $this->colorFlag = ($this->fillColor->toPdfFillString() !== $this->textColor->toPdfFillString()); + } + + public function setDrawColor(Color $color): void + { + $this->drawColor = $color; + + if ($this->pageNumber > 0) { + $this->out($color->toPdfStrokeString()); + } + } + + // --- Drawing --- + + public function setLineWidth(float $width): void + { + $this->lineWidth = $width; + + if ($this->pageNumber > 0) { + $this->out(sprintf('%.2F w', $width * $this->scaleFactor)); + } + } + + // --- Text output --- + + public function cell( + float $width, + float $height = 0, + string $text = '', + Border|int|string $border = 0, + CellNextPosition|int $ln = 0, + TextAlign|string $align = '', + bool $fill = false, + string $link = '', + ): void { + $k = $this->scaleFactor; + + if ($this->y + $height > $this->pageBreakTrigger + && !$this->inFooter + && $this->autoPageBreak + ) { + $savedX = $this->x; + $savedWs = $this->wordSpacing; + if ($savedWs > 0) { + $this->wordSpacing = 0; + $this->out('0 Tw'); + } + $this->addPage($this->currentOrientation); + $this->x = $savedX; + if ($savedWs > 0) { + $this->wordSpacing = $savedWs; + $this->out(sprintf('%.3F Tw', $savedWs * $k)); + } + } + + if ($width == 0) { + $width = $this->w - $this->rightMargin - $this->x; + } + + $border = $this->resolveBorder($border); + $align = $this->resolveAlign($align); + $ln = $this->resolveLn($ln); + + $s = ''; + + if ($fill || $border->isFull()) { + if ($fill) { + $op = $border->isFull() ? 'B' : 'f'; + } else { + $op = 'S'; + } + $s .= sprintf( + '%.2F %.2F %.2F %.2F re %s ', + $this->x * $k, + ($this->h - $this->y) * $k, + $width * $k, + -$height * $k, + $op, + ); + } + + if ($border->hasAny() && !$border->isFull()) { + $x1 = $this->x * $k; + $y1 = ($this->h - $this->y) * $k; + $x2 = ($this->x + $width) * $k; + $y2 = ($this->h - ($this->y + $height)) * $k; + + if ($border->hasLeft()) { + $s .= sprintf('%.2F %.2F m %.2F %.2F l S ', $x1, $y1, $x1, $y2); + } + if ($border->hasTop()) { + $s .= sprintf('%.2F %.2F m %.2F %.2F l S ', $x1, $y1, $x2, $y1); + } + if ($border->hasRight()) { + $s .= sprintf('%.2F %.2F m %.2F %.2F l S ', $x2, $y1, $x2, $y2); + } + if ($border->hasBottom()) { + $s .= sprintf('%.2F %.2F m %.2F %.2F l S ', $x1, $y2, $x2, $y2); + } + } + + if ($text !== '') { + if ($this->currentFont === null) { + throw new PdfException('No font set'); + } + + $dx = match ($align) { + TextAlign::Right => $width - $this->cellMargin - $this->getStringWidth($text), + TextAlign::Center => ($width - $this->getStringWidth($text)) / 2, + default => $this->cellMargin, + }; + + if ($this->colorFlag) { + $s .= 'q ' . $this->textColor->toPdfFillString() . ' '; + } + + $localName = $this->registerFont($this->currentFont); + $escaped = self::escapeString($text); + $textX = ($this->x + $dx) * $k; + $textY = ($this->h - ($this->y + 0.5 * $height + 0.3 * $this->fontSize)) * $k; + $s .= sprintf( + 'BT /%s %.2F Tf %.2F %.2F Td (%s) Tj ET', + $localName, + $this->fontSizePt, + $textX, + $textY, + $escaped, + ); + + if ($this->underline) { + $s .= ' ' . $this->doUnderline($textX, $textY, $text); + } + + if ($this->colorFlag) { + $s .= ' Q'; + } + } + + if ($s !== '') { + $this->out($s); + } + + $this->lastHeight = $height; + + if ($ln === CellNextPosition::NextLine || $ln === CellNextPosition::Below) { + $this->y += $height; + if ($ln === CellNextPosition::NextLine) { + $this->x = $this->leftMargin; + } + } else { + $this->x += $width; + } + } + + public function multiCell( + float $width, + float $height, + string $text, + Border|int|string $border = 0, + TextAlign|string $align = 'J', + bool $fill = false, + ): void { + if ($this->currentFont === null) { + throw new PdfException('No font set'); + } + + $cw = $this->currentFontWidths(); + + if ($width == 0) { + $width = $this->w - $this->rightMargin - $this->x; + } + + $wmax = ($width - 2 * $this->cellMargin) * 1000 / $this->fontSize; + $s = str_replace("\r", '', $text); + $nb = strlen($s); + if ($nb > 0 && $s[$nb - 1] === "\n") { + $nb--; + } + + $resolvedBorder = $this->resolveBorder($border); + $resolvedAlign = $this->resolveAlign($align); + + $b = Border::none(); + $b2 = Border::none(); + + if ($resolvedBorder->hasAny()) { + if ($resolvedBorder->isFull()) { + $b = Border::sides(left: true, right: true, top: true); + $b2 = Border::sides(left: true, right: true); + } else { + $b2 = Border::sides( + left: $resolvedBorder->hasLeft(), + right: $resolvedBorder->hasRight(), + ); + $b = $resolvedBorder->hasTop() + ? Border::sides(left: $b2->hasLeft(), right: $b2->hasRight(), top: true) + : $b2; + } + } + + $sep = -1; + $i = 0; + $j = 0; + $l = 0; + $ns = 0; + $nl = 1; + $ls = 0; + + while ($i < $nb) { + $c = $s[$i]; + if ($c === "\n") { + if ($this->wordSpacing > 0) { + $this->wordSpacing = 0; + $this->out('0 Tw'); + } + $this->cell($width, $height, substr($s, $j, $i - $j), $b, CellNextPosition::Below, $resolvedAlign, $fill); + $i++; + $sep = -1; + $j = $i; + $l = 0; + $ns = 0; + $nl++; + if ($resolvedBorder->hasAny() && $nl === 2) { + $b = $b2; + } + continue; + } + if ($c === ' ') { + $sep = $i; + $ls = $l; + $ns++; + } + $l += $cw[$c] ?? 0; + if ($l > $wmax) { + if ($sep === -1) { + if ($i === $j) { + $i++; + } + if ($this->wordSpacing > 0) { + $this->wordSpacing = 0; + $this->out('0 Tw'); + } + $this->cell($width, $height, substr($s, $j, $i - $j), $b, CellNextPosition::Below, $resolvedAlign, $fill); + } else { + if ($resolvedAlign === TextAlign::Justify) { + $this->wordSpacing = ($ns > 1) + ? ($wmax - $ls) / 1000 * $this->fontSize / ($ns - 1) + : 0; + $this->out(sprintf('%.3F Tw', $this->wordSpacing * $this->scaleFactor)); + } + $this->cell($width, $height, substr($s, $j, $sep - $j), $b, CellNextPosition::Below, $resolvedAlign, $fill); + $i = $sep + 1; + } + $sep = -1; + $j = $i; + $l = 0; + $ns = 0; + $nl++; + if ($resolvedBorder->hasAny() && $nl === 2) { + $b = $b2; + } + } else { + $i++; + } + } + + if ($this->wordSpacing > 0) { + $this->wordSpacing = 0; + $this->out('0 Tw'); + } + + if ($resolvedBorder->hasBottom()) { + $b = Border::sides( + left: $b->hasLeft(), + right: $b->hasRight(), + top: $b->hasTop(), + bottom: true, + ); + } + $this->cell($width, $height, substr($s, $j, $i - $j), $b, CellNextPosition::Below, $resolvedAlign, $fill); + $this->x = $this->leftMargin; + } + + public function write(float $height, string $text, string $link = ''): void + { + if ($this->currentFont === null) { + throw new PdfException('No font set'); + } + + $cw = $this->currentFontWidths(); + $width = $this->w - $this->rightMargin - $this->x; + $wmax = ($width - 2 * $this->cellMargin) * 1000 / $this->fontSize; + $s = str_replace("\r", '', $text); + $nb = strlen($s); + $sep = -1; + $i = 0; + $j = 0; + $l = 0; + $nl = 1; + $ls = 0; + + while ($i < $nb) { + $c = $s[$i]; + if ($c === "\n") { + $this->cell($width, $height, substr($s, $j, $i - $j), 0, CellNextPosition::Below, '', false, $link); + $i++; + $sep = -1; + $j = $i; + $l = 0; + if ($nl === 1) { + $this->x = $this->leftMargin; + $width = $this->w - $this->rightMargin - $this->x; + $wmax = ($width - 2 * $this->cellMargin) * 1000 / $this->fontSize; + } + $nl++; + continue; + } + if ($c === ' ') { + $sep = $i; + $ls = $l; + } + $l += $cw[$c] ?? 0; + if ($l > $wmax) { + if ($sep === -1) { + if ($this->x > $this->leftMargin) { + $this->x = $this->leftMargin; + $this->y += $height; + $width = $this->w - $this->rightMargin - $this->x; + $wmax = ($width - 2 * $this->cellMargin) * 1000 / $this->fontSize; + $i++; + $nl++; + continue; + } + if ($i === $j) { + $i++; + } + $this->cell($width, $height, substr($s, $j, $i - $j), 0, CellNextPosition::Below, '', false, $link); + } else { + $this->cell($width, $height, substr($s, $j, $sep - $j), 0, CellNextPosition::Below, '', false, $link); + $i = $sep + 1; + } + $sep = -1; + $j = $i; + $l = 0; + if ($nl === 1) { + $this->x = $this->leftMargin; + $width = $this->w - $this->rightMargin - $this->x; + $wmax = ($width - 2 * $this->cellMargin) * 1000 / $this->fontSize; + } + $nl++; + } else { + $i++; + } + } + + if ($i !== $j) { + $this->cell( + $l / 1000 * $this->fontSize, + $height, + substr($s, $j, $i - $j), + 0, + CellNextPosition::ToRight, + '', + false, + $link, + ); + } + } + + // --- Image --- + + public function image( + string $file, + float $x, + float $y, + float $width = 0, + float $height = 0, + string $type = '', + ): void { + if ($type === '') { + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $type = match ($ext) { + 'jpg', 'jpeg' => 'jpeg', + 'png' => 'png', + default => throw new PdfException(sprintf('Unsupported image type: %s', $ext)), + }; + } else { + $type = strtolower($type); + if ($type === 'jpg') { + $type = 'jpeg'; + } + } + + if (!isset($this->imageCache[$file])) { + $this->imageCache[$file] = match ($type) { + 'jpeg' => JpegParser::parseFile($file), + 'png' => PngParser::parseFile($file), + default => throw new PdfException(sprintf('Unsupported image type: %s', $type)), + }; + } + + $image = $this->imageCache[$file]; + + if ($width == 0 && $height == 0) { + $width = $image->width / $this->scaleFactor; + $height = $image->height / $this->scaleFactor; + } elseif ($width == 0) { + $width = $height * $image->width / $image->height; + } elseif ($height == 0) { + $height = $width * $image->height / $image->width; + } + + $k = $this->scaleFactor; + $localName = $this->registerImage($image); + $this->out(sprintf( + 'q %.2F 0 0 %.2F %.2F %.2F cm /%s Do Q', + $width * $k, + $height * $k, + $x * $k, + $this->hPt - ($y * $k) - ($height * $k), + $localName, + )); + } + + // --- Metadata --- + + public function aliasNbPages(string $alias = '{nb}'): void + { + $this->aliasNbPages = $alias; + } + + public function setInfo(string $key, string $value): void + { + $title = $this->documentInfo?->title; + $author = $this->documentInfo?->author; + $subject = $this->documentInfo?->subject; + $keywords = $this->documentInfo?->keywords; + $creator = $this->documentInfo?->creator; + $creationDate = $this->documentInfo?->creationDate; + + match (strtolower($key)) { + 'title' => $title = $value, + 'author' => $author = $value, + 'subject' => $subject = $value, + 'keywords' => $keywords = $value, + 'creator' => $creator = $value, + 'creationdate' => $creationDate = $value, + default => null, + }; + + $this->documentInfo = new DocumentInfo( + title: $title, + author: $author, + subject: $subject, + keywords: $keywords, + creator: $creator, + creationDate: $creationDate, + ); + } + + public function setDisplayMode(string $zoom, string $layout = ''): void + { + $zoomMode = match (strtolower($zoom)) { + 'fullpage' => ZoomMode::FullPage, + 'fullwidth' => ZoomMode::FullWidth, + 'real' => ZoomMode::Real, + default => ZoomMode::DefaultMode, + }; + + $layoutMode = match (strtolower($layout)) { + 'single' => LayoutMode::Single, + 'continuous' => LayoutMode::Continuous, + 'two' => LayoutMode::Two, + default => LayoutMode::DefaultMode, + }; + + $this->viewerPreferences = new ViewerPreferences( + zoomMode: $zoomMode, + layoutMode: $layoutMode, + ); + } + + public function setCompression(bool $compress): void + { + $this->compress = $compress; + } + + // --- Private helpers --- + + private function out(string $s): void + { + $this->pageContent[$this->pageNumber] .= $s . "\n"; + } + + private function finalizePage(): void + { + if ($this->headerFooter !== null && !$this->inFooter) { + $this->inFooter = true; + $this->headerFooter->writeFooter($this); + $this->inFooter = false; + } + + $this->state = DocumentState::Open; + } + + private function registerFont(Font $font): string + { + $p = $this->pageNumber; + $key = $font->pdfName(); + + if (isset($this->fontNameMaps[$p][$key])) { + return $this->fontNameMaps[$p][$key]; + } + + $this->fontCounters[$p]++; + $localName = 'F' . $this->fontCounters[$p]; + $this->fontNameMaps[$p][$key] = $localName; + $this->fontMaps[$p][$localName] = $font; + + return $localName; + } + + private function registerImage(ImageXObject $image): string + { + $p = $this->pageNumber; + $id = spl_object_id($image); + + if (isset($this->imageNameMaps[$p][$id])) { + return $this->imageNameMaps[$p][$id]; + } + + $this->imageCounters[$p]++; + $localName = 'I' . $this->imageCounters[$p]; + $this->imageNameMaps[$p][$id] = $localName; + $this->imageMaps[$p][$localName] = $image; + + return $localName; + } + + private function mediaBoxForOrientation(Orientation $orientation): Rectangle + { + if ($orientation !== $this->defaultOrientation) { + return Rectangle::fromDimensions($this->fhPt, $this->fwPt); + } + + return Rectangle::fromDimensions($this->fwPt, $this->fhPt); + } + + private function resolveBorder(Border|int|string $border): Border + { + if ($border instanceof Border) { + return $border; + } + + return Border::fromLegacy($border); + } + + private function resolveAlign(TextAlign|string $align): TextAlign + { + if ($align instanceof TextAlign) { + return $align; + } + + return match (strtoupper($align)) { + 'L' => TextAlign::Left, + 'C' => TextAlign::Center, + 'R' => TextAlign::Right, + 'J' => TextAlign::Justify, + default => TextAlign::Left, + }; + } + + private function resolveLn(CellNextPosition|int $ln): CellNextPosition + { + if ($ln instanceof CellNextPosition) { + return $ln; + } + + return CellNextPosition::from($ln); + } + + /** + * @return array + */ + private function currentFontWidths(): array + { + if ($this->currentFont instanceof Type1Font) { + return $this->currentFont->widths(); + } + + return []; + } + + private static function escapeString(string $s): string + { + return str_replace( + ['\\', '(', ')'], + ['\\\\', '\\(', '\\)'], + $s, + ); + } + + private function doUnderline(float $x, float $y, string $text): string + { + $up = -100; + $ut = 50; + $w = $this->currentFont->widthOfString($text, $this->fontSizePt); + + return sprintf( + '%.2F %.2F %.2F %.2F re f', + $x, + $y - ($up * $this->fontSizePt / 1000.0), + $w, + -($ut * $this->fontSizePt / 1000.0), + ); + } +} diff --git a/src/PngParser.php b/src/PngParser.php new file mode 100644 index 0000000..baa1d02 --- /dev/null +++ b/src/PngParser.php @@ -0,0 +1,130 @@ + 8) { + throw new PdfException(sprintf('16-bit depth not supported: %s', $path)); + } + + $ct = ord((string) fread($f, 1)); + $colorSpace = match ($ct) { + 0 => new DeviceGray(), + 2 => new DeviceRgb(), + 3 => new DeviceRgb(), + default => throw new PdfException(sprintf('Alpha channel not supported: %s', $path)), + }; + + if (ord((string) fread($f, 1)) !== 0) { + throw new PdfException(sprintf('Unknown compression method: %s', $path)); + } + if (ord((string) fread($f, 1)) !== 0) { + throw new PdfException(sprintf('Unknown filter method: %s', $path)); + } + if (ord((string) fread($f, 1)) !== 0) { + throw new PdfException(sprintf('Interlacing not supported: %s', $path)); + } + + fread($f, 4); + + $colors = ($ct === 2) ? 3 : 1; + $decodeParms = '/DecodeParms <>'; + + $pal = ''; + $trns = []; + $data = ''; + + do { + $n = self::readInt($f); + $type = (string) fread($f, 4); + + if ($type === 'PLTE') { + $pal = (string) fread($f, $n); + fread($f, 4); + } elseif ($type === 'tRNS') { + $t = (string) fread($f, $n); + $trns = match ($ct) { + 0 => [ord($t[1])], + 2 => [ord($t[1]), ord($t[3]), ord($t[5])], + default => (($pos = strpos($t, "\0")) !== false) ? [$pos] : [], + }; + fread($f, 4); + } elseif ($type === 'IDAT') { + $data .= (string) fread($f, $n); + fread($f, 4); + } elseif ($type === 'IEND') { + break; + } else { + fread($f, $n + 4); + } + } while ($n); + + if ($ct === 3 && $pal === '') { + throw new PdfException(sprintf('Missing palette in: %s', $path)); + } + + return new ImageXObject( + width: $width, + height: $height, + colorSpace: $colorSpace, + bitsPerComponent: $bpc, + filter: 'FlateDecode', + data: $data, + decodeParms: $decodeParms, + palette: ($pal !== '') ? $pal : null, + transparency: ($trns !== []) ? $trns : null, + ); + } + + /** + * @param resource $f + */ + private static function readInt($f): int + { + $i = ord((string) fread($f, 1)) << 24; + $i += ord((string) fread($f, 1)) << 16; + $i += ord((string) fread($f, 1)) << 8; + $i += ord((string) fread($f, 1)); + + return $i; + } +} diff --git a/src/Rectangle.php b/src/Rectangle.php new file mode 100644 index 0000000..c6177cf --- /dev/null +++ b/src/Rectangle.php @@ -0,0 +1,54 @@ +dimensions(); + + if ($orientation === Orientation::Landscape) { + return new self(0.0, 0.0, $h, $w); + } + + return new self(0.0, 0.0, $w, $h); + } + + public function width(): float + { + return $this->urx - $this->llx; + } + + public function height(): float + { + return $this->ury - $this->lly; + } + + public function toPdfArray(): string + { + return sprintf( + '[%.2F %.2F %.2F %.2F]', + $this->llx, + $this->lly, + $this->urx, + $this->ury, + ); + } +} diff --git a/src/ResourceDictionary.php b/src/ResourceDictionary.php new file mode 100644 index 0000000..359fd29 --- /dev/null +++ b/src/ResourceDictionary.php @@ -0,0 +1,60 @@ + */ + private array $fonts = []; + + /** @var array */ + private array $images = []; + + public function addFont(string $name, Font $font): void + { + $this->fonts[$name] = $font; + } + + public function addImage(string $name, ImageXObject $image): void + { + $this->images[$name] = $image; + } + + /** + * @return array + */ + public function fonts(): array + { + return $this->fonts; + } + + /** + * @return array + */ + public function images(): array + { + return $this->images; + } + + public function merge(self $other): void + { + foreach ($other->fonts as $name => $font) { + if (!isset($this->fonts[$name])) { + $this->fonts[$name] = $font; + } + } + + foreach ($other->images as $name => $image) { + if (!isset($this->images[$name])) { + $this->images[$name] = $image; + } + } + } + + public function isEmpty(): bool + { + return empty($this->fonts) && empty($this->images); + } +} diff --git a/src/ShapeStyle.php b/src/ShapeStyle.php new file mode 100644 index 0000000..3b3e259 --- /dev/null +++ b/src/ShapeStyle.php @@ -0,0 +1,21 @@ + 'S', + self::Fill => 'f', + self::DrawAndFill => 'B', + }; + } +} diff --git a/src/TextAlign.php b/src/TextAlign.php new file mode 100644 index 0000000..c8f3b63 --- /dev/null +++ b/src/TextAlign.php @@ -0,0 +1,13 @@ +|null */ + private ?array $widths = null; + + public function __construct( + private readonly CoreFont $coreFont, + ) {} + + public function pdfName(): string + { + return $this->coreFont->pdfName(); + } + + public function encoding(): FontEncoding + { + return match ($this->coreFont) { + CoreFont::Symbol => FontEncoding::Symbol, + CoreFont::ZapfDingbats => FontEncoding::ZapfDingbats, + default => FontEncoding::WinAnsi, + }; + } + + public function style(): FontStyle + { + return match ($this->coreFont) { + CoreFont::CourierBold, CoreFont::HelveticaBold, CoreFont::TimesBold => FontStyle::Bold, + CoreFont::CourierItalic, CoreFont::HelveticaItalic, CoreFont::TimesItalic => FontStyle::Italic, + CoreFont::CourierBoldItalic, CoreFont::HelveticaBoldItalic, CoreFont::TimesBoldItalic => FontStyle::BoldItalic, + default => FontStyle::Regular, + }; + } + + public function widthOfString(string $text, float $size): float + { + $widths = $this->widths(); + $total = 0; + + for ($i = 0, $len = strlen($text); $i < $len; $i++) { + $total += $widths[$text[$i]] ?? 0; + } + + return $total * $size / 1000.0; + } + + public function encode(string $text): string + { + return $text; + } + + public function requiresEmbedding(): bool + { + return false; + } + + public function coreFont(): CoreFont + { + return $this->coreFont; + } + + /** + * @return array + */ + public function widths(): array + { + if ($this->widths === null) { + $this->widths = $this->coreFont->widths(); + } + + return $this->widths; + } +} diff --git a/src/Unit.php b/src/Unit.php new file mode 100644 index 0000000..0d67f26 --- /dev/null +++ b/src/Unit.php @@ -0,0 +1,23 @@ + 1.0, + self::Millimeter => 72.0 / 25.4, + self::Centimeter => 72.0 / 2.54, + self::Inch => 72.0, + }; + } +} diff --git a/src/UriAction.php b/src/UriAction.php new file mode 100644 index 0000000..382abb1 --- /dev/null +++ b/src/UriAction.php @@ -0,0 +1,17 @@ + Orientation::Landscape, + default => Orientation::Portrait, + }; + } + + $unit = Unit::Millimeter; + if (isset($params['unit'])) { + $unit = Unit::from($params['unit']); + } + + $format = PageFormat::A4; + if (isset($params['format'])) { + if (is_array($params['format'])) { + $format = new CustomPageFormat($params['format'][0], $params['format'][1]); + } else { + $format = PageFormat::from(strtolower($params['format'])); + } + } + + return new self($orientation, $unit, $format); + } + + /** + * Page format dimensions in points. + * + * @return array{float, float} Width and height in points. + */ + public function formatDimensionsInPoints(): array + { + if ($this->format instanceof PageFormat) { + return $this->format->dimensions(); + } + + $scale = $this->unit->scaleFactor(); + return [ + $this->format->width * $scale, + $this->format->height * $scale, + ]; + } +} diff --git a/src/ZoomMode.php b/src/ZoomMode.php new file mode 100644 index 0000000..2efa9f5 --- /dev/null +++ b/src/ZoomMode.php @@ -0,0 +1,13 @@ +run(); diff --git a/test/Horde/Pdf/bootstrap.php b/test/Horde/Pdf/bootstrap.php deleted file mode 100644 index 4e19e93..0000000 --- a/test/Horde/Pdf/bootstrap.php +++ /dev/null @@ -1,3 +0,0 @@ -assertInstanceOf(Action::class, $action); + $this->assertSame('URI', $action->actionType()); + $this->assertSame('https://www.horde.org/', $action->uri); + } + + public function testGoToAction(): void + { + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $dest = new Destination($page, top: 700.0); + $action = new GoToAction($dest); + $this->assertInstanceOf(Action::class, $action); + $this->assertSame('GoTo', $action->actionType()); + $this->assertSame($dest, $action->destination); + } + + public function testLinkAnnotationWithUri(): void + { + $rect = new Rectangle(72.0, 700.0, 200.0, 720.0); + $action = new UriAction('https://example.com'); + $link = new LinkAnnotation($rect, $action); + + $this->assertInstanceOf(Annotation::class, $link); + $this->assertSame('Link', $link->subtype()); + $this->assertSame($rect, $link->rect()); + $this->assertSame($action, $link->target); + } + + public function testLinkAnnotationWithDestination(): void + { + $rect = new Rectangle(72.0, 700.0, 200.0, 720.0); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $dest = new Destination($page, top: 500.0); + $link = new LinkAnnotation($rect, $dest); + + $this->assertInstanceOf(Destination::class, $link->target); + } + + public function testPageAnnotations(): void + { + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $rect = new Rectangle(72.0, 700.0, 200.0, 720.0); + $link = new LinkAnnotation($rect, new UriAction('https://horde.org')); + $page->addAnnotation($link); + + $this->assertCount(1, $page->annotations()); + $this->assertSame($link, $page->annotations()[0]); + } +} diff --git a/test/unit/BorderTest.php b/test/unit/BorderTest.php new file mode 100644 index 0000000..cd7609b --- /dev/null +++ b/test/unit/BorderTest.php @@ -0,0 +1,77 @@ +assertFalse($border->hasLeft()); + $this->assertFalse($border->hasRight()); + $this->assertFalse($border->hasTop()); + $this->assertFalse($border->hasBottom()); + $this->assertFalse($border->hasAny()); + $this->assertFalse($border->isFull()); + } + + public function testFull(): void + { + $border = Border::full(); + $this->assertTrue($border->hasLeft()); + $this->assertTrue($border->hasRight()); + $this->assertTrue($border->hasTop()); + $this->assertTrue($border->hasBottom()); + $this->assertTrue($border->hasAny()); + $this->assertTrue($border->isFull()); + } + + public function testCustomSides(): void + { + $border = Border::sides(left: true, bottom: true); + $this->assertTrue($border->hasLeft()); + $this->assertFalse($border->hasRight()); + $this->assertFalse($border->hasTop()); + $this->assertTrue($border->hasBottom()); + $this->assertTrue($border->hasAny()); + $this->assertFalse($border->isFull()); + } + + public function testFromLegacyZero(): void + { + $border = Border::fromLegacy(0); + $this->assertFalse($border->hasAny()); + } + + public function testFromLegacyOne(): void + { + $border = Border::fromLegacy(1); + $this->assertTrue($border->isFull()); + } + + public function testFromLegacyString(): void + { + $border = Border::fromLegacy('LR'); + $this->assertTrue($border->hasLeft()); + $this->assertTrue($border->hasRight()); + $this->assertFalse($border->hasTop()); + $this->assertFalse($border->hasBottom()); + } + + public function testFromLegacyAllSides(): void + { + $border = Border::fromLegacy('LTRB'); + $this->assertTrue($border->isFull()); + } + + public function testFromLegacyEmptyString(): void + { + $border = Border::fromLegacy(''); + $this->assertFalse($border->hasAny()); + } +} diff --git a/test/unit/ColorSpaceImplTest.php b/test/unit/ColorSpaceImplTest.php new file mode 100644 index 0000000..8a2acb8 --- /dev/null +++ b/test/unit/ColorSpaceImplTest.php @@ -0,0 +1,40 @@ +assertInstanceOf(ColorSpace::class, $cs); + $this->assertSame('DeviceRGB', $cs->pdfName()); + $this->assertSame(3, $cs->componentCount()); + } + + public function testDeviceCmyk(): void + { + $cs = new DeviceCmyk(); + $this->assertInstanceOf(ColorSpace::class, $cs); + $this->assertSame('DeviceCMYK', $cs->pdfName()); + $this->assertSame(4, $cs->componentCount()); + } + + public function testDeviceGray(): void + { + $cs = new DeviceGray(); + $this->assertInstanceOf(ColorSpace::class, $cs); + $this->assertSame('DeviceGray', $cs->pdfName()); + $this->assertSame(1, $cs->componentCount()); + } +} diff --git a/test/unit/ColorTest.php b/test/unit/ColorTest.php new file mode 100644 index 0000000..b9fcdcc --- /dev/null +++ b/test/unit/ColorTest.php @@ -0,0 +1,77 @@ +assertSame('1.000 0.000 0.000 rg', $color->toPdfFillString()); + } + + public function testRgbStrokeString(): void + { + $color = Color::rgb(0.0, 0.5, 1.0); + $this->assertSame('0.000 0.500 1.000 RG', $color->toPdfStrokeString()); + } + + public function testCmykFillString(): void + { + $color = Color::cmyk(1.0, 0.0, 0.0, 0.5); + $this->assertSame('1.000 0.000 0.000 0.500 k', $color->toPdfFillString()); + } + + public function testCmykStrokeString(): void + { + $color = Color::cmyk(0.0, 1.0, 0.0, 0.0); + $this->assertSame('0.000 1.000 0.000 0.000 K', $color->toPdfStrokeString()); + } + + public function testGrayFillString(): void + { + $color = Color::gray(0.5); + $this->assertSame('0.500 g', $color->toPdfFillString()); + } + + public function testGrayStrokeString(): void + { + $color = Color::gray(0.0); + $this->assertSame('0.000 G', $color->toPdfStrokeString()); + } + + public function testHexFullForm(): void + { + $color = Color::hex('#FF0000'); + $this->assertSame('1.000 0.000 0.000 rg', $color->toPdfFillString()); + } + + public function testHexShortForm(): void + { + $color = Color::hex('#F00'); + $this->assertSame('1.000 0.000 0.000 rg', $color->toPdfFillString()); + } + + public function testHexWithoutHash(): void + { + $color = Color::hex('00FF00'); + $this->assertSame('0.000 1.000 0.000 rg', $color->toPdfFillString()); + } + + public function testHexMatchesLegacyWriter(): void + { + $color = Color::hex('#F00'); + $this->assertSame('1.000 0.000 0.000 RG', $color->toPdfStrokeString()); + + $color = Color::hex('#0F0'); + $this->assertSame('0.000 1.000 0.000 rg', $color->toPdfFillString()); + + $color = Color::hex('#00F'); + $this->assertSame('0.000 0.000 1.000 rg', $color->toPdfFillString()); + } +} diff --git a/test/unit/ContentStreamBuilderTest.php b/test/unit/ContentStreamBuilderTest.php new file mode 100644 index 0000000..ab7f409 --- /dev/null +++ b/test/unit/ContentStreamBuilderTest.php @@ -0,0 +1,305 @@ +build(); + $this->assertSame('', $stream->operators); + $this->assertTrue($stream->resources->isEmpty()); + } + + public function testMoveTo(): void + { + $stream = (new ContentStreamBuilder()) + ->moveTo(72.0, 720.0) + ->build(); + $this->assertSame('72.00 720.00 m', $stream->operators); + } + + public function testLineTo(): void + { + $stream = (new ContentStreamBuilder()) + ->moveTo(72.0, 720.0) + ->lineTo(200.0, 720.0) + ->build(); + $this->assertStringContainsString('200.00 720.00 l', $stream->operators); + } + + public function testRect(): void + { + $stream = (new ContentStreamBuilder()) + ->rect(10.0, 20.0, 100.0, 50.0) + ->build(); + $this->assertSame('10.00 20.00 100.00 50.00 re', $stream->operators); + } + + public function testCurveTo(): void + { + $stream = (new ContentStreamBuilder()) + ->curveTo(1.0, 2.0, 3.0, 4.0, 5.0, 6.0) + ->build(); + $this->assertSame('1.00 2.00 3.00 4.00 5.00 6.00 c', $stream->operators); + } + + public function testClosePath(): void + { + $stream = (new ContentStreamBuilder()) + ->closePath() + ->build(); + $this->assertSame('h', $stream->operators); + } + + public function testStroke(): void + { + $stream = (new ContentStreamBuilder()) + ->moveTo(0.0, 0.0) + ->lineTo(100.0, 100.0) + ->stroke() + ->build(); + $this->assertStringEndsWith('S', $stream->operators); + } + + public function testFill(): void + { + $stream = (new ContentStreamBuilder()) + ->rect(0.0, 0.0, 100.0, 100.0) + ->fill() + ->build(); + $this->assertStringEndsWith('f', $stream->operators); + } + + public function testFillAndStroke(): void + { + $stream = (new ContentStreamBuilder()) + ->rect(0.0, 0.0, 100.0, 100.0) + ->fillAndStroke() + ->build(); + $this->assertStringEndsWith('B', $stream->operators); + } + + public function testSetLineWidth(): void + { + $stream = (new ContentStreamBuilder()) + ->setLineWidth(0.50) + ->build(); + $this->assertSame('0.50 w', $stream->operators); + } + + public function testSetLineCap(): void + { + $stream = (new ContentStreamBuilder()) + ->setLineCap(LineCap::Round) + ->build(); + $this->assertSame('1 J', $stream->operators); + } + + public function testSetDashPattern(): void + { + $stream = (new ContentStreamBuilder()) + ->setDashPattern(new LineDashPattern([3.0, 2.0], 0.0)) + ->build(); + $this->assertSame('[3.00 2.00] 0.00 d', $stream->operators); + } + + public function testSetFillColor(): void + { + $stream = (new ContentStreamBuilder()) + ->setFillColor(Color::rgb(1.0, 0.0, 0.0)) + ->build(); + $this->assertSame('1.000 0.000 0.000 rg', $stream->operators); + } + + public function testSetStrokeColor(): void + { + $stream = (new ContentStreamBuilder()) + ->setStrokeColor(Color::rgb(0.0, 0.0, 1.0)) + ->build(); + $this->assertSame('0.000 0.000 1.000 RG', $stream->operators); + } + + public function testBeginEndText(): void + { + $stream = (new ContentStreamBuilder()) + ->beginText() + ->endText() + ->build(); + $this->assertSame("BT\nET", $stream->operators); + } + + public function testSetFont(): void + { + $font = CoreFont::Helvetica->toFont(); + $stream = (new ContentStreamBuilder()) + ->beginText() + ->setFont($font, 12.0) + ->endText() + ->build(); + $this->assertStringContainsString('/F1 12.00 Tf', $stream->operators); + $this->assertArrayHasKey('F1', $stream->resources->fonts()); + $this->assertSame('Helvetica', $stream->resources->fonts()['F1']->pdfName()); + } + + public function testShowText(): void + { + $font = CoreFont::Helvetica->toFont(); + $stream = (new ContentStreamBuilder()) + ->beginText() + ->setFont($font, 12.0) + ->showText('Hello') + ->endText() + ->build(); + $this->assertStringContainsString('(Hello) Tj', $stream->operators); + } + + public function testShowTextEscapesSpecialChars(): void + { + $font = CoreFont::Helvetica->toFont(); + $stream = (new ContentStreamBuilder()) + ->beginText() + ->setFont($font, 10.0) + ->showText('Test (parens) and \\backslash') + ->endText() + ->build(); + $this->assertStringContainsString('(Test \\(parens\\) and \\\\backslash) Tj', $stream->operators); + } + + public function testMoveTextPosition(): void + { + $stream = (new ContentStreamBuilder()) + ->beginText() + ->moveTextPosition(72.0, 720.0) + ->endText() + ->build(); + $this->assertStringContainsString('72.00 720.00 Td', $stream->operators); + } + + public function testSetCharSpacing(): void + { + $stream = (new ContentStreamBuilder()) + ->beginText() + ->setCharSpacing(1.50) + ->endText() + ->build(); + $this->assertStringContainsString('1.50 Tc', $stream->operators); + } + + public function testSetWordSpacing(): void + { + $stream = (new ContentStreamBuilder()) + ->beginText() + ->setWordSpacing(2.00) + ->endText() + ->build(); + $this->assertStringContainsString('2.00 Tw', $stream->operators); + } + + public function testSaveRestore(): void + { + $stream = (new ContentStreamBuilder()) + ->save() + ->setLineWidth(2.0) + ->restore() + ->build(); + $lines = explode("\n", $stream->operators); + $this->assertSame('q', $lines[0]); + $this->assertSame('Q', $lines[2]); + } + + public function testClip(): void + { + $stream = (new ContentStreamBuilder()) + ->rect(0.0, 0.0, 100.0, 100.0) + ->clip() + ->build(); + $this->assertStringContainsString('W n', $stream->operators); + } + + public function testShowTextOutsideTextBlockThrows(): void + { + $this->expectException(PdfException::class); + (new ContentStreamBuilder())->showText('Hello'); + } + + public function testEndTextOutsideTextBlockThrows(): void + { + $this->expectException(PdfException::class); + (new ContentStreamBuilder())->endText(); + } + + public function testNestedBeginTextThrows(): void + { + $this->expectException(PdfException::class); + (new ContentStreamBuilder()) + ->beginText() + ->beginText(); + } + + public function testUnclosedTextBlockThrowsOnBuild(): void + { + $this->expectException(PdfException::class); + (new ContentStreamBuilder()) + ->beginText() + ->build(); + } + + public function testUnbalancedSaveRestoreThrowsOnBuild(): void + { + $this->expectException(PdfException::class); + (new ContentStreamBuilder()) + ->save() + ->build(); + } + + public function testRestoreWithoutSaveThrows(): void + { + $this->expectException(PdfException::class); + (new ContentStreamBuilder())->restore(); + } + + public function testFontReuse(): void + { + $font = CoreFont::Helvetica->toFont(); + $stream = (new ContentStreamBuilder()) + ->beginText() + ->setFont($font, 12.0) + ->showText('Hello') + ->setFont($font, 14.0) + ->showText('World') + ->endText() + ->build(); + $this->assertCount(1, $stream->resources->fonts()); + $ops = $stream->operators; + $this->assertSame(2, substr_count($ops, '/F1')); + } + + public function testMultipleFonts(): void + { + $helvetica = CoreFont::Helvetica->toFont(); + $courier = CoreFont::Courier->toFont(); + $stream = (new ContentStreamBuilder()) + ->beginText() + ->setFont($helvetica, 12.0) + ->showText('Hello') + ->setFont($courier, 12.0) + ->showText('World') + ->endText() + ->build(); + $this->assertCount(2, $stream->resources->fonts()); + $this->assertStringContainsString('/F1', $stream->operators); + $this->assertStringContainsString('/F2', $stream->operators); + } +} diff --git a/test/unit/CoreFontTest.php b/test/unit/CoreFontTest.php new file mode 100644 index 0000000..123dfb2 --- /dev/null +++ b/test/unit/CoreFontTest.php @@ -0,0 +1,119 @@ +assertCount(14, CoreFont::cases()); + } + + public function testPdfNames(): void + { + $this->assertSame('Courier', CoreFont::Courier->pdfName()); + $this->assertSame('Courier-Bold', CoreFont::CourierBold->pdfName()); + $this->assertSame('Helvetica', CoreFont::Helvetica->pdfName()); + $this->assertSame('Times-Roman', CoreFont::Times->pdfName()); + $this->assertSame('Symbol', CoreFont::Symbol->pdfName()); + $this->assertSame('ZapfDingbats', CoreFont::ZapfDingbats->pdfName()); + } + + public function testFamilies(): void + { + $this->assertSame('courier', CoreFont::Courier->family()); + $this->assertSame('courier', CoreFont::CourierBold->family()); + $this->assertSame('helvetica', CoreFont::Helvetica->family()); + $this->assertSame('times', CoreFont::Times->family()); + $this->assertSame('symbol', CoreFont::Symbol->family()); + $this->assertSame('zapfdingbats', CoreFont::ZapfDingbats->family()); + } + + public function testCourierWidthsAreUniform(): void + { + $widths = CoreFont::Courier->widths(); + $this->assertNotEmpty($widths); + foreach ($widths as $w) { + $this->assertSame(600, $w); + } + } + + public function testHelveticaWidthsVary(): void + { + $widths = CoreFont::Helvetica->widths(); + $this->assertNotEmpty($widths); + $this->assertSame(278, $widths[' ']); + $this->assertSame(667, $widths['A']); + } + + public function testAllFontsHaveWidths(): void + { + foreach (CoreFont::cases() as $font) { + $widths = $font->widths(); + $this->assertNotEmpty($widths, "Font {$font->value} should have width data"); + $this->assertGreaterThan(200, count($widths), "Font {$font->value} should have 256 character widths"); + } + } + + public function testFromStringValue(): void + { + $this->assertSame(CoreFont::Courier, CoreFont::from('courier')); + $this->assertSame(CoreFont::HelveticaBoldItalic, CoreFont::from('helveticaBI')); + } + + public function testFromFamilyStyleTimes(): void + { + [$font, $underline] = CoreFont::fromFamilyStyle('Times', ''); + $this->assertSame(CoreFont::Times, $font); + $this->assertFalse($underline); + } + + public function testFromFamilyStyleTimesBold(): void + { + [$font, $underline] = CoreFont::fromFamilyStyle('Times', 'B'); + $this->assertSame(CoreFont::TimesBold, $font); + $this->assertFalse($underline); + } + + public function testFromFamilyStyleArialAlias(): void + { + [$font,] = CoreFont::fromFamilyStyle('Arial', ''); + $this->assertSame(CoreFont::Helvetica, $font); + } + + public function testFromFamilyStyleBoldItalicNormalization(): void + { + [$font,] = CoreFont::fromFamilyStyle('Helvetica', 'IB'); + $this->assertSame(CoreFont::HelveticaBoldItalic, $font); + } + + public function testFromFamilyStyleUnderlineFlag(): void + { + [$font, $underline] = CoreFont::fromFamilyStyle('Courier', 'BU'); + $this->assertSame(CoreFont::CourierBold, $font); + $this->assertTrue($underline); + } + + public function testFromFamilyStyleSymbolIgnoresStyle(): void + { + [$font,] = CoreFont::fromFamilyStyle('Symbol', 'B'); + $this->assertSame(CoreFont::Symbol, $font); + } + + public function testFromFamilyStyleCaseInsensitive(): void + { + [$font,] = CoreFont::fromFamilyStyle('TIMES', 'bi'); + $this->assertSame(CoreFont::TimesBoldItalic, $font); + } + + public function testFromFamilyStyleUnknownThrows(): void + { + $this->expectException(ValueError::class); + CoreFont::fromFamilyStyle('UnknownFont', ''); + } +} diff --git a/test/unit/DocumentCatalogTest.php b/test/unit/DocumentCatalogTest.php new file mode 100644 index 0000000..aa5ff81 --- /dev/null +++ b/test/unit/DocumentCatalogTest.php @@ -0,0 +1,169 @@ +assertSame(PdfVersion::V1_7, $catalog->version); + $this->assertSame(0, $catalog->pageTree()->count()); + $this->assertNull($catalog->info()); + $this->assertNull($catalog->viewerPreferences()); + } + + public function testAddPage(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $catalog->addPage($page); + + $this->assertSame(1, $catalog->pageTree()->count()); + $this->assertSame($page, $catalog->pageTree()->pages()[0]); + } + + public function testMultiplePages(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::Letter))); + $this->assertSame(2, $catalog->pageTree()->count()); + } + + public function testSetInfo(): void + { + $catalog = new DocumentCatalog(); + $info = new DocumentInfo(title: 'Test'); + $catalog->setInfo($info); + $this->assertSame($info, $catalog->info()); + } + + public function testSetViewerPreferences(): void + { + $catalog = new DocumentCatalog(); + $prefs = new ViewerPreferences(zoomMode: ZoomMode::FullWidth); + $catalog->setViewerPreferences($prefs); + $this->assertSame($prefs, $catalog->viewerPreferences()); + } + + public function testPageMediaBox(): void + { + $rect = Rectangle::fromPageFormat(PageFormat::A4); + $page = new Page($rect); + $this->assertSame($rect, $page->mediaBox); + $this->assertSame(595.28, $page->mediaBox->width()); + $this->assertSame(841.89, $page->mediaBox->height()); + } + + public function testPageContentStreams(): void + { + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $this->assertEmpty($page->contentStreams()); + + $resources = new ResourceDictionary(); + $stream = new ContentStream('BT /F1 12 Tf ET', $resources); + $page->addContentStream($stream); + + $this->assertCount(1, $page->contentStreams()); + $this->assertSame('BT /F1 12 Tf ET', $page->contentStreams()[0]->operators); + } + + public function testPageResourcesMergeFromContentStreams(): void + { + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $font = CoreFont::Helvetica->toFont(); + + $resources = new ResourceDictionary(); + $resources->addFont('F1', $font); + $stream = new ContentStream('BT /F1 12 Tf ET', $resources); + $page->addContentStream($stream); + + $this->assertArrayHasKey('F1', $page->resourceDictionary()->fonts()); + } + + public function testResourceDictionaryMerge(): void + { + $rd1 = new ResourceDictionary(); + $rd1->addFont('F1', CoreFont::Helvetica->toFont()); + + $rd2 = new ResourceDictionary(); + $rd2->addFont('F2', CoreFont::CourierBold->toFont()); + + $rd1->merge($rd2); + + $this->assertCount(2, $rd1->fonts()); + $this->assertArrayHasKey('F1', $rd1->fonts()); + $this->assertArrayHasKey('F2', $rd1->fonts()); + } + + public function testResourceDictionaryMergeDoesNotOverwrite(): void + { + $helvetica = CoreFont::Helvetica->toFont(); + $courier = CoreFont::Courier->toFont(); + + $rd1 = new ResourceDictionary(); + $rd1->addFont('F1', $helvetica); + + $rd2 = new ResourceDictionary(); + $rd2->addFont('F1', $courier); + + $rd1->merge($rd2); + + $this->assertSame('Helvetica', $rd1->fonts()['F1']->pdfName()); + } + + public function testResourceDictionaryIsEmpty(): void + { + $rd = new ResourceDictionary(); + $this->assertTrue($rd->isEmpty()); + + $rd->addFont('F1', CoreFont::Helvetica->toFont()); + $this->assertFalse($rd->isEmpty()); + } + + public function testPageTreeCountAndPages(): void + { + $tree = new PageTree(); + $this->assertSame(0, $tree->count()); + + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $tree->addPage($page); + + $this->assertSame(1, $tree->count()); + $this->assertSame([$page], $tree->pages()); + } + + public function testDestination(): void + { + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $dest = new Destination($page, top: 700.0, left: 0.0); + + $this->assertSame($page, $dest->page); + $this->assertSame(700.0, $dest->top); + $this->assertSame(0.0, $dest->left); + $this->assertNull($dest->zoom); + } +} diff --git a/test/unit/DocumentInfoTest.php b/test/unit/DocumentInfoTest.php new file mode 100644 index 0000000..b5c72f8 --- /dev/null +++ b/test/unit/DocumentInfoTest.php @@ -0,0 +1,40 @@ +assertNull($info->title); + $this->assertNull($info->author); + $this->assertNull($info->subject); + $this->assertNull($info->keywords); + $this->assertNull($info->creator); + $this->assertNull($info->creationDate); + } + + public function testCustomValues(): void + { + $info = new DocumentInfo( + title: 'My PDF', + author: 'Test Author', + subject: 'Testing', + keywords: 'pdf test', + creator: 'Horde', + creationDate: 'D:20260426120000', + ); + $this->assertSame('My PDF', $info->title); + $this->assertSame('Test Author', $info->author); + $this->assertSame('Testing', $info->subject); + $this->assertSame('pdf test', $info->keywords); + $this->assertSame('Horde', $info->creator); + $this->assertSame('D:20260426120000', $info->creationDate); + } +} diff --git a/test/unit/EnumTest.php b/test/unit/EnumTest.php new file mode 100644 index 0000000..35da9d4 --- /dev/null +++ b/test/unit/EnumTest.php @@ -0,0 +1,165 @@ +assertSame('P', Orientation::Portrait->value); + $this->assertSame('L', Orientation::Landscape->value); + $this->assertCount(2, Orientation::cases()); + } + + public function testUnitScaleFactors(): void + { + $this->assertSame(1.0, Unit::Point->scaleFactor()); + $this->assertEqualsWithDelta(72.0 / 25.4, Unit::Millimeter->scaleFactor(), 0.0001); + $this->assertEqualsWithDelta(72.0 / 2.54, Unit::Centimeter->scaleFactor(), 0.0001); + $this->assertSame(72.0, Unit::Inch->scaleFactor()); + } + + public function testUnitFromString(): void + { + $this->assertSame(Unit::Point, Unit::from('pt')); + $this->assertSame(Unit::Millimeter, Unit::from('mm')); + $this->assertSame(Unit::Centimeter, Unit::from('cm')); + $this->assertSame(Unit::Inch, Unit::from('in')); + } + + public function testPageFormatDimensions(): void + { + [$w, $h] = PageFormat::A4->dimensions(); + $this->assertSame(595.28, $w); + $this->assertSame(841.89, $h); + + [$w, $h] = PageFormat::A3->dimensions(); + $this->assertSame(841.89, $w); + $this->assertSame(1190.55, $h); + + [$w, $h] = PageFormat::Letter->dimensions(); + $this->assertSame(612.0, $w); + $this->assertSame(792.0, $h); + } + + public function testPageFormatCount(): void + { + $this->assertCount(5, PageFormat::cases()); + } + + public function testColorModelValues(): void + { + $this->assertSame('rgb', ColorModel::Rgb->value); + $this->assertSame('cmyk', ColorModel::Cmyk->value); + $this->assertSame('gray', ColorModel::Gray->value); + $this->assertSame('hex', ColorModel::Hex->value); + } + + public function testShapeStylePdfOperators(): void + { + $this->assertSame('S', ShapeStyle::Draw->pdfOperator()); + $this->assertSame('f', ShapeStyle::Fill->pdfOperator()); + $this->assertSame('B', ShapeStyle::DrawAndFill->pdfOperator()); + } + + public function testZoomModeValues(): void + { + $this->assertSame('fullpage', ZoomMode::FullPage->value); + $this->assertSame('fullwidth', ZoomMode::FullWidth->value); + $this->assertSame('real', ZoomMode::Real->value); + $this->assertSame('default', ZoomMode::DefaultMode->value); + } + + public function testLayoutModeValues(): void + { + $this->assertSame('single', LayoutMode::Single->value); + $this->assertSame('continuous', LayoutMode::Continuous->value); + $this->assertSame('two', LayoutMode::Two->value); + $this->assertSame('default', LayoutMode::DefaultMode->value); + } + + public function testDocumentStateValues(): void + { + $this->assertSame(0, DocumentState::Initial->value); + $this->assertSame(1, DocumentState::Open->value); + $this->assertSame(2, DocumentState::PageOpen->value); + $this->assertSame(3, DocumentState::Closed->value); + } + + public function testTextAlignValues(): void + { + $this->assertSame('L', TextAlign::Left->value); + $this->assertSame('C', TextAlign::Center->value); + $this->assertSame('R', TextAlign::Right->value); + $this->assertSame('J', TextAlign::Justify->value); + } + + public function testCellNextPositionValues(): void + { + $this->assertSame(0, CellNextPosition::ToRight->value); + $this->assertSame(1, CellNextPosition::NextLine->value); + $this->assertSame(2, CellNextPosition::Below->value); + } + + public function testPdfVersionHeader(): void + { + $this->assertSame('%PDF-1.4', PdfVersion::V1_4->header()); + $this->assertSame('%PDF-1.7', PdfVersion::V1_7->header()); + $this->assertSame('%PDF-2.0', PdfVersion::V2_0->header()); + $this->assertCount(4, PdfVersion::cases()); + } + + public function testFontStyleValues(): void + { + $this->assertSame('', FontStyle::Regular->value); + $this->assertSame('B', FontStyle::Bold->value); + $this->assertSame('I', FontStyle::Italic->value); + $this->assertSame('BI', FontStyle::BoldItalic->value); + } + + public function testLineCapValues(): void + { + $this->assertSame(0, LineCap::Butt->value); + $this->assertSame(1, LineCap::Round->value); + $this->assertSame(2, LineCap::Square->value); + } + + public function testFontEncodingValues(): void + { + $this->assertSame('WinAnsiEncoding', FontEncoding::WinAnsi->value); + $this->assertSame('MacRomanEncoding', FontEncoding::MacRoman->value); + $this->assertSame('Symbol', FontEncoding::Symbol->value); + $this->assertSame('ZapfDingbats', FontEncoding::ZapfDingbats->value); + } +} diff --git a/test/unit/FeatureParityTest.php b/test/unit/FeatureParityTest.php new file mode 100644 index 0000000..a45fa59 --- /dev/null +++ b/test/unit/FeatureParityTest.php @@ -0,0 +1,857 @@ +serialize($catalog); + } + + private function catalogWithPage(PageFormat $format = PageFormat::A4): array + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat($format)); + return [$catalog, $page]; + } + + // ------------------------------------------------------- + // Writer::__construct / page setup parity + // ------------------------------------------------------- + + public function testPageFormatA4(): void + { + [$catalog, $page] = $this->catalogWithPage(PageFormat::A4); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('[0.00 0.00 595.28 841.89]', $pdf); + } + + public function testPageFormatLetter(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::Letter)); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('[0.00 0.00 612.00 792.00]', $pdf); + } + + public function testLandscapeOrientation(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4, Orientation::Landscape)); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('[0.00 0.00 841.89 595.28]', $pdf); + } + + public function testMultiplePageFormats(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::Letter))); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('[0.00 0.00 595.28 841.89]', $pdf); + $this->assertStringContainsString('[0.00 0.00 612.00 792.00]', $pdf); + $this->assertStringContainsString('/Count 2', $pdf); + } + + // ------------------------------------------------------- + // Writer::setFont / font handling parity + // All 14 core fonts must serialize correctly + // ------------------------------------------------------- + + public function testAllCoreFontsSerialize(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $builder = new ContentStreamBuilder(); + $builder->beginText(); + foreach (CoreFont::cases() as $cf) { + $builder->setFont($cf->toFont(), 10.0); + } + $builder->endText(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertSame(14, substr_count($pdf, '/Type /Font')); + $this->assertStringContainsString('/BaseFont /Courier', $pdf); + $this->assertStringContainsString('/BaseFont /Courier-Bold', $pdf); + $this->assertStringContainsString('/BaseFont /Courier-Oblique', $pdf); + $this->assertStringContainsString('/BaseFont /Courier-BoldOblique', $pdf); + $this->assertStringContainsString('/BaseFont /Helvetica', $pdf); + $this->assertStringContainsString('/BaseFont /Helvetica-Bold', $pdf); + $this->assertStringContainsString('/BaseFont /Helvetica-Oblique', $pdf); + $this->assertStringContainsString('/BaseFont /Helvetica-BoldOblique', $pdf); + $this->assertStringContainsString('/BaseFont /Times-Roman', $pdf); + $this->assertStringContainsString('/BaseFont /Times-Bold', $pdf); + $this->assertStringContainsString('/BaseFont /Times-Italic', $pdf); + $this->assertStringContainsString('/BaseFont /Times-BoldItalic', $pdf); + $this->assertStringContainsString('/BaseFont /Symbol', $pdf); + $this->assertStringContainsString('/BaseFont /ZapfDingbats', $pdf); + } + + public function testSymbolAndZapfDingbatsOmitEncoding(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $builder = new ContentStreamBuilder(); + $builder->beginText() + ->setFont(CoreFont::Symbol->toFont(), 12.0) + ->setFont(CoreFont::ZapfDingbats->toFont(), 12.0) + ->endText(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $fontSections = []; + preg_match_all('/\/BaseFont \/(\S+).*?endobj/s', $pdf, $matches, PREG_SET_ORDER); + foreach ($matches as $m) { + $fontSections[$m[1]] = $m[0]; + } + + $this->assertArrayHasKey('Symbol', $fontSections); + $this->assertStringNotContainsString('WinAnsiEncoding', $fontSections['Symbol']); + + $this->assertArrayHasKey('ZapfDingbats', $fontSections); + $this->assertStringNotContainsString('WinAnsiEncoding', $fontSections['ZapfDingbats']); + } + + public function testRegularFontsHaveWinAnsiEncoding(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $builder = new ContentStreamBuilder(); + $builder->beginText() + ->setFont(CoreFont::Helvetica->toFont(), 12.0) + ->endText(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + preg_match('/\/BaseFont \/Helvetica\n.*?endobj/s', $pdf, $m); + $this->assertStringContainsString('/Encoding /WinAnsiEncoding', $m[0]); + } + + // ------------------------------------------------------- + // Writer::getStringWidth parity + // ------------------------------------------------------- + + public function testStringWidthCourierUniform(): void + { + $font = CoreFont::Courier->toFont(); + $this->assertEqualsWithDelta( + 600 * 5 * 12.0 / 1000.0, + $font->widthOfString('Hello', 12.0), + 0.001, + ); + } + + public function testStringWidthHelveticaProportional(): void + { + $font = CoreFont::Helvetica->toFont(); + $w = $font->widths(); + $expected = ($w['H'] + $w['i']) * 10.0 / 1000.0; + $this->assertEqualsWithDelta($expected, $font->widthOfString('Hi', 10.0), 0.001); + } + + // ------------------------------------------------------- + // Writer::text parity (direct coordinate text placement) + // ------------------------------------------------------- + + public function testDirectTextPlacement(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->beginText() + ->setFont(CoreFont::Helvetica->toFont(), 12.0) + ->moveTextPosition(72.0, 720.0) + ->showText('Hello, World!') + ->endText(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('BT', $pdf); + $this->assertStringContainsString('/F1 12.00 Tf', $pdf); + $this->assertStringContainsString('72.00 720.00 Td', $pdf); + $this->assertStringContainsString('(Hello, World!) Tj', $pdf); + $this->assertStringContainsString('ET', $pdf); + } + + public function testMultipleFontSwitching(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->beginText() + ->setFont(CoreFont::Helvetica->toFont(), 24.0) + ->moveTextPosition(72.0, 750.0) + ->showText('Title') + ->setFont(CoreFont::Times->toFont(), 12.0) + ->moveTextPosition(0.0, -30.0) + ->showText('Body text') + ->setFont(CoreFont::CourierBold->toFont(), 10.0) + ->moveTextPosition(0.0, -20.0) + ->showText('Code') + ->endText(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('(Title) Tj', $pdf); + $this->assertStringContainsString('(Body text) Tj', $pdf); + $this->assertStringContainsString('(Code) Tj', $pdf); + $this->assertSame(3, substr_count($pdf, '/Type /Font')); + } + + // ------------------------------------------------------- + // Writer::setDrawColor / setFillColor / setTextColor parity + // ------------------------------------------------------- + + public function testRgbColors(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->setFillColor(Color::rgb(1.0, 0.0, 0.0)) + ->setStrokeColor(Color::rgb(0.0, 0.0, 1.0)) + ->rect(72.0, 700.0, 100.0, 50.0) + ->fillAndStroke(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('1.000 0.000 0.000 rg', $pdf); + $this->assertStringContainsString('0.000 0.000 1.000 RG', $pdf); + } + + public function testCmykColors(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->setFillColor(Color::cmyk(1.0, 0.0, 0.0, 0.0)) + ->rect(72.0, 700.0, 100.0, 50.0) + ->fill(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('1.000 0.000 0.000 0.000 k', $pdf); + } + + public function testGrayColor(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->setFillColor(Color::gray(0.5)) + ->rect(72.0, 700.0, 100.0, 50.0) + ->fill(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('0.500 g', $pdf); + } + + public function testHexColor(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->setFillColor(Color::hex('#FF0000')) + ->rect(72.0, 700.0, 100.0, 50.0) + ->fill(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('1.000 0.000 0.000 rg', $pdf); + } + + // ------------------------------------------------------- + // Writer::line / Writer::rect / Writer::circle parity + // ------------------------------------------------------- + + public function testLine(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->moveTo(72.0, 720.0) + ->lineTo(523.0, 720.0) + ->stroke(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('72.00 720.00 m', $pdf); + $this->assertStringContainsString('523.00 720.00 l', $pdf); + $this->assertStringContainsString('S', $pdf); + } + + public function testRectangle(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->setFillColor(Color::rgb(0.9, 0.9, 0.9)) + ->rect(72.0, 650.0, 451.0, 100.0) + ->fill(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('72.00 650.00 451.00 100.00 re', $pdf); + $this->assertStringContainsString('f', $pdf); + } + + public function testRectangleStrokeAndFill(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->setFillColor(Color::rgb(0.9, 0.9, 0.9)) + ->setStrokeColor(Color::rgb(0.0, 0.0, 0.0)) + ->rect(72.0, 650.0, 100.0, 50.0) + ->fillAndStroke(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('B', $pdf); + } + + public function testCircleViaBezierCurves(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $cx = 300.0; + $cy = 400.0; + $r = 50.0; + $k = 0.5522847498; + $builder = new ContentStreamBuilder(); + $builder + ->moveTo($cx + $r, $cy) + ->curveTo($cx + $r, $cy + $r * $k, $cx + $r * $k, $cy + $r, $cx, $cy + $r) + ->curveTo($cx - $r * $k, $cy + $r, $cx - $r, $cy + $r * $k, $cx - $r, $cy) + ->curveTo($cx - $r, $cy - $r * $k, $cx - $r * $k, $cy - $r, $cx, $cy - $r) + ->curveTo($cx + $r * $k, $cy - $r, $cx + $r, $cy - $r * $k, $cx + $r, $cy) + ->stroke(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertSame(4, substr_count($pdf, ' c')); + $this->assertStringContainsString('350.00 400.00 m', $pdf); + } + + // ------------------------------------------------------- + // Writer::setLineWidth parity + // ------------------------------------------------------- + + public function testLineWidth(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->setLineWidth(2.0) + ->moveTo(72.0, 720.0) + ->lineTo(523.0, 720.0) + ->stroke(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('2.00 w', $pdf); + } + + public function testLineCap(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->setLineCap(LineCap::Round) + ->moveTo(72.0, 720.0) + ->lineTo(200.0, 720.0) + ->stroke(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('1 J', $pdf); + } + + public function testDashPattern(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->setDashPattern(new LineDashPattern([5.0, 3.0])) + ->moveTo(72.0, 720.0) + ->lineTo(200.0, 720.0) + ->stroke(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('[5.00 3.00] 0.00 d', $pdf); + } + + // ------------------------------------------------------- + // Writer::setInfo parity + // ------------------------------------------------------- + + public function testDocumentInfoFields(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->setInfo(new DocumentInfo( + title: 'My Title', + author: 'My Author', + subject: 'My Subject', + keywords: 'pdf test horde', + creator: 'Horde', + creationDate: 'D:20260427120000', + )); + + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('/Producer (Horde PDF)', $pdf); + $this->assertStringContainsString('/Title (My Title)', $pdf); + $this->assertStringContainsString('/Author (My Author)', $pdf); + $this->assertStringContainsString('/Subject (My Subject)', $pdf); + $this->assertStringContainsString('/Keywords (pdf test horde)', $pdf); + $this->assertStringContainsString('/Creator (Horde)', $pdf); + $this->assertStringContainsString('/CreationDate (D:20260427120000)', $pdf); + } + + // ------------------------------------------------------- + // Writer::setDisplayMode parity + // ------------------------------------------------------- + + public function testDisplayModeFullPage(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->setViewerPreferences(new ViewerPreferences( + zoomMode: ZoomMode::FullPage, + )); + + $pdf = $this->serialize($catalog); + $this->assertStringContainsString('/Fit]', $pdf); + } + + public function testDisplayModeFullWidth(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->setViewerPreferences(new ViewerPreferences( + zoomMode: ZoomMode::FullWidth, + )); + + $pdf = $this->serialize($catalog); + $this->assertStringContainsString('/FitH null]', $pdf); + } + + public function testDisplayModeReal(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->setViewerPreferences(new ViewerPreferences( + zoomMode: ZoomMode::Real, + )); + + $pdf = $this->serialize($catalog); + $this->assertStringContainsString('/XYZ null null 1]', $pdf); + } + + public function testLayoutModeSingle(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->setViewerPreferences(new ViewerPreferences( + layoutMode: LayoutMode::Single, + )); + + $pdf = $this->serialize($catalog); + $this->assertStringContainsString('/PageLayout /SinglePage', $pdf); + } + + public function testLayoutModeContinuous(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->setViewerPreferences(new ViewerPreferences( + layoutMode: LayoutMode::Continuous, + )); + + $pdf = $this->serialize($catalog); + $this->assertStringContainsString('/PageLayout /OneColumn', $pdf); + } + + // ------------------------------------------------------- + // Writer::link / Writer::addLink / Writer::setLink parity + // ------------------------------------------------------- + + public function testExternalUriLink(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $rect = new Rectangle(72.0, 700.0, 200.0, 720.0); + $page->addAnnotation(new LinkAnnotation($rect, new UriAction('https://www.horde.org/'))); + + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('/Annots [', $pdf); + $this->assertStringContainsString('/Type /Annot', $pdf); + $this->assertStringContainsString('/Subtype /Link', $pdf); + $this->assertStringContainsString('/Border [0 0 0]', $pdf); + $this->assertStringContainsString('/S /URI', $pdf); + $this->assertStringContainsString('/URI (https://www.horde.org/)', $pdf); + } + + public function testInternalGoToLink(): void + { + $catalog = new DocumentCatalog(); + $page1 = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $page2 = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $dest = new Destination($page2, top: 841.89); + $rect = new Rectangle(72.0, 700.0, 200.0, 720.0); + $page1->addAnnotation(new LinkAnnotation($rect, new GoToAction($dest))); + + $catalog->addPage($page1); + $catalog->addPage($page2); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('/Dest [', $pdf); + $this->assertStringContainsString('/XYZ', $pdf); + } + + // ------------------------------------------------------- + // Writer::image parity (JPEG) + // ------------------------------------------------------- + + public function testJpegImageInPdf(): void + { + if (!function_exists('imagecreatetruecolor')) { + $this->markTestSkipped('GD extension not available'); + } + + $img = imagecreatetruecolor(40, 30); + imagefill($img, 0, 0, imagecolorallocate($img, 255, 0, 0)); + $path = tempnam(sys_get_temp_dir(), 'horde_pdf_parity_') . '.jpg'; + imagejpeg($img, $path, 75); + imagedestroy($img); + + try { + $image = JpegParser::parseFile($path); + + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $builder = new ContentStreamBuilder(); + $builder->drawImage($image, 72.0, 650.0, 200.0, 150.0); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('/Type /XObject', $pdf); + $this->assertStringContainsString('/Subtype /Image', $pdf); + $this->assertStringContainsString('/Width 40', $pdf); + $this->assertStringContainsString('/Height 30', $pdf); + $this->assertStringContainsString('/ColorSpace /DeviceRGB', $pdf); + $this->assertStringContainsString('/Filter /DCTDecode', $pdf); + $this->assertStringContainsString('/BitsPerComponent 8', $pdf); + } finally { + @unlink($path); + } + } + + // ------------------------------------------------------- + // Writer::setCompression parity + // ------------------------------------------------------- + + public function testCompressionEnabledProducesFlateStreams(): void + { + if (!function_exists('gzcompress')) { + $this->markTestSkipped('zlib not available'); + } + + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $builder = new ContentStreamBuilder(); + $builder + ->beginText() + ->setFont(CoreFont::Helvetica->toFont(), 12.0) + ->showText('Compressed text') + ->endText(); + $page->addContentStream($builder->build()); + $catalog->addPage($page); + + $compressed = (new PdfSerializer(compress: true))->serialize($catalog); + $uncompressed = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/Filter /FlateDecode', $compressed); + $this->assertStringNotContainsString('/Filter /FlateDecode', $uncompressed); + } + + // ------------------------------------------------------- + // Writer::writeRotated parity (via save/restore + transform) + // ------------------------------------------------------- + + public function testGraphicsStateSaveRestore(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->save() + ->setLineWidth(3.0) + ->moveTo(100.0, 100.0) + ->lineTo(200.0, 200.0) + ->stroke() + ->restore(); + + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('q', $pdf); + $this->assertStringContainsString('Q', $pdf); + } + + // ------------------------------------------------------- + // Writer::getOutput / Writer::save parity + // ------------------------------------------------------- + + public function testOutputIsValidPdf(): void + { + $catalog = new DocumentCatalog(); + $catalog->setInfo(new DocumentInfo(title: 'Validity Test')); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $builder = new ContentStreamBuilder(); + $builder + ->beginText() + ->setFont(CoreFont::Helvetica->toFont(), 12.0) + ->moveTextPosition(72.0, 720.0) + ->showText('Testing PDF validity') + ->endText(); + $page->addContentStream($builder->build()); + $catalog->addPage($page); + + $pdf = $this->serialize($catalog); + + $this->assertStringStartsWith('%PDF-1.7', $pdf); + $this->assertStringContainsString("%%EOF\n", $pdf); + + preg_match('/startxref\n(\d+)\n/', $pdf, $m); + $this->assertNotEmpty($m, 'Missing startxref'); + $xrefOffset = (int) $m[1]; + $this->assertSame('xref', substr($pdf, $xrefOffset, 4)); + + preg_match('/\/Size (\d+)/', $pdf, $sizeMatch); + preg_match('/xref\n0 (\d+)/', $pdf, $countMatch); + $this->assertSame($sizeMatch[1], $countMatch[1]); + } + + // ------------------------------------------------------- + // Writer::text with special characters parity + // ------------------------------------------------------- + + public function testSpecialCharacterEscaping(): void + { + [$catalog, $page] = $this->catalogWithPage(); + $builder = new ContentStreamBuilder(); + $builder + ->beginText() + ->setFont(CoreFont::Helvetica->toFont(), 12.0) + ->showText('Price: $100 (discounted) 50\\% off') + ->endText(); + $page->addContentStream($builder->build()); + $catalog->addPage($page); + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('(Price: $100 \\(discounted\\) 50\\\\% off) Tj', $pdf); + } + + // ------------------------------------------------------- + // Multi-page with different content per page + // (Writer::addPage + content per page) + // ------------------------------------------------------- + + public function testMultiPageWithContent(): void + { + $catalog = new DocumentCatalog(); + + $page1 = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $b1 = new ContentStreamBuilder(); + $b1->beginText() + ->setFont(CoreFont::Helvetica->toFont(), 24.0) + ->moveTextPosition(72.0, 750.0) + ->showText('Page 1') + ->endText(); + $page1->addContentStream($b1->build()); + + $page2 = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $b2 = new ContentStreamBuilder(); + $b2->beginText() + ->setFont(CoreFont::Times->toFont(), 24.0) + ->moveTextPosition(72.0, 750.0) + ->showText('Page 2') + ->endText(); + $page2->addContentStream($b2->build()); + + $catalog->addPage($page1); + $catalog->addPage($page2); + + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('/Count 2', $pdf); + $this->assertStringContainsString('(Page 1) Tj', $pdf); + $this->assertStringContainsString('(Page 2) Tj', $pdf); + $this->assertStringContainsString('/BaseFont /Helvetica', $pdf); + $this->assertStringContainsString('/BaseFont /Times-Roman', $pdf); + } + + // ------------------------------------------------------- + // Prove legacy Writer hello-world equivalent + // ------------------------------------------------------- + + /** + * Produces the same visual result as the legacy testHelloWorldUncompressed + * but via the object graph. Validates PDF structure, not byte-for-byte match. + */ + public function testHelloWorldEquivalent(): void + { + $catalog = new DocumentCatalog(); + $catalog->setInfo(new DocumentInfo(creationDate: 'D:20071105152947')); + + $page1 = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $b1 = new ContentStreamBuilder(); + $b1->beginText() + ->setFont(CoreFont::Courier->toFont(), 40.0) + ->moveTextPosition(10.0, 10.0) + ->showText('Hello World') + ->endText(); + $page1->addContentStream($b1->build()); + + $page2 = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $b2 = new ContentStreamBuilder(); + $b2->beginText() + ->setFont(CoreFont::Helvetica->toFont(), 40.0) + ->moveTextPosition(10.0, 10.0) + ->showText('Hello World') + ->setFont(CoreFont::HelveticaBold->toFont(), 40.0) + ->moveTextPosition(0.0, -50.0) + ->showText('Hello World') + ->setFont(CoreFont::HelveticaItalic->toFont(), 40.0) + ->moveTextPosition(0.0, -50.0) + ->showText('Hello World') + ->setFont(CoreFont::HelveticaBoldItalic->toFont(), 40.0) + ->moveTextPosition(0.0, -50.0) + ->showText('Hello World') + ->endText(); + $page2->addContentStream($b2->build()); + + $page3 = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $b3 = new ContentStreamBuilder(); + $b3->beginText() + ->setFont(CoreFont::Helvetica->toFont(), 10.0) + ->moveTextPosition(10.0, 10.0) + ->showText('Hello World 10pt') + ->setFont(CoreFont::Helvetica->toFont(), 14.0) + ->moveTextPosition(0.0, -20.0) + ->showText('Hello World 14pt') + ->setFont(CoreFont::Helvetica->toFont(), 18.0) + ->moveTextPosition(0.0, -24.0) + ->showText('Hello World 18pt') + ->setFont(CoreFont::Helvetica->toFont(), 22.0) + ->moveTextPosition(0.0, -28.0) + ->showText('Hello World 22pt') + ->endText(); + $page3->addContentStream($b3->build()); + + $catalog->addPage($page1); + $catalog->addPage($page2); + $catalog->addPage($page3); + + $pdf = $this->serialize($catalog); + + $this->assertStringContainsString('/Count 3', $pdf); + $this->assertStringContainsString('/BaseFont /Courier', $pdf); + $this->assertStringContainsString('/BaseFont /Helvetica-Bold', $pdf); + $this->assertStringContainsString('/BaseFont /Helvetica-Oblique', $pdf); + $this->assertStringContainsString('/BaseFont /Helvetica-BoldOblique', $pdf); + $this->assertStringContainsString('/CreationDate (D:20071105152947)', $pdf); + $this->assertStringContainsString('(Hello World) Tj', $pdf); + } +} diff --git a/test/unit/HeaderFooterStylesPdf.php b/test/unit/HeaderFooterStylesPdf.php new file mode 100644 index 0000000..8ab8f6b --- /dev/null +++ b/test/unit/HeaderFooterStylesPdf.php @@ -0,0 +1,53 @@ +setFont('Arial', 'B', 15); + $w = $this->getStringWidth($this->_info['title']) + 6; + $this->setX((210 - $w) / 2); + $this->setDrawColor('rgb', 0 / 255, 80 / 255, 180 / 255); + $this->setFillColor('rgb', 230 / 255, 230 / 255, 0 / 255); + $this->setTextColor('rgb', 220 / 255, 50 / 255, 50 / 255); + $this->setLineWidth(1); + $this->cell($w, 9, $this->_info['title'], 1, 1, 'C', 1); + $this->newLine(10); + } + + public function footer() + { + $this->setY(-15); + $this->setFont('Arial', 'I', 8); + $this->setTextColor('gray', 128 / 255); + $this->cell(0, 10, 'Page ' . $this->getPageNo(), 0, 0, 'C'); + } + + public function chapterTitle(int $num, string $label): void + { + $this->setFont('Arial', '', 12); + $this->setFillColor('rgb', 200 / 255, 220 / 255, 255 / 255); + $this->cell(0, 6, "Chapter $num : $label", 0, 1, 'L', 1); + $this->newLine(4); + } + + public function chapterBody(string $file): void + { + $filename = __DIR__ . "/fixtures/$file"; + $text = file_get_contents($filename); + $this->setFont('Times', '', 12); + $this->multiCell(0, 5, $text); + $this->newLine(); + $this->setFont('', 'I'); + $this->cell(0, 5, '(end of extract)'); + } + + public function printChapter(int $num, string $title, string $file): void + { + $this->addPage(); + $this->chapterTitle($num, $title); + $this->chapterBody($file); + } +} diff --git a/test/unit/ImageXObjectTest.php b/test/unit/ImageXObjectTest.php new file mode 100644 index 0000000..106c5de --- /dev/null +++ b/test/unit/ImageXObjectTest.php @@ -0,0 +1,30 @@ +assertSame(100, $image->width); + $this->assertSame(200, $image->height); + $this->assertSame('DeviceRGB', $image->colorSpace->pdfName()); + $this->assertSame(8, $image->bitsPerComponent); + $this->assertSame('DCTDecode', $image->filter); + $this->assertSame('fake-jpeg-data', $image->data); + } +} diff --git a/test/unit/JpegParserTest.php b/test/unit/JpegParserTest.php new file mode 100644 index 0000000..c4571ac --- /dev/null +++ b/test/unit/JpegParserTest.php @@ -0,0 +1,67 @@ +fixtureDir = __DIR__ . '/fixtures'; + } + + public function testParseJpegFile(): void + { + $path = $this->createTempJpeg(80, 60); + $image = JpegParser::parseFile($path); + + $this->assertSame(80, $image->width); + $this->assertSame(60, $image->height); + $this->assertSame('DeviceRGB', $image->colorSpace->pdfName()); + $this->assertSame(8, $image->bitsPerComponent); + $this->assertSame('DCTDecode', $image->filter); + $this->assertNotEmpty($image->data); + + @unlink($path); + } + + public function testNonExistentFileThrows(): void + { + $this->expectException(PdfException::class); + JpegParser::parseFile('/tmp/nonexistent-' . uniqid() . '.jpg'); + } + + public function testNonJpegFileThrows(): void + { + $this->expectException(PdfException::class); + $pngPath = $this->fixtureDir . '/horde-power1.png'; + if (!is_readable($pngPath)) { + $this->markTestSkipped('PNG fixture not available'); + } + JpegParser::parseFile($pngPath); + } + + private function createTempJpeg(int $w, int $h): string + { + if (!function_exists('imagecreatetruecolor')) { + $this->markTestSkipped('GD extension not available'); + } + + $img = imagecreatetruecolor($w, $h); + $red = imagecolorallocate($img, 255, 0, 0); + imagefill($img, 0, 0, $red); + + $path = tempnam(sys_get_temp_dir(), 'horde_pdf_test_') . '.jpg'; + imagejpeg($img, $path, 75); + imagedestroy($img); + + return $path; + } +} diff --git a/test/unit/PageNumberFooter.php b/test/unit/PageNumberFooter.php new file mode 100644 index 0000000..b8bf8bb --- /dev/null +++ b/test/unit/PageNumberFooter.php @@ -0,0 +1,25 @@ +headerCallCount++; + } + + public function writeFooter(PdfWriter $writer): void + { + $this->footerCallCount++; + $writer->setY(-30); + $writer->setFont('Times', '', 10); + $writer->cell(0, 10, 'Page ' . $writer->getPageNo(), 0, 0, 'C'); + } +} diff --git a/test/unit/PdfSerializerTest.php b/test/unit/PdfSerializerTest.php new file mode 100644 index 0000000..f162574 --- /dev/null +++ b/test/unit/PdfSerializerTest.php @@ -0,0 +1,431 @@ +addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringStartsWith('%PDF-1.7', $pdf); + $this->assertStringEndsWith("%%EOF\n", $pdf); + $this->assertStringContainsString('/Type /Page', $pdf); + $this->assertStringContainsString('/Type /Pages', $pdf); + $this->assertStringContainsString('/Type /Catalog', $pdf); + $this->assertStringContainsString('xref', $pdf); + $this->assertStringContainsString('trailer', $pdf); + $this->assertStringContainsString('startxref', $pdf); + } + + public function testPdfVersionHeader(): void + { + $catalog = new DocumentCatalog(version: PdfVersion::V1_4); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + $this->assertStringStartsWith('%PDF-1.4', $pdf); + } + + public function testSinglePageWithText(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $font = CoreFont::Helvetica->toFont(); + $builder = new ContentStreamBuilder(); + $stream = $builder + ->beginText() + ->setFont($font, 12.0) + ->moveTextPosition(72.0, 720.0) + ->showText('Hello, World!') + ->endText() + ->build(); + + $page->addContentStream($stream); + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/Type /Font', $pdf); + $this->assertStringContainsString('/BaseFont /Helvetica', $pdf); + $this->assertStringContainsString('/Encoding /WinAnsiEncoding', $pdf); + $this->assertStringContainsString('BT', $pdf); + $this->assertStringContainsString('(Hello, World!) Tj', $pdf); + $this->assertStringContainsString('ET', $pdf); + } + + public function testMultiPage(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::Letter))); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/Count 2', $pdf); + $this->assertSame(2, substr_count($pdf, '/Type /Page' . "\n")); + + preg_match('/\/Kids \[(.+?)\]/', $pdf, $matches); + $this->assertNotEmpty($matches); + $kids = trim($matches[1]); + $refs = preg_split('/\s+/', $kids); + $this->assertCount(6, $refs); + } + + public function testDocumentInfo(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->setInfo(new DocumentInfo( + title: 'Test PDF', + author: 'Horde Test', + creationDate: 'D:20260427120000', + )); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/Title (Test PDF)', $pdf); + $this->assertStringContainsString('/Author (Horde Test)', $pdf); + $this->assertStringContainsString('/CreationDate (D:20260427120000)', $pdf); + $this->assertStringContainsString('/Producer (Horde PDF)', $pdf); + } + + public function testSymbolFontNoEncoding(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $font = CoreFont::Symbol->toFont(); + $builder = new ContentStreamBuilder(); + $stream = $builder + ->beginText() + ->setFont($font, 12.0) + ->showText('abc') + ->endText() + ->build(); + + $page->addContentStream($stream); + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/BaseFont /Symbol', $pdf); + $this->assertStringNotContainsString('/Encoding /WinAnsiEncoding', $pdf); + } + + public function testCompression(): void + { + if (!function_exists('gzcompress')) { + $this->markTestSkipped('zlib not available'); + } + + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $font = CoreFont::Courier->toFont(); + $builder = new ContentStreamBuilder(); + $stream = $builder + ->beginText() + ->setFont($font, 12.0) + ->showText('Compressed content test string that should be long enough') + ->endText() + ->build(); + + $page->addContentStream($stream); + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: true))->serialize($catalog); + + $this->assertStringContainsString('/Filter /FlateDecode', $pdf); + } + + public function testUncompressed(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringNotContainsString('/Filter /FlateDecode', $pdf); + } + + public function testImageSerialization(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $image = new ImageXObject( + width: 100, + height: 80, + colorSpace: new DeviceRgb(), + bitsPerComponent: 8, + filter: 'DCTDecode', + data: 'fake-jpeg-data-for-test', + ); + + $builder = new ContentStreamBuilder(); + $stream = $builder + ->drawImage($image, 72.0, 650.0, 200.0, 160.0) + ->build(); + + $page->addContentStream($stream); + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/Type /XObject', $pdf); + $this->assertStringContainsString('/Subtype /Image', $pdf); + $this->assertStringContainsString('/Width 100', $pdf); + $this->assertStringContainsString('/Height 80', $pdf); + $this->assertStringContainsString('/ColorSpace /DeviceRGB', $pdf); + $this->assertStringContainsString('/Filter /DCTDecode', $pdf); + $this->assertStringContainsString('/BitsPerComponent 8', $pdf); + } + + public function testUriLinkAnnotation(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $rect = new Rectangle(72.0, 700.0, 200.0, 720.0); + $link = new LinkAnnotation($rect, new UriAction('https://www.horde.org/')); + $page->addAnnotation($link); + + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/Annots [', $pdf); + $this->assertStringContainsString('/Subtype /Link', $pdf); + $this->assertStringContainsString('/S /URI', $pdf); + $this->assertStringContainsString('/URI (https://www.horde.org/)', $pdf); + } + + public function testInternalLinkAnnotation(): void + { + $catalog = new DocumentCatalog(); + + $page1 = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $page2 = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $dest = new Destination($page2, top: 841.89); + $rect = new Rectangle(72.0, 700.0, 200.0, 720.0); + $link = new LinkAnnotation($rect, new GoToAction($dest)); + $page1->addAnnotation($link); + + $catalog->addPage($page1); + $catalog->addPage($page2); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/Annots [', $pdf); + $this->assertStringContainsString('/Dest [', $pdf); + $this->assertStringContainsString('/XYZ', $pdf); + } + + public function testViewerPreferencesFullWidth(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->setViewerPreferences(new ViewerPreferences( + zoomMode: ZoomMode::FullWidth, + layoutMode: LayoutMode::Single, + )); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/OpenAction [', $pdf); + $this->assertStringContainsString('/FitH null', $pdf); + $this->assertStringContainsString('/PageLayout /SinglePage', $pdf); + } + + public function testXrefTableFormat(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + preg_match('/xref\n0 (\d+)\n/', $pdf, $matches); + $this->assertNotEmpty($matches); + $count = (int) $matches[1]; + $this->assertGreaterThan(1, $count); + + $this->assertMatchesRegularExpression('/0000000000 65535 f /', $pdf); + $this->assertMatchesRegularExpression('/\d{10} 00000 n /', $pdf); + } + + public function testXrefOffsetsPointToObjects(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + $font = CoreFont::Helvetica->toFont(); + $stream = (new ContentStreamBuilder()) + ->beginText() + ->setFont($font, 12.0) + ->showText('Test') + ->endText() + ->build(); + $page->addContentStream($stream); + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + preg_match('/xref\n0 (\d+)\n(.*?)\ntrailer/s', $pdf, $xrefMatch); + $this->assertNotEmpty($xrefMatch, 'Could not find xref section'); + + $lines = explode("\n", trim($xrefMatch[2])); + foreach ($lines as $idx => $line) { + if ($idx === 0) { + $this->assertStringContainsString('65535 f', $line); + continue; + } + + preg_match('/^(\d{10}) 00000 n/', $line, $entryMatch); + $this->assertNotEmpty($entryMatch, "Invalid xref entry at index $idx: $line"); + + $offset = (int) $entryMatch[1]; + $objHeader = substr($pdf, $offset, 20); + $this->assertMatchesRegularExpression( + '/^\d+ 0 obj/', + $objHeader, + "Xref offset $offset for object $idx does not point to a valid object: " . substr($pdf, $offset, 40), + ); + } + } + + public function testStringEscaping(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + $catalog->setInfo(new DocumentInfo( + title: 'Test (with parens) and \\backslash', + )); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/Title (Test \\(with parens\\) and \\\\backslash)', $pdf); + } + + public function testMediaBoxFormat(): void + { + $catalog = new DocumentCatalog(); + $catalog->addPage(new Page(Rectangle::fromPageFormat(PageFormat::A4))); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('[0.00 0.00 595.28 841.89]', $pdf); + } + + public function testMultipleFonts(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $helvetica = CoreFont::Helvetica->toFont(); + $courier = CoreFont::Courier->toFont(); + $builder = new ContentStreamBuilder(); + $stream = $builder + ->beginText() + ->setFont($helvetica, 12.0) + ->showText('Helvetica text') + ->setFont($courier, 10.0) + ->showText('Courier text') + ->endText() + ->build(); + + $page->addContentStream($stream); + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/BaseFont /Helvetica', $pdf); + $this->assertStringContainsString('/BaseFont /Courier', $pdf); + $this->assertSame(2, substr_count($pdf, '/Type /Font')); + } + + public function testPngImageWithDecodeParmsAndTransparency(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $image = new ImageXObject( + width: 10, + height: 10, + colorSpace: new DeviceRgb(), + bitsPerComponent: 8, + filter: 'FlateDecode', + data: 'fake-png-data', + decodeParms: '/DecodeParms <>', + transparency: [255, 0, 128], + ); + + $builder = new ContentStreamBuilder(); + $builder->drawImage($image, 72.0, 700.0, 100.0, 100.0); + $page->addContentStream($builder->build()); + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/Type /XObject', $pdf); + $this->assertStringContainsString('/Filter /FlateDecode', $pdf); + $this->assertStringContainsString('/Predictor 15', $pdf); + $this->assertStringContainsString('/Mask [255 255 0 0 128 128 ]', $pdf); + } + + public function testPngIndexedColorWithPalette(): void + { + $catalog = new DocumentCatalog(); + $page = new Page(Rectangle::fromPageFormat(PageFormat::A4)); + + $palette = str_repeat("\xFF\x00\x00", 1) . str_repeat("\x00\xFF\x00", 1); + $image = new ImageXObject( + width: 5, + height: 5, + colorSpace: new DeviceRgb(), + bitsPerComponent: 8, + filter: 'FlateDecode', + data: 'fake-indexed-data', + decodeParms: '/DecodeParms <>', + palette: $palette, + ); + + $builder = new ContentStreamBuilder(); + $builder->drawImage($image, 72.0, 700.0, 50.0, 50.0); + $page->addContentStream($builder->build()); + $catalog->addPage($page); + + $pdf = (new PdfSerializer(compress: false))->serialize($catalog); + + $this->assertStringContainsString('/ColorSpace [/Indexed /DeviceRGB 1', $pdf); + $this->assertStringNotContainsString('/ColorSpace /DeviceRGB', $pdf); + } +} diff --git a/test/unit/PdfWriterParityTest.php b/test/unit/PdfWriterParityTest.php new file mode 100644 index 0000000..d4e4c17 --- /dev/null +++ b/test/unit/PdfWriterParityTest.php @@ -0,0 +1,541 @@ + 'Letter', 'unit' => 'pt']); + $w->setCompression(false); + return $w; + } + + private function assertValidPdf(string $pdf): void + { + $this->assertStringStartsWith('%PDF-', $pdf); + $this->assertStringContainsString("%%EOF\n", $pdf); + + preg_match('/startxref\n(\d+)\n/', $pdf, $m); + $this->assertNotEmpty($m, 'Missing startxref'); + $xrefOffset = (int) $m[1]; + $this->assertSame('xref', substr($pdf, $xrefOffset, 4)); + } + + // ----------------------------------------------------------- + // Mnemo usage pattern (note export to PDF) + // ----------------------------------------------------------- + + public function testMnemoPatternProducesValidPdf(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + $w->addPage(); + + $w->setFont('Times', 'B', 24); + $w->multiCell(0, 24, 'Shopping List', 'B', 'L'); + $w->newLine(20); + + $w->setFont('Times', '', 14); + $w->write(14, "- Eggs\n- Milk\n- Bread\n- Butter"); + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringContainsString('/BaseFont /Times-Bold', $pdf); + $this->assertStringContainsString('/BaseFont /Times-Roman', $pdf); + $this->assertStringContainsString('(Shopping List) Tj', $pdf); + $this->assertStringContainsString('(- Eggs) Tj', $pdf); + $this->assertStringContainsString('(- Bread) Tj', $pdf); + } + + public function testMnemoLongNoteTriggersPageBreaks(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + $w->addPage(); + + $w->setFont('Times', 'B', 24); + $w->multiCell(0, 24, 'Long Note', 'B', 'L'); + $w->newLine(20); + + $w->setFont('Times', '', 14); + $body = str_repeat("This is a paragraph of note content that spans multiple lines. ", 200); + $w->write(14, $body); + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertGreaterThan(1, $w->getPageNo()); + preg_match('/\/Count (\d+)/', $pdf, $m); + $this->assertGreaterThan(1, (int) $m[1]); + } + + // ----------------------------------------------------------- + // Jonah usage pattern (news article export to PDF) + // ----------------------------------------------------------- + + public function testJonahPatternProducesValidPdf(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + $w->addPage(); + + $w->setFont('Times', 'B', 14); + $w->cell(0, 14, '2026-04-27', 0, 1); + $w->newLine(10); + + $w->setFont('Times', 'B', 24); + $w->multiCell(0, 24, 'Breaking: Horde Pdf Reaches Feature Parity', 'B', 'L'); + $w->newLine(20); + + $w->setFont('Times', '', 14); + $w->write(14, 'The Horde project announced today that its modern PDF writer library has reached full feature parity with the legacy implementation.'); + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringContainsString('(2026-04-27) Tj', $pdf); + $this->assertStringContainsString('(Breaking: Horde Pdf Reaches Feature Parity) Tj', $pdf); + } + + public function testJonahMultipleArticles(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + + for ($i = 1; $i <= 3; $i++) { + $w->addPage(); + $w->setFont('Times', 'B', 14); + $w->cell(0, 14, "Article $i", 0, 1); + $w->newLine(10); + + $w->setFont('Times', '', 14); + $w->write(14, "Body of article $i with enough text to verify rendering."); + } + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringContainsString('/Count 3', $pdf); + $this->assertStringContainsString('(Article 1) Tj', $pdf); + $this->assertStringContainsString('(Article 2) Tj', $pdf); + $this->assertStringContainsString('(Article 3) Tj', $pdf); + } + + // ----------------------------------------------------------- + // Header/footer callback fires on each addPage() and close() + // ----------------------------------------------------------- + + public function testHeaderFooterCallbackFiresOnEachPage(): void + { + $handler = new PageNumberFooter(); + $w = new PdfWriter(headerFooter: $handler, compress: false); + $w->open(); + $w->addPage(); + $w->addPage(); + $w->addPage(); + + $pdf = $w->getOutput(); + + $this->assertSame(3, $handler->headerCallCount); + $this->assertSame(3, $handler->footerCallCount); + } + + public function testFooterContentInPdf(): void + { + $handler = new PageNumberFooter(); + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w = new PdfWriter( + options: new Horde\Pdf\WriterOptions( + unit: Horde\Pdf\Unit::Point, + format: Horde\Pdf\PageFormat::Letter, + ), + headerFooter: $handler, + compress: false, + ); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(0, 14, 'Content', 0, 1); + $w->addPage(); + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringContainsString('(Page 1) Tj', $pdf); + $this->assertStringContainsString('(Page 2) Tj', $pdf); + } + + // ----------------------------------------------------------- + // {nb} alias replaced with total page count + // ----------------------------------------------------------- + + public function testNbAliasReplacedAllPages(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->aliasNbPages(); + $w->open(); + + for ($i = 1; $i <= 5; $i++) { + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(0, 14, "Page $i of {nb}", 0, 1); + } + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringNotContainsString('{nb}', $pdf); + $this->assertStringContainsString('(Page 1 of 5) Tj', $pdf); + $this->assertStringContainsString('(Page 5 of 5) Tj', $pdf); + } + + public function testCustomNbAlias(): void + { + $w = $this->makeWriter(); + $w->aliasNbPages('{total}'); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(0, 14, 'Total pages: {total}', 0, 1); + $w->addPage(); + $w->addPage(); + + $pdf = $w->getOutput(); + + $this->assertStringNotContainsString('{total}', $pdf); + $this->assertStringContainsString('(Total pages: 3) Tj', $pdf); + } + + // ----------------------------------------------------------- + // Auto page break triggers new page + // ----------------------------------------------------------- + + public function testAutoPageBreakWithMultiCell(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $text = str_repeat("Line of text that fills the page and wraps around. ", 500); + $w->multiCell(0, 14, $text); + + $this->assertGreaterThan(1, $w->getPageNo()); + } + + public function testAutoPageBreakWithWriteMethod(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $text = str_repeat("Flowing text content. ", 200); + $w->write(14, $text); + + $this->assertGreaterThan(1, $w->getPageNo()); + } + + public function testNoAutoPageBreakWhenDisabled(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->setAutoPageBreak(false); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + for ($i = 0; $i < 60; $i++) { + $w->cell(0, 14, "Line $i", 0, 1); + } + + $this->assertSame(1, $w->getPageNo()); + } + + // ----------------------------------------------------------- + // Multi-page document with mixed fonts/colors + // ----------------------------------------------------------- + + public function testMixedFontsAndColors(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + + $w->setFont('Helvetica', 'B', 18); + $w->setTextColor(Color::rgb(0.0, 0.0, 1.0)); + $w->cell(0, 18, 'Blue Helvetica Bold Title', 0, 1); + + $w->setFont('Times', '', 12); + $w->setTextColor(Color::gray(0.0)); + $w->cell(0, 12, 'Black Times body text', 0, 1); + + $w->setFont('Courier', 'B', 10); + $w->setTextColor(Color::rgb(1.0, 0.0, 0.0)); + $w->cell(0, 10, 'Red Courier Bold code', 0, 1); + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringContainsString('/BaseFont /Helvetica-Bold', $pdf); + $this->assertStringContainsString('/BaseFont /Times-Roman', $pdf); + $this->assertStringContainsString('/BaseFont /Courier-Bold', $pdf); + $this->assertStringContainsString('(Blue Helvetica Bold Title) Tj', $pdf); + $this->assertStringContainsString('(Black Times body text) Tj', $pdf); + $this->assertStringContainsString('(Red Courier Bold code) Tj', $pdf); + $this->assertStringContainsString('0.000 0.000 1.000 rg', $pdf); + $this->assertStringContainsString('1.000 0.000 0.000 rg', $pdf); + } + + // ----------------------------------------------------------- + // Cell text positioning (operator-level assertions) + // ----------------------------------------------------------- + + public function testCellTextBaselinePosition(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(200, 20, 'Baseline Test', 0, 1); + + $pdf = $w->getOutput(); + + preg_match('/(\d+\.\d+) (\d+\.\d+) Td \(Baseline Test\)/', $pdf, $m); + $this->assertNotEmpty($m, 'Could not find text positioning operators'); + + $textX = (float) $m[1]; + $textY = (float) $m[2]; + + $expectedY = 792.0 - (50.0 + 0.5 * 20.0 + 0.3 * 14.0); + $this->assertEqualsWithDelta($expectedY, $textY, 0.01); + $this->assertGreaterThan(50.0, $textX); + } + + public function testCellCenterAlignPositioning(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Courier', '', 12); + $w->cell(300, 14, 'Centered', 0, 1, 'C'); + + $pdf = $w->getOutput(); + + $strWidth = $w->getStringWidth('Centered'); + + preg_match('/(\d+\.\d+) \d+\.\d+ Td \(Centered\)/', $pdf, $m); + $this->assertNotEmpty($m); + $textX = (float) $m[1]; + + $expectedX = 50.0 + (300.0 - $strWidth) / 2; + $this->assertEqualsWithDelta($expectedX, $textX, 0.5); + } + + public function testCellRightAlignPositioning(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Courier', '', 12); + $cellMargin = (28.35 / 10.0); + $w->cell(300, 14, 'Right', 0, 1, 'R'); + + $pdf = $w->getOutput(); + + $strWidth = $w->getStringWidth('Right'); + + preg_match('/(\d+\.\d+) \d+\.\d+ Td \(Right\)/', $pdf, $m); + $this->assertNotEmpty($m); + $textX = (float) $m[1]; + + $expectedX = 50.0 + 300.0 - $cellMargin - $strWidth; + $this->assertEqualsWithDelta($expectedX, $textX, 0.5); + } + + // ----------------------------------------------------------- + // PNG image embedding + // ----------------------------------------------------------- + + public function testPngImageEmbedded(): void + { + $pngFile = __DIR__ . '/fixtures/horde-power1.png'; + if (!file_exists($pngFile)) { + $this->markTestSkipped('PNG fixture not available'); + } + + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->image($pngFile, 50, 50, 100, 0); + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringContainsString('/Type /XObject', $pdf); + $this->assertStringContainsString('/Subtype /Image', $pdf); + } + + // ----------------------------------------------------------- + // Fill and border combinations + // ----------------------------------------------------------- + + public function testTableLikeLayout(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Helvetica', 'B', 12); + + $w->setFillColor(Color::rgb(0.8, 0.8, 0.8)); + $w->cell(150, 14, 'Name', 1, 0, 'C', true); + $w->cell(100, 14, 'Value', 1, 1, 'C', true); + + $w->setFont('Helvetica', '', 12); + $w->setFillColor(Color::gray(1.0)); + $w->cell(150, 14, 'Width', 1, 0, 'L'); + $w->cell(100, 14, '612 pt', 1, 1, 'R'); + $w->cell(150, 14, 'Height', 1, 0, 'L'); + $w->cell(100, 14, '792 pt', 1, 1, 'R'); + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringContainsString('(Name) Tj', $pdf); + $this->assertStringContainsString('(Value) Tj', $pdf); + $this->assertStringContainsString('(Width) Tj', $pdf); + $this->assertStringContainsString('(612 pt) Tj', $pdf); + $this->assertStringContainsString('re B', $pdf); + } + + // ----------------------------------------------------------- + // Document info via PdfWriter + // ----------------------------------------------------------- + + public function testDocumentInfoViaWriter(): void + { + $w = $this->makeWriter(); + $w->setInfo('Title', 'Test Document'); + $w->setInfo('Author', 'Horde Tests'); + $w->setInfo('Subject', 'Integration Testing'); + $w->open(); + $w->addPage(); + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringContainsString('/Title (Test Document)', $pdf); + $this->assertStringContainsString('/Author (Horde Tests)', $pdf); + $this->assertStringContainsString('/Subject (Integration Testing)', $pdf); + } + + // ----------------------------------------------------------- + // Display mode via PdfWriter + // ----------------------------------------------------------- + + public function testDisplayModeViaWriter(): void + { + $w = $this->makeWriter(); + $w->setDisplayMode('fullwidth', 'single'); + $w->open(); + $w->addPage(); + + $pdf = $w->getOutput(); + + $this->assertValidPdf($pdf); + $this->assertStringContainsString('/FitH null', $pdf); + $this->assertStringContainsString('/PageLayout /SinglePage', $pdf); + } + + // ----------------------------------------------------------- + // Special characters in all text methods + // ----------------------------------------------------------- + + public function testSpecialCharsInAllTextMethods(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $w->cell(0, 14, 'Cell: (parens) and \\back', 0, 1); + $w->multiCell(0, 14, 'Multi: (parens) too'); + $w->write(14, 'Write: (parens) also'); + + $pdf = $w->getOutput(); + + $this->assertStringContainsString('(Cell: \\(parens\\) and \\\\back) Tj', $pdf); + $this->assertStringContainsString('(Multi: \\(parens\\) too) Tj', $pdf); + $this->assertStringContainsString('(Write: \\(parens\\) also) Tj', $pdf); + } + + // ----------------------------------------------------------- + // Xref table validity in writer output + // ----------------------------------------------------------- + + public function testXrefOffsetsAreValid(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(0, 14, 'Xref test', 0, 1); + $w->addPage(); + $w->cell(0, 14, 'Page 2', 0, 1); + + $pdf = $w->getOutput(); + + preg_match('/xref\n0 (\d+)\n(.*?)\ntrailer/s', $pdf, $xrefMatch); + $this->assertNotEmpty($xrefMatch, 'Could not find xref section'); + + $lines = explode("\n", trim($xrefMatch[2])); + foreach ($lines as $idx => $line) { + if ($idx === 0) { + $this->assertStringContainsString('65535 f', $line); + continue; + } + + preg_match('/^(\d{10}) 00000 n/', $line, $entryMatch); + $this->assertNotEmpty($entryMatch, "Invalid xref entry at index $idx: $line"); + + $offset = (int) $entryMatch[1]; + $objHeader = substr($pdf, $offset, 20); + $this->assertMatchesRegularExpression( + '/^\d+ 0 obj/', + $objHeader, + "Xref offset $offset for object $idx does not point to a valid object", + ); + } + } +} diff --git a/test/unit/PdfWriterTest.php b/test/unit/PdfWriterTest.php new file mode 100644 index 0000000..c5b78ee --- /dev/null +++ b/test/unit/PdfWriterTest.php @@ -0,0 +1,593 @@ +open(); + $w->addPage(); + + $this->assertSame(1, $w->getPageNo()); + } + + public function testFromLegacyFactory(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setCompression(false); + $w->open(); + $w->addPage(); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('[0.00 0.00 612.00 792.00]', $pdf); + } + + private function makeWriter(): PdfWriter + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setCompression(false); + return $w; + } + + public function testSetMargins(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + + $this->assertEqualsWithDelta(50.0, $w->getX(), 0.01); + $this->assertEqualsWithDelta(50.0, $w->getY(), 0.01); + $this->assertEqualsWithDelta(612.0 - 50.0 - 50.0, $w->getPageWidth(), 0.01); + } + + public function testSetAutoPageBreak(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + $w->addPage(); + + $this->assertEqualsWithDelta(792.0 - 50.0 - 50.0, $w->getPageHeight(), 0.01); + } + + public function testAddPageCreatesNewPage(): void + { + $w = new PdfWriter(); + $w->open(); + $w->addPage(); + $w->addPage(); + $w->addPage(); + + $this->assertSame(3, $w->getPageNo()); + } + + public function testSetFontResolvesCoreFonts(): void + { + $w = $this->makeWriter(); + $w->open(); + $w->addPage(); + $w->setFont('Times', 'B', 24); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('/BaseFont /Times-Bold', $pdf); + } + + public function testSetFontArialAlias(): void + { + $w = $this->makeWriter(); + $w->open(); + $w->addPage(); + $w->setFont('Arial', '', 12); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('/BaseFont /Helvetica', $pdf); + } + + public function testCellBasicText(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(0, 14, 'Hello World', 0, 1); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('(Hello World) Tj', $pdf); + } + + public function testCellFullWidth(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $initialX = $w->getX(); + $w->cell(0, 14, 'Full width', 0, 1); + + $this->assertEqualsWithDelta(50.0, $w->getX(), 0.01); + $this->assertEqualsWithDelta(50.0 + 14.0, $w->getY(), 0.01); + } + + public function testCellCursorMovementToRight(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $w->cell(100, 14, 'Cell 1', 0, 0); + $this->assertEqualsWithDelta(150.0, $w->getX(), 0.01); + $this->assertEqualsWithDelta(50.0, $w->getY(), 0.01); + } + + public function testCellCursorMovementNextLine(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $w->cell(100, 14, 'Cell 1', 0, 1); + $this->assertEqualsWithDelta(50.0, $w->getX(), 0.01); + $this->assertEqualsWithDelta(64.0, $w->getY(), 0.01); + } + + public function testCellCursorMovementBelow(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $startX = $w->getX(); + $w->cell(100, 14, 'Cell 1', 0, 2); + $this->assertEqualsWithDelta($startX, $w->getX(), 0.01); + $this->assertEqualsWithDelta(64.0, $w->getY(), 0.01); + } + + public function testCellWithBorderFull(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(100, 14, 'Bordered', 1, 1); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('re S', $pdf); + } + + public function testCellWithBorderString(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(100, 14, 'Bottom border', 'B', 1); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('l S', $pdf); + } + + public function testMultiCellWrapsText(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $longText = str_repeat('This is a long sentence that should wrap around. ', 10); + $w->multiCell(0, 14, $longText); + + $pdf = $w->getOutput(); + $tjCount = substr_count($pdf, 'Tj'); + $this->assertGreaterThan(1, $tjCount); + } + + public function testMultiCellExplicitNewlines(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $w->multiCell(0, 14, "Line 1\nLine 2\nLine 3"); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('(Line 1) Tj', $pdf); + $this->assertStringContainsString('(Line 2) Tj', $pdf); + $this->assertStringContainsString('(Line 3) Tj', $pdf); + } + + public function testWriteFlowingText(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $w->write(14, 'Short text'); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('(Short text) Tj', $pdf); + } + + public function testWriteWithLineBreaks(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $w->write(14, "Line A\nLine B"); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('(Line A) Tj', $pdf); + $this->assertStringContainsString('(Line B) Tj', $pdf); + } + + public function testNewLineDefault(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $w->cell(0, 20, 'Cell', 0, 1); + $yAfterCell = $w->getY(); + $w->newLine(); + + $this->assertEqualsWithDelta($yAfterCell + 20.0, $w->getY(), 0.01); + } + + public function testNewLineExplicit(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + + $yBefore = $w->getY(); + $w->newLine(30); + + $this->assertEqualsWithDelta($yBefore + 30.0, $w->getY(), 0.01); + $this->assertEqualsWithDelta(50.0, $w->getX(), 0.01); + } + + public function testAutoPageBreak(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + + $w->setY(790.0 - 50.0 - 1.0); + $w->cell(0, 20, 'This triggers page break', 0, 1); + + $this->assertSame(2, $w->getPageNo()); + } + + public function testCoordinateConversion(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(100, 14, 'Test', 0, 1); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('Td (Test) Tj', $pdf); + $this->assertStringContainsString('[0.00 0.00 612.00 792.00]', $pdf); + } + + public function testUnitConversionMm(): void + { + $w = new PdfWriter(new WriterOptions(unit: Unit::Millimeter, format: PageFormat::A4)); + $w->setCompression(false); + $w->open(); + $w->addPage(); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('[0.00 0.00 595.28 841.89]', $pdf); + } + + public function testGetOutputProducesValidPdf(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(0, 14, 'Hello', 0, 1); + + $pdf = $w->getOutput(); + + $this->assertStringStartsWith('%PDF-', $pdf); + $this->assertStringContainsString("%%EOF\n", $pdf); + + preg_match('/startxref\n(\d+)\n/', $pdf, $m); + $this->assertNotEmpty($m); + $xrefOffset = (int) $m[1]; + $this->assertSame('xref', substr($pdf, $xrefOffset, 4)); + } + + public function testPageNumberAlias(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->aliasNbPages(); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(0, 14, 'Page 1 of {nb}', 0, 1); + $w->addPage(); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('(Page 1 of 2) Tj', $pdf); + $this->assertStringNotContainsString('{nb}', $pdf); + } + + public function testMnemoUsagePattern(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + $w->addPage(); + + $w->setFont('Times', 'B', 24); + $w->multiCell(0, 24, 'My Note Title', 'B', 'L'); + $w->newLine(20); + + $w->setFont('Times', '', 14); + $w->write(14, 'This is the note body text that can be quite long.'); + + $pdf = $w->getOutput(); + + $this->assertStringStartsWith('%PDF-', $pdf); + $this->assertStringContainsString("%%EOF\n", $pdf); + $this->assertStringContainsString('/BaseFont /Times-Bold', $pdf); + $this->assertStringContainsString('/BaseFont /Times-Roman', $pdf); + $this->assertStringContainsString('(My Note Title) Tj', $pdf); + } + + public function testJonahUsagePattern(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->setAutoPageBreak(true, 50); + $w->open(); + $w->addPage(); + + $w->setFont('Times', 'B', 14); + $w->cell(0, 14, '2026-04-27', 0, 1); + $w->newLine(10); + + $w->setFont('Times', 'B', 24); + $w->multiCell(0, 24, 'News Article Title', 'B', 'L'); + $w->newLine(20); + + $w->setFont('Times', '', 14); + $w->write(14, 'This is the story body text.'); + + $pdf = $w->getOutput(); + + $this->assertStringStartsWith('%PDF-', $pdf); + $this->assertStringContainsString('(2026-04-27) Tj', $pdf); + $this->assertStringContainsString('(News Article Title) Tj', $pdf); + } + + public function testGetStringWidth(): void + { + $w = $this->makeWriter(); + $w->open(); + $w->addPage(); + $w->setFont('Courier', '', 12); + + $width = $w->getStringWidth('Hello'); + $this->assertGreaterThan(0, $width); + $this->assertEqualsWithDelta(600 * 5 * 12.0 / 1000.0, $width, 0.01); + } + + public function testSetTextColor(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->setTextColor(Color::rgb(1.0, 0.0, 0.0)); + $w->cell(0, 14, 'Red text', 0, 1); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('1.000 0.000 0.000 rg', $pdf); + $this->assertStringContainsString('q', $pdf); + $this->assertStringContainsString('Q', $pdf); + } + + public function testLandscapeOrientation(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt', 'orientation' => 'L']); + $w->setCompression(false); + $w->open(); + $w->addPage(); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('[0.00 0.00 792.00 612.00]', $pdf); + } + + public function testMultiplePages(): void + { + $w = $this->makeWriter(); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(0, 14, 'Page 1 content', 0, 1); + $w->addPage(); + $w->cell(0, 14, 'Page 2 content', 0, 1); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('/Count 2', $pdf); + $this->assertStringContainsString('(Page 1 content) Tj', $pdf); + $this->assertStringContainsString('(Page 2 content) Tj', $pdf); + } + + public function testDocumentInfo(): void + { + $w = $this->makeWriter(); + $w->setInfo('Title', 'My Document'); + $w->setInfo('Author', 'Test Author'); + $w->open(); + $w->addPage(); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('/Title (My Document)', $pdf); + $this->assertStringContainsString('/Author (Test Author)', $pdf); + } + + public function testCellWithFill(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->setFillColor(Color::rgb(0.9, 0.9, 0.9)); + $w->cell(100, 14, 'Filled', 0, 1, '', true); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('re f', $pdf); + } + + public function testCellWithFillAndBorder(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->setFillColor(Color::rgb(0.9, 0.9, 0.9)); + $w->cell(100, 14, 'Both', 1, 1, '', true); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('re B', $pdf); + } + + public function testCellAlignCenter(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(200, 14, 'Centered', 0, 1, 'C'); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('(Centered) Tj', $pdf); + } + + public function testCellAlignRight(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(200, 14, 'Right', 0, 1, 'R'); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('(Right) Tj', $pdf); + } + + public function testSpecialCharacterEscaping(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->cell(0, 14, 'Price: (100) 50\\%', 0, 1); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('(Price: \\(100\\) 50\\\\%) Tj', $pdf); + } + + public function testSetPosition(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + + $w->setX(100); + $this->assertEqualsWithDelta(100.0, $w->getX(), 0.01); + + $w->setY(200); + $this->assertEqualsWithDelta(200.0, $w->getY(), 0.01); + $this->assertEqualsWithDelta(50.0, $w->getX(), 0.01); + + $w->setXY(150, 300); + $this->assertEqualsWithDelta(150.0, $w->getX(), 0.01); + $this->assertEqualsWithDelta(300.0, $w->getY(), 0.01); + } + + public function testNegativePosition(): void + { + $w = PdfWriter::fromLegacy(['format' => 'Letter', 'unit' => 'pt']); + $w->open(); + $w->addPage(); + + $w->setX(-50); + $this->assertEqualsWithDelta(612.0 - 50.0, $w->getX(), 0.01); + + $w->setY(-30); + $this->assertEqualsWithDelta(792.0 - 30.0, $w->getY(), 0.01); + } + + public function testMultiCellWithBorder(): void + { + $w = $this->makeWriter(); + $w->setMargins(50, 50); + $w->open(); + $w->addPage(); + $w->setFont('Times', '', 14); + $w->multiCell(0, 14, "Line 1\nLine 2\nLine 3", 1); + + $pdf = $w->getOutput(); + $this->assertStringContainsString('(Line 1) Tj', $pdf); + $this->assertStringContainsString('(Line 3) Tj', $pdf); + } +} diff --git a/test/unit/PngParserTest.php b/test/unit/PngParserTest.php new file mode 100644 index 0000000..b553113 --- /dev/null +++ b/test/unit/PngParserTest.php @@ -0,0 +1,88 @@ +markTestSkipped('GD extension not available'); + } + + $img = imagecreatetruecolor(20, 15); + imagefill($img, 0, 0, imagecolorallocate($img, 255, 0, 0)); + $path = tempnam(sys_get_temp_dir(), 'horde_pdf_png_') . '.png'; + imagepng($img, $path); + imagedestroy($img); + + try { + $xobj = PngParser::parseFile($path); + + $this->assertSame(20, $xobj->width); + $this->assertSame(15, $xobj->height); + $this->assertInstanceOf(DeviceRgb::class, $xobj->colorSpace); + $this->assertSame(8, $xobj->bitsPerComponent); + $this->assertSame('FlateDecode', $xobj->filter); + $this->assertNotEmpty($xobj->data); + $this->assertNotNull($xobj->decodeParms); + $this->assertStringContainsString('/Predictor 15', $xobj->decodeParms); + $this->assertStringContainsString('/Colors 3', $xobj->decodeParms); + } finally { + @unlink($path); + } + } + + public function testParseGrayscalePng(): void + { + if (!function_exists('imagecreate')) { + $this->markTestSkipped('GD extension not available'); + } + + $img = imagecreate(10, 10); + imagecolorallocate($img, 128, 128, 128); + $path = tempnam(sys_get_temp_dir(), 'horde_pdf_png_gray_') . '.png'; + imagepng($img, $path); + imagedestroy($img); + + try { + $xobj = PngParser::parseFile($path); + + $this->assertSame(10, $xobj->width); + $this->assertSame(10, $xobj->height); + $this->assertNotEmpty($xobj->data); + $this->assertNotNull($xobj->decodeParms); + } finally { + @unlink($path); + } + } + + public function testRejectsNonPngFile(): void + { + $path = tempnam(sys_get_temp_dir(), 'horde_pdf_notpng_'); + file_put_contents($path, 'not a png file'); + + try { + $this->expectException(PdfException::class); + $this->expectExceptionMessage('Not a PNG file'); + PngParser::parseFile($path); + } finally { + @unlink($path); + } + } + + public function testRejectsUnreadableFile(): void + { + $this->expectException(PdfException::class); + $this->expectExceptionMessage('Unable to open'); + PngParser::parseFile('/nonexistent/path.png'); + } +} diff --git a/test/unit/RectangleTest.php b/test/unit/RectangleTest.php new file mode 100644 index 0000000..034c07d --- /dev/null +++ b/test/unit/RectangleTest.php @@ -0,0 +1,63 @@ +assertSame(10.0, $rect->llx); + $this->assertSame(20.0, $rect->lly); + $this->assertSame(110.0, $rect->urx); + $this->assertSame(220.0, $rect->ury); + } + + public function testWidth(): void + { + $rect = new Rectangle(10.0, 0.0, 110.0, 100.0); + $this->assertSame(100.0, $rect->width()); + } + + public function testHeight(): void + { + $rect = new Rectangle(0.0, 20.0, 100.0, 220.0); + $this->assertSame(200.0, $rect->height()); + } + + public function testFromDimensions(): void + { + $rect = Rectangle::fromDimensions(595.28, 841.89); + $this->assertSame(0.0, $rect->llx); + $this->assertSame(0.0, $rect->lly); + $this->assertSame(595.28, $rect->urx); + $this->assertSame(841.89, $rect->ury); + } + + public function testFromPageFormatPortrait(): void + { + $rect = Rectangle::fromPageFormat(PageFormat::A4); + $this->assertSame(595.28, $rect->width()); + $this->assertSame(841.89, $rect->height()); + } + + public function testFromPageFormatLandscape(): void + { + $rect = Rectangle::fromPageFormat(PageFormat::A4, Orientation::Landscape); + $this->assertSame(841.89, $rect->width()); + $this->assertSame(595.28, $rect->height()); + } + + public function testToPdfArray(): void + { + $rect = new Rectangle(0.0, 0.0, 595.28, 841.89); + $this->assertSame('[0.00 0.00 595.28 841.89]', $rect->toPdfArray()); + } +} diff --git a/test/unit/Type1FontTest.php b/test/unit/Type1FontTest.php new file mode 100644 index 0000000..1ff3879 --- /dev/null +++ b/test/unit/Type1FontTest.php @@ -0,0 +1,115 @@ +toFont(); + $this->assertSame('Helvetica', $font->pdfName()); + } + + public function testImplementsFontInterface(): void + { + $font = CoreFont::Courier->toFont(); + $this->assertInstanceOf(Font::class, $font); + } + + public function testRequiresEmbeddingFalse(): void + { + $font = CoreFont::Times->toFont(); + $this->assertFalse($font->requiresEmbedding()); + } + + public function testEncodeReturnsTextUnchanged(): void + { + $font = CoreFont::Helvetica->toFont(); + $this->assertSame('Hello World', $font->encode('Hello World')); + } + + public function testEncodingWinAnsiForMostFonts(): void + { + $font = CoreFont::Helvetica->toFont(); + $this->assertSame(FontEncoding::WinAnsi, $font->encoding()); + + $font = CoreFont::Courier->toFont(); + $this->assertSame(FontEncoding::WinAnsi, $font->encoding()); + + $font = CoreFont::TimesBold->toFont(); + $this->assertSame(FontEncoding::WinAnsi, $font->encoding()); + } + + public function testEncodingSymbol(): void + { + $font = CoreFont::Symbol->toFont(); + $this->assertSame(FontEncoding::Symbol, $font->encoding()); + } + + public function testEncodingZapfDingbats(): void + { + $font = CoreFont::ZapfDingbats->toFont(); + $this->assertSame(FontEncoding::ZapfDingbats, $font->encoding()); + } + + public function testStyleRegular(): void + { + $this->assertSame(FontStyle::Regular, CoreFont::Helvetica->toFont()->style()); + $this->assertSame(FontStyle::Regular, CoreFont::Courier->toFont()->style()); + $this->assertSame(FontStyle::Regular, CoreFont::Times->toFont()->style()); + } + + public function testStyleBold(): void + { + $this->assertSame(FontStyle::Bold, CoreFont::HelveticaBold->toFont()->style()); + $this->assertSame(FontStyle::Bold, CoreFont::CourierBold->toFont()->style()); + } + + public function testStyleItalic(): void + { + $this->assertSame(FontStyle::Italic, CoreFont::HelveticaItalic->toFont()->style()); + $this->assertSame(FontStyle::Italic, CoreFont::TimesItalic->toFont()->style()); + } + + public function testStyleBoldItalic(): void + { + $this->assertSame(FontStyle::BoldItalic, CoreFont::HelveticaBoldItalic->toFont()->style()); + $this->assertSame(FontStyle::BoldItalic, CoreFont::CourierBoldItalic->toFont()->style()); + } + + public function testWidthOfStringCourier(): void + { + $font = CoreFont::Courier->toFont(); + $width = $font->widthOfString('Hi', 12.0); + $this->assertEqualsWithDelta(600 * 2 * 12.0 / 1000.0, $width, 0.001); + } + + public function testWidthOfStringHelvetica(): void + { + $font = CoreFont::Helvetica->toFont(); + $widths = $font->widths(); + $expected = ($widths['H'] + $widths['i']) * 10.0 / 1000.0; + $this->assertEqualsWithDelta($expected, $font->widthOfString('Hi', 10.0), 0.001); + } + + public function testWidthOfEmptyString(): void + { + $font = CoreFont::Helvetica->toFont(); + $this->assertSame(0.0, $font->widthOfString('', 12.0)); + } + + public function testCoreFontBridge(): void + { + $font = CoreFont::HelveticaBold->toFont(); + $this->assertSame(CoreFont::HelveticaBold, $font->coreFont()); + } +} diff --git a/test/unit/ViewerPreferencesTest.php b/test/unit/ViewerPreferencesTest.php new file mode 100644 index 0000000..f0b7591 --- /dev/null +++ b/test/unit/ViewerPreferencesTest.php @@ -0,0 +1,33 @@ +assertSame(ZoomMode::DefaultMode, $prefs->zoomMode); + $this->assertSame(LayoutMode::DefaultMode, $prefs->layoutMode); + $this->assertNull($prefs->zoomPercent); + } + + public function testCustomValues(): void + { + $prefs = new ViewerPreferences( + zoomMode: ZoomMode::FullWidth, + layoutMode: LayoutMode::Two, + zoomPercent: 150, + ); + $this->assertSame(ZoomMode::FullWidth, $prefs->zoomMode); + $this->assertSame(LayoutMode::Two, $prefs->layoutMode); + $this->assertSame(150, $prefs->zoomPercent); + } +} diff --git a/test/unit/WriterOptionsTest.php b/test/unit/WriterOptionsTest.php new file mode 100644 index 0000000..fb7e676 --- /dev/null +++ b/test/unit/WriterOptionsTest.php @@ -0,0 +1,116 @@ +assertSame(Orientation::Portrait, $options->orientation); + $this->assertSame(Unit::Millimeter, $options->unit); + $this->assertSame(PageFormat::A4, $options->format); + } + + public function testCustomValues(): void + { + $options = new WriterOptions( + orientation: Orientation::Landscape, + unit: Unit::Point, + format: PageFormat::A3, + ); + $this->assertSame(Orientation::Landscape, $options->orientation); + $this->assertSame(Unit::Point, $options->unit); + $this->assertSame(PageFormat::A3, $options->format); + } + + public function testCustomPageFormat(): void + { + $custom = new CustomPageFormat(100.0, 200.0); + $options = new WriterOptions( + unit: Unit::Point, + format: $custom, + ); + $this->assertInstanceOf(CustomPageFormat::class, $options->format); + $this->assertSame(100.0, $custom->width); + $this->assertSame(200.0, $custom->height); + } + + public function testFormatDimensionsInPointsForStandardFormat(): void + { + $options = new WriterOptions(format: PageFormat::A4); + [$w, $h] = $options->formatDimensionsInPoints(); + $this->assertSame(595.28, $w); + $this->assertSame(841.89, $h); + } + + public function testFormatDimensionsInPointsForCustomFormat(): void + { + $options = new WriterOptions( + unit: Unit::Point, + format: new CustomPageFormat(50.0, 50.0), + ); + [$w, $h] = $options->formatDimensionsInPoints(); + $this->assertSame(50.0, $w); + $this->assertSame(50.0, $h); + } + + public function testFormatDimensionsInPointsConvertsUnits(): void + { + $options = new WriterOptions( + unit: Unit::Inch, + format: new CustomPageFormat(8.5, 11.0), + ); + [$w, $h] = $options->formatDimensionsInPoints(); + $this->assertSame(612.0, $w); + $this->assertSame(792.0, $h); + } + + public function testFromLegacyDefaults(): void + { + $options = WriterOptions::fromLegacy(); + $this->assertSame(Orientation::Portrait, $options->orientation); + $this->assertSame(Unit::Millimeter, $options->unit); + $this->assertSame(PageFormat::A4, $options->format); + } + + public function testFromLegacyWithValues(): void + { + $options = WriterOptions::fromLegacy([ + 'orientation' => 'L', + 'unit' => 'pt', + 'format' => 'A3', + ]); + $this->assertSame(Orientation::Landscape, $options->orientation); + $this->assertSame(Unit::Point, $options->unit); + $this->assertSame(PageFormat::A3, $options->format); + } + + public function testFromLegacyWithCustomFormat(): void + { + $options = WriterOptions::fromLegacy([ + 'format' => [50.0, 50.0], + 'unit' => 'pt', + ]); + $this->assertInstanceOf(CustomPageFormat::class, $options->format); + [$w, $h] = $options->formatDimensionsInPoints(); + $this->assertSame(50.0, $w); + $this->assertSame(50.0, $h); + } + + public function testFromLegacyLandscapeSpelled(): void + { + $options = WriterOptions::fromLegacy(['orientation' => 'Landscape']); + $this->assertSame(Orientation::Landscape, $options->orientation); + } +} diff --git a/test/Horde/Pdf/WriterTest.php b/test/unit/WriterTest.php similarity index 63% rename from test/Horde/Pdf/WriterTest.php rename to test/unit/WriterTest.php index cf7420a..895aba6 100644 --- a/test/Horde/Pdf/WriterTest.php +++ b/test/unit/WriterTest.php @@ -1,25 +1,18 @@ 'L', 'unit' => 'pt', 'format' => 'A3'); + $options = ['orientation' => 'L', 'unit' => 'pt', 'format' => 'A3']; $pdf = new Horde_Pdf_Writer($options); $this->assertEquals('L', $pdf->getDefaultOrientation()); @@ -27,7 +20,7 @@ public function testFactoryWithOptions() $this->assertEquals(1190.55, $pdf->getFormatWidth()); } - public function testFactoryWithDefaults() + public function testFactoryWithDefaults(): void { $pdf = new Horde_Pdf_Writer(); @@ -37,9 +30,9 @@ public function testFactoryWithDefaults() $this->assertEquals(595.28, $pdf->getFormatWidth()); } - public function testHelloWorldUncompressed() + public function testHelloWorldUncompressed(): void { - $pdf = new Horde_Pdf_Writer(array('orientation' => 'P', 'format' => 'A4')); + $pdf = new Horde_Pdf_Writer(['orientation' => 'P', 'format' => 'A4']); $pdf->setInfo('CreationDate', $this->fixtureCreationDate()); $pdf->open(); $pdf->setCompression(false); @@ -57,9 +50,9 @@ public function testHelloWorldUncompressed() $this->assertEquals($expected, $actual); } - public function testHelloWorldCompressed() + public function testHelloWorldCompressed(): void { - $pdf = new Horde_Pdf_Writer(array('orientation' => 'P', 'format' => 'A4')); + $pdf = new Horde_Pdf_Writer(['orientation' => 'P', 'format' => 'A4']); $pdf->setInfo('CreationDate', $this->fixtureCreationDate()); $pdf->open(); $pdf->setCompression(false); @@ -77,9 +70,9 @@ public function testHelloWorldCompressed() $this->assertEquals($expected, $actual); } - public function testAutoBreak() + public function testAutoBreak(): void { - $pdf = new Horde_Pdf_Writer(array('format' => array(50, 50), 'unit' => 'pt')); + $pdf = new Horde_Pdf_Writer(['format' => [50, 50], 'unit' => 'pt']); $pdf->setInfo('CreationDate', $this->fixtureCreationDate()); $pdf->setCompression(false); $pdf->setMargins(0, 0); @@ -95,36 +88,30 @@ public function testAutoBreak() $this->assertEquals($expected, $actual); } - - public function testChangePage() + public function testChangePage(): void { - $pdf = new Horde_Pdf_Writer(array('format' => array(80, 80), 'unit' => 'pt')); + $pdf = new Horde_Pdf_Writer(['format' => [80, 80], 'unit' => 'pt']); $pdf->setInfo('CreationDate', $this->fixtureCreationDate()); $pdf->setCompression(false); $pdf->setMargins(0, 0); $pdf->open(); - // first page $pdf->addPage(); - $pdf->setFont('Courier', '', 10); $pdf->write(10, "Hello"); - // second page $pdf->addPage(); - // back to first page again $pdf->setPage(1); $pdf->write(10, "Goodbye"); - // back to second page $pdf->setPage(2); $expected = $this->fixture('change_page'); $this->assertEquals($expected, $pdf->getOutput()); } - public function testTextColor() + public function testTextColor(): void { $pdf = new Horde_Pdf_Writer(); $pdf->setInfo('CreationDate', $this->fixtureCreationDate()); @@ -142,7 +129,7 @@ public function testTextColor() $this->assertEquals($expected, $actual); } - public function testTextColorUsingHex() + public function testTextColorUsingHex(): void { $pdf = new Horde_Pdf_Writer(); $pdf->setInfo('timestamp', $this->fixtureCreationDate()); @@ -160,9 +147,9 @@ public function testTextColorUsingHex() $this->assertEquals('0.000 0.000 1.000 rg', $pdf->getFillColor()); } - public function testUnderline() + public function testUnderline(): void { - $pdf = new Horde_Pdf_Writer(array('orientation' => 'P', 'format' => 'A4')); + $pdf = new Horde_Pdf_Writer(['orientation' => 'P', 'format' => 'A4']); $pdf->setInfo('CreationDate', $this->fixtureCreationDate()); $pdf->open(); $pdf->setCompression(false); @@ -176,16 +163,13 @@ public function testUnderline() $this->assertEquals($expected, $actual); } - /** - * PEAR Bug #12310 - */ - public function testHeaderFooterStyles() + public function testHeaderFooterStyles(): void { - $pdf = new HeaderFooterStylesPdf(array( + $pdf = new HeaderFooterStylesPdf([ 'orientation' => 'P', 'unit' => 'mm', 'format' => 'A4', - )); + ]); $pdf->setCompression(false); $pdf->setInfo('title', '20000 Leagues Under the Seas'); $pdf->setInfo('author', 'Jules Verne'); @@ -198,12 +182,9 @@ public function testHeaderFooterStyles() $this->assertEquals($expected, $actual); } - /** - * Horde Bug #5964 - */ - public function testLinks() + public function testLinks(): void { - $pdf = new Horde_Pdf_Writer(array('orientation' => 'P', 'format' => 'A4')); + $pdf = new Horde_Pdf_Writer(['orientation' => 'P', 'format' => 'A4']); $pdf->setInfo('CreationDate', $this->fixtureCreationDate()); $pdf->open(); $pdf->setCompression(false); @@ -222,80 +203,24 @@ public function testLinks() $this->assertEquals($expected, $actual); } - /** - * PEAR Bug #12310 - */ - public function testCourierStyle() + public function testCourierStyle(): void { + $this->expectNotToPerformAssertions(); $pdf = new Horde_Pdf_Writer(); $pdf->setFont('courier', 'B', 10); } - // Test Helpers - - protected function fixture($name) + protected function fixture(string $name): string { $filename = __DIR__ . "/fixtures/{$name}.pdf"; $fixture = file_get_contents($filename); - $this->assertInternalType('string', $fixture); + $this->assertIsString($fixture); return $fixture; } - protected function fixtureCreationDate() + protected function fixtureCreationDate(): string { return 'D:20071105152947'; } - -} - -class HeaderFooterStylesPdf extends Horde_Pdf_Writer -{ - public function header() - { - $this->setFont('Arial', 'B', 15); - $w = $this->getStringWidth($this->_info['title']) + 6; - $this->setX((210 - $w) / 2); - $this->setDrawColor('rgb', 0/255, 80/255, 180/255); - $this->setFillColor('rgb', 230/255, 230/255, 0/255); - $this->setTextColor('rgb', 220/255, 50/255, 50/255); - $this->setLineWidth(1); - $this->cell($w, 9, $this->_info['title'], 1, 1, 'C', 1); - $this->newLine(10); - } - - public function footer() - { - $this->setY(-15); - $this->setFont('Arial', 'I', 8); - $this->setTextColor('gray', 128/255); - $this->cell(0, 10, 'Page ' . $this->getPageNo(), 0, 0, 'C'); - } - - public function chapterTitle($num, $label) - { - $this->setFont('Arial', '', 12); - $this->setFillColor('rgb', 200/255, 220/255, 255/255); - $this->cell(0, 6, "Chapter $num : $label", 0, 1, 'L', 1); - $this->newLine(4); - } - - public function chapterBody($file) - { - $filename = __DIR__ . "/fixtures/$file"; - $text = file_get_contents($filename); - $this->setFont('Times', '', 12); - $this->multiCell(0, 5, $text); - $this->newLine(); - $this->setFont('', 'I'); - $this->cell(0, 5, '(end of extract)'); - } - - public function printChapter($num, $title, $file) - { - $this->addPage(); - $this->chapterTitle($num, $title); - $this->chapterBody($file); - } - } diff --git a/test/Horde/Pdf/fixtures/20k_c1.txt b/test/unit/fixtures/20k_c1.txt similarity index 100% rename from test/Horde/Pdf/fixtures/20k_c1.txt rename to test/unit/fixtures/20k_c1.txt diff --git a/test/Horde/Pdf/fixtures/20k_c2.txt b/test/unit/fixtures/20k_c2.txt similarity index 100% rename from test/Horde/Pdf/fixtures/20k_c2.txt rename to test/unit/fixtures/20k_c2.txt diff --git a/test/Horde/Pdf/fixtures/auto_break.pdf b/test/unit/fixtures/auto_break.pdf similarity index 100% rename from test/Horde/Pdf/fixtures/auto_break.pdf rename to test/unit/fixtures/auto_break.pdf diff --git a/test/Horde/Pdf/fixtures/change_page.pdf b/test/unit/fixtures/change_page.pdf similarity index 100% rename from test/Horde/Pdf/fixtures/change_page.pdf rename to test/unit/fixtures/change_page.pdf diff --git a/test/Horde/Pdf/fixtures/header_footer_styles.pdf b/test/unit/fixtures/header_footer_styles.pdf similarity index 100% rename from test/Horde/Pdf/fixtures/header_footer_styles.pdf rename to test/unit/fixtures/header_footer_styles.pdf diff --git a/test/Horde/Pdf/fixtures/hello_world_compressed.pdf b/test/unit/fixtures/hello_world_compressed.pdf similarity index 100% rename from test/Horde/Pdf/fixtures/hello_world_compressed.pdf rename to test/unit/fixtures/hello_world_compressed.pdf diff --git a/test/Horde/Pdf/fixtures/hello_world_uncompressed.pdf b/test/unit/fixtures/hello_world_uncompressed.pdf similarity index 100% rename from test/Horde/Pdf/fixtures/hello_world_uncompressed.pdf rename to test/unit/fixtures/hello_world_uncompressed.pdf diff --git a/test/Horde/Pdf/fixtures/horde-power1.png b/test/unit/fixtures/horde-power1.png similarity index 100% rename from test/Horde/Pdf/fixtures/horde-power1.png rename to test/unit/fixtures/horde-power1.png diff --git a/test/Horde/Pdf/fixtures/links.pdf b/test/unit/fixtures/links.pdf similarity index 100% rename from test/Horde/Pdf/fixtures/links.pdf rename to test/unit/fixtures/links.pdf diff --git a/test/Horde/Pdf/fixtures/text_color.pdf b/test/unit/fixtures/text_color.pdf similarity index 100% rename from test/Horde/Pdf/fixtures/text_color.pdf rename to test/unit/fixtures/text_color.pdf diff --git a/test/Horde/Pdf/fixtures/underline.pdf b/test/unit/fixtures/underline.pdf similarity index 100% rename from test/Horde/Pdf/fixtures/underline.pdf rename to test/unit/fixtures/underline.pdf From 5f19105a6d483d6ce555d537b4996e5b3e537736 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Mon, 27 Apr 2026 20:38:43 +0200 Subject: [PATCH 2/3] style: php-cs-fixer --- lib/Horde/Pdf/Exception.php | 5 +- lib/Horde/Pdf/Font/Courier.php | 3 +- lib/Horde/Pdf/Font/Courierb.php | 3 +- lib/Horde/Pdf/Font/Courierbi.php | 3 +- lib/Horde/Pdf/Font/Courieri.php | 3 +- lib/Horde/Pdf/Font/Helvetica.php | 6 +- lib/Horde/Pdf/Font/Helveticab.php | 6 +- lib/Horde/Pdf/Font/Helveticabi.php | 6 +- lib/Horde/Pdf/Font/Helveticai.php | 6 +- lib/Horde/Pdf/Font/Symbol.php | 6 +- lib/Horde/Pdf/Font/Times.php | 6 +- lib/Horde/Pdf/Font/Timesb.php | 6 +- lib/Horde/Pdf/Font/Timesbi.php | 6 +- lib/Horde/Pdf/Font/Timesi.php | 6 +- lib/Horde/Pdf/Font/Zapfdingbats.php | 6 +- lib/Horde/Pdf/Writer.php | 278 +++++++++++++++++----------- 16 files changed, 208 insertions(+), 147 deletions(-) diff --git a/lib/Horde/Pdf/Exception.php b/lib/Horde/Pdf/Exception.php index 9cb2f14..bb40547 100644 --- a/lib/Horde/Pdf/Exception.php +++ b/lib/Horde/Pdf/Exception.php @@ -1,4 +1,5 @@ array( + return ['helvetica' => [ chr(0) => 278, chr(1) => 278, chr(2) => 278, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 500, chr(254) => 556, chr(255) => 500, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Font/Helveticab.php b/lib/Horde/Pdf/Font/Helveticab.php index 83ad63b..ff090b6 100644 --- a/lib/Horde/Pdf/Font/Helveticab.php +++ b/lib/Horde/Pdf/Font/Helveticab.php @@ -1,4 +1,5 @@ array( + return ['helveticaB' => [ chr(0) => 278, chr(1) => 278, chr(2) => 278, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 556, chr(254) => 611, chr(255) => 556, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Font/Helveticabi.php b/lib/Horde/Pdf/Font/Helveticabi.php index 08c1f67..c5d1661 100644 --- a/lib/Horde/Pdf/Font/Helveticabi.php +++ b/lib/Horde/Pdf/Font/Helveticabi.php @@ -1,4 +1,5 @@ array( + return ['helveticaBI' => [ chr(0) => 278, chr(1) => 278, chr(2) => 278, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 556, chr(254) => 611, chr(255) => 556, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Font/Helveticai.php b/lib/Horde/Pdf/Font/Helveticai.php index df29650..cf7e638 100644 --- a/lib/Horde/Pdf/Font/Helveticai.php +++ b/lib/Horde/Pdf/Font/Helveticai.php @@ -1,4 +1,5 @@ array( + return ['helveticaI' => [ chr(0) => 278, chr(1) => 278, chr(2) => 278, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 500, chr(254) => 556, chr(255) => 500, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Font/Symbol.php b/lib/Horde/Pdf/Font/Symbol.php index a7fbeb3..8709109 100644 --- a/lib/Horde/Pdf/Font/Symbol.php +++ b/lib/Horde/Pdf/Font/Symbol.php @@ -1,4 +1,5 @@ array( + return ['symbol' => [ chr(0) => 250, chr(1) => 250, chr(2) => 250, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 494, chr(254) => 494, chr(255) => 0, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Font/Times.php b/lib/Horde/Pdf/Font/Times.php index 8bfba83..eeb5ec1 100644 --- a/lib/Horde/Pdf/Font/Times.php +++ b/lib/Horde/Pdf/Font/Times.php @@ -1,4 +1,5 @@ array( + return ['times' => [ chr(0) => 250, chr(1) => 250, chr(2) => 250, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 500, chr(254) => 500, chr(255) => 500, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Font/Timesb.php b/lib/Horde/Pdf/Font/Timesb.php index eb39869..c4416a4 100644 --- a/lib/Horde/Pdf/Font/Timesb.php +++ b/lib/Horde/Pdf/Font/Timesb.php @@ -1,4 +1,5 @@ array( + return ['timesB' => [ chr(0) => 250, chr(1) => 250, chr(2) => 250, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 500, chr(254) => 556, chr(255) => 500, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Font/Timesbi.php b/lib/Horde/Pdf/Font/Timesbi.php index 5320a4a..8df2ffe 100644 --- a/lib/Horde/Pdf/Font/Timesbi.php +++ b/lib/Horde/Pdf/Font/Timesbi.php @@ -1,4 +1,5 @@ array( + return ['timesBI' => [ chr(0) => 250, chr(1) => 250, chr(2) => 250, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 444, chr(254) => 500, chr(255) => 444, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Font/Timesi.php b/lib/Horde/Pdf/Font/Timesi.php index ab7cf8e..45ce95c 100644 --- a/lib/Horde/Pdf/Font/Timesi.php +++ b/lib/Horde/Pdf/Font/Timesi.php @@ -1,4 +1,5 @@ array( + return ['timesI' => [ chr(0) => 250, chr(1) => 250, chr(2) => 250, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 444, chr(254) => 500, chr(255) => 444, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Font/Zapfdingbats.php b/lib/Horde/Pdf/Font/Zapfdingbats.php index b8eaf9e..100af55 100644 --- a/lib/Horde/Pdf/Font/Zapfdingbats.php +++ b/lib/Horde/Pdf/Font/Zapfdingbats.php @@ -1,4 +1,5 @@ array( + return ['zapfdingbats' => [ chr(0) => 0, chr(1) => 0, chr(2) => 0, @@ -286,7 +286,7 @@ public function getWidths() chr(253) => 970, chr(254) => 918, chr(255) => 0, - )); + ]]; } } diff --git a/lib/Horde/Pdf/Writer.php b/lib/Horde/Pdf/Writer.php index 6b20a4a..bf070d1 100644 --- a/lib/Horde/Pdf/Writer.php +++ b/lib/Horde/Pdf/Writer.php @@ -1,12 +1,13 @@ - * Copyright 2003-2017 Horde LLC (http://www.horde.org/) + * Copyright 2001-2026 Olivier Plathey + * Copyright 2003-2026 Horde LLC (http://www.horde.org/) * * @author Olivier Plathey * @author Marko Djukic @@ -44,7 +45,7 @@ class Horde_Pdf_Writer * * @var array */ - protected $_offsets = array(); + protected $_offsets = []; /** * Buffer holding in-memory Pdf. @@ -72,7 +73,7 @@ class Horde_Pdf_Writer * * @var array */ - protected $_pages = array(); + protected $_pages = []; /** * Current document state.
@@ -112,7 +113,7 @@ class Horde_Pdf_Writer
      *
      * @var array
      */
-    protected $_orientation_changes = array();
+    protected $_orientation_changes = [];
 
     /**
      * Current width of page format in points.
@@ -249,55 +250,55 @@ class Horde_Pdf_Writer
      *
      * @var array
      */
-    protected $_core_fonts = array('courier'      => 'Courier',
-                                   'courierB'     => 'Courier-Bold',
-                                   'courierI'     => 'Courier-Oblique',
-                                   'courierBI'    => 'Courier-BoldOblique',
-                                   'helvetica'    => 'Helvetica',
-                                   'helveticaB'   => 'Helvetica-Bold',
-                                   'helveticaI'   => 'Helvetica-Oblique',
-                                   'helveticaBI'  => 'Helvetica-BoldOblique',
-                                   'times'        => 'Times-Roman',
-                                   'timesB'       => 'Times-Bold',
-                                   'timesI'       => 'Times-Italic',
-                                   'timesBI'      => 'Times-BoldItalic',
-                                   'symbol'       => 'Symbol',
-                                   'zapfdingbats' => 'ZapfDingbats');
+    protected $_core_fonts = ['courier'      => 'Courier',
+        'courierB'     => 'Courier-Bold',
+        'courierI'     => 'Courier-Oblique',
+        'courierBI'    => 'Courier-BoldOblique',
+        'helvetica'    => 'Helvetica',
+        'helveticaB'   => 'Helvetica-Bold',
+        'helveticaI'   => 'Helvetica-Oblique',
+        'helveticaBI'  => 'Helvetica-BoldOblique',
+        'times'        => 'Times-Roman',
+        'timesB'       => 'Times-Bold',
+        'timesI'       => 'Times-Italic',
+        'timesBI'      => 'Times-BoldItalic',
+        'symbol'       => 'Symbol',
+        'zapfdingbats' => 'ZapfDingbats'];
 
     /**
      * An array of used fonts.
      *
      * @var array
      */
-    protected $_fonts = array();
+    protected $_fonts = [];
 
     /**
      * An array of font files.
      *
      * @var array
      */
-    protected $_font_files = array();
+    protected $_font_files = [];
 
     /**
      * Widths of specific font files
      *
      * @var array
      */
-    protected static $_font_widths = array();
+    protected static $_font_widths = [];
 
     /**
      * An array of encoding differences.
      *
      * @var array
      */
-    protected $_diffs = array();
+    protected $_diffs = [];
 
     /**
      * An array of used images.
      *
      * @var array
      */
-    protected $_images = array();
+    protected $_images = [];
 
     /**
      * An array of links in pages.
@@ -311,7 +312,7 @@ class Horde_Pdf_Writer
      *
      * @var array
      */
-    protected $_links = array();
+    protected $_links = [];
 
     /**
      * Current font family.
@@ -435,7 +436,7 @@ class Horde_Pdf_Writer
      *
      * @var array
      */
-    protected $_info = array();
+    protected $_info = [];
 
     /**
      * Alias for total number of pages.
@@ -486,10 +487,10 @@ class Horde_Pdf_Writer
      *                         (expressed in the unit given by the unit
      *                         parameter).
      */
-    public function __construct($params = array())
+    public function __construct($params = [])
     {
         /* Default parameters. */
-        $defaults = array('orientation' => 'P', 'unit' => 'mm', 'format' => 'A4');
+        $defaults = ['orientation' => 'P', 'unit' => 'mm', 'format' => 'A4'];
         $params = array_merge($defaults, $params);
 
         /* Scale factor. */
@@ -508,15 +509,15 @@ public function __construct($params = array())
         if (is_string($params['format'])) {
             $params['format'] = Horde_String::lower($params['format']);
             if ($params['format'] == 'a3') {
-                $params['format'] = array(841.89, 1190.55);
+                $params['format'] = [841.89, 1190.55];
             } elseif ($params['format'] == 'a4') {
-                $params['format'] = array(595.28, 841.89);
+                $params['format'] = [595.28, 841.89];
             } elseif ($params['format'] == 'a5') {
-                $params['format'] = array(420.94, 595.28);
+                $params['format'] = [420.94, 595.28];
             } elseif ($params['format'] == 'letter') {
-                $params['format'] = array(612, 792);
+                $params['format'] = [612, 792];
             } elseif ($params['format'] == 'legal') {
-                $params['format'] = array(612, 1008);
+                $params['format'] = [612, 1008];
             } else {
                 throw new Horde_Pdf_Exception(sprintf('Unknown page format: %s', $params['format']));
             }
@@ -1044,7 +1045,7 @@ public function setFillColor($cs = 'rgb', $c1 = 0, $c2 = 0, $c3 = 0, $c4 = 0)
         // convert hex to rgb
         if ($cs == 'hex') {
             $cs = 'rgb';
-            list($c1, $c2, $c3) = $this->_hexToRgb($c1);
+            [$c1, $c2, $c3] = $this->_hexToRgb($c1);
         }
 
         if ($cs == 'rgb') {
@@ -1102,7 +1103,7 @@ public function setTextColor($cs = 'rgb', $c1 = 0, $c2 = 0, $c3 = 0, $c4 = 0)
         // convert hex to rgb
         if ($cs == 'hex') {
             $cs = 'rgb';
-            list($c1, $c2, $c3) = $this->_hexToRgb($c1);
+            [$c1, $c2, $c3] = $this->_hexToRgb($c1);
         }
 
         if ($cs == 'rgb') {
@@ -1158,7 +1159,7 @@ public function setDrawColor($cs = 'rgb', $c1 = 0, $c2 = 0, $c3 = 0, $c4 = 0)
         // convert hex to rgb
         if ($cs == 'hex') {
             $cs = 'rgb';
-            list($c1, $c2, $c3) = $this->_hexToRgb($c1);
+            [$c1, $c2, $c3] = $this->_hexToRgb($c1);
         }
 
         if ($cs == 'rgb') {
@@ -1194,7 +1195,7 @@ public function getDrawColor()
      */
     public function getStringWidth($text, $pt = false)
     {
-        $text = (string)$text;
+        $text = (string) $text;
         $width = 0;
         $length = strlen($text);
         for ($i = 0; $i < $length; $i++) {
@@ -1392,35 +1393,55 @@ public function circle($x, $y, $r, $style = '')
         $c = sprintf('%.2F %.2F m', $x - $r, $y);
         $x = $x - $r;
         /* First circle quarter. */
-        $c .= sprintf(' %.2F %.2F %.2F %.2F %.2F %.2F c',
-                      $x, $y + $b,           // First control point.
-                      $x + $r - $b, $y + $r, // Second control point.
-                      $x + $r, $y + $r);     // Final point.
+        $c .= sprintf(
+            ' %.2F %.2F %.2F %.2F %.2F %.2F c',
+            $x,
+            $y + $b,           // First control point.
+            $x + $r - $b,
+            $y + $r, // Second control point.
+            $x + $r,
+            $y + $r
+        );     // Final point.
         /* Set x/y to the final point. */
         $x = $x + $r;
         $y = $y + $r;
         /* Second circle quarter. */
-        $c .= sprintf(' %.2F %.2F %.2F %.2F %.2F %.2F c',
-                      $x + $b, $y,
-                      $x + $r, $y - $r + $b,
-                      $x + $r, $y - $r);
+        $c .= sprintf(
+            ' %.2F %.2F %.2F %.2F %.2F %.2F c',
+            $x + $b,
+            $y,
+            $x + $r,
+            $y - $r + $b,
+            $x + $r,
+            $y - $r
+        );
         /* Set x/y to the final point. */
         $x = $x + $r;
         $y = $y - $r;
         /* Third circle quarter. */
-        $c .= sprintf(' %.2F %.2F %.2F %.2F %.2F %.2F c',
-                      $x, $y - $b,
-                      $x - $r + $b, $y - $r,
-                      $x - $r, $y - $r);
+        $c .= sprintf(
+            ' %.2F %.2F %.2F %.2F %.2F %.2F c',
+            $x,
+            $y - $b,
+            $x - $r + $b,
+            $y - $r,
+            $x - $r,
+            $y - $r
+        );
         /* Set x/y to the final point. */
         $x = $x - $r;
         $y = $y - $r;
         /* Fourth circle quarter. */
-        $c .= sprintf(' %.2F %.2F %.2F %.2F %.2F %.2F c %s',
-                      $x - $b, $y,
-                      $x - $r, $y + $r - $b,
-                      $x - $r, $y + $r,
-                      $op);
+        $c .= sprintf(
+            ' %.2F %.2F %.2F %.2F %.2F %.2F c %s',
+            $x - $b,
+            $y,
+            $x - $r,
+            $y + $r - $b,
+            $x - $r,
+            $y + $r,
+            $op
+        );
         /* Output the whole string. */
         $this->_out($c);
     }
@@ -1477,7 +1498,7 @@ public function addFont($family, $style = '', $file = '')
             throw new Horde_Pdf_Exception('Could not include font definition file');
         }
         $i = count($this->_fonts) + 1;
-        $this->_fonts[$family . $style] = array('i' => $i, 'type' => $type, 'name' => $name, 'desc' => $desc, 'up' => $up, 'ut' => $ut, 'cw' => $cw, 'enc' => $enc, 'file' => $file);
+        $this->_fonts[$family . $style] = ['i' => $i, 'type' => $type, 'name' => $name, 'desc' => $desc, 'up' => $up, 'ut' => $ut, 'cw' => $cw, 'enc' => $enc, 'file' => $file];
         if ($diff) {
             /* Search existing encodings. */
             $d = 0;
@@ -1496,9 +1517,9 @@ public function addFont($family, $style = '', $file = '')
         }
         if ($file) {
             if ($type == 'TrueType') {
-                $this->_font_files[$file] = array('length1' => $originalsize);
+                $this->_font_files[$file] = ['length1' => $originalsize];
             } else {
-                $this->_font_files[$file] = array('length1' => $size1, 'length2' => $size2);
+                $this->_font_files[$file] = ['length1' => $size1, 'length2' => $size2];
             }
         }
     }
@@ -1587,8 +1608,8 @@ public function setFont($family, $style = '', $size = null, $force = false)
         /* If font requested is already the current font and no force setting
          * of the font is requested (eg. when adding a new page) don't bother
          * with the rest of the function and simply return. */
-        if ($this->_font_family == $family && $this->_font_style == $style &&
-            $this->_font_size_pt == $size && !$force) {
+        if ($this->_font_family == $family && $this->_font_style == $style
+            && $this->_font_size_pt == $size && !$force) {
             return;
         }
 
@@ -1601,13 +1622,13 @@ public function setFont($family, $style = '', $size = null, $force = false)
             $font_widths = self::_getFontFile($fontkey);
 
             $i = count($this->_fonts) + 1;
-            $this->_fonts[$fontkey] = array(
+            $this->_fonts[$fontkey] = [
                 'i'    => $i,
                 'type' => 'core',
                 'name' => $this->_core_fonts[$fontkey],
                 'up'   => -100,
                 'ut'   => 50,
-                'cw'   => $font_widths[$fontkey]);
+                'cw'   => $font_widths[$fontkey]];
         }
 
         /* Store font information as current font. */
@@ -1678,7 +1699,7 @@ public function setFontStyle($style)
     public function addLink()
     {
         $n = count($this->_links) + 1;
-        $this->_links[$n] = array(0, 0);
+        $this->_links[$n] = [0, 0];
         return $n;
     }
 
@@ -1702,7 +1723,7 @@ public function setLink($link, $y = 0, $page = -1)
         if ($page == -1) {
             $page = $this->_page;
         }
-        $this->_links[$link] = array($page, $y);
+        $this->_links[$link] = [$page, $y];
     }
 
     /**
@@ -1862,12 +1883,19 @@ public function acceptPageBreak()
      * @see write()
      * @see setAutoPageBreak()
      */
-    public function cell($width, $height = 0, $text = '', $border = 0, $ln = 0,
-                  $align = '', $fill = 0, $link = '')
-    {
+    public function cell(
+        $width,
+        $height = 0,
+        $text = '',
+        $border = 0,
+        $ln = 0,
+        $align = '',
+        $fill = 0,
+        $link = ''
+    ) {
         $k = $this->_scale;
-        if ($this->y + $height > $this->_page_break_trigger &&
-            !$this->_in_footer && $this->acceptPageBreak()) {
+        if ($this->y + $height > $this->_page_break_trigger
+            && !$this->_in_footer && $this->acceptPageBreak()) {
             $x = $this->x;
             $ws = $this->_word_spacing;
             if ($ws > 0) {
@@ -1931,7 +1959,7 @@ public function cell($width, $height = 0, $text = '', $border = 0, $ln = 0,
                 $s .= ' Q';
             }
             if ($link) {
-                $this->link($this->x + $dx, $this->y + .5 * $height- .5 * $this->_font_size, $this->getStringWidth($text), $this->_font_size, $link);
+                $this->link($this->x + $dx, $this->y + .5 * $height - .5 * $this->_font_size, $this->getStringWidth($text), $this->_font_size, $link);
             }
         }
         if ($s) {
@@ -1989,17 +2017,22 @@ public function cell($width, $height = 0, $text = '', $border = 0, $ln = 0,
      * @see write()
      * @see setAutoPageBreak()
      */
-    public function multiCell($width, $height, $text, $border = 0, $align = 'J',
-                       $fill = 0)
-    {
+    public function multiCell(
+        $width,
+        $height,
+        $text,
+        $border = 0,
+        $align = 'J',
+        $fill = 0
+    ) {
         $cw = $this->_current_font['cw'];
         if ($width == 0) {
             $width = $this->w - $this->_right_margin - $this->x;
         }
-        $wmax = ($width-2 * $this->_cell_margin) * 1000 / $this->_font_size;
+        $wmax = ($width - 2 * $this->_cell_margin) * 1000 / $this->_font_size;
         $s = str_replace("\r", '', $text);
         $nb = strlen($s);
-        if ($nb > 0 && $s[$nb-1] == "\n") {
+        if ($nb > 0 && $s[$nb - 1] == "\n") {
             $nb--;
         }
         $b = 0;
@@ -2034,7 +2067,7 @@ public function multiCell($width, $height, $text, $border = 0, $align = 'J',
                     $this->_word_spacing = 0;
                     $this->_out('0 Tw');
                 }
-                $this->cell($width, $height, substr($s, $j, $i-$j), $b, 2, $align, $fill);
+                $this->cell($width, $height, substr($s, $j, $i - $j), $b, 2, $align, $fill);
                 $i++;
                 $sep = -1;
                 $j = $i;
@@ -2065,7 +2098,7 @@ public function multiCell($width, $height, $text, $border = 0, $align = 'J',
                     $this->cell($width, $height, substr($s, $j, $i - $j), $b, 2, $align, $fill);
                 } else {
                     if ($align == 'J') {
-                        $this->_word_spacing = ($ns>1) ? ($wmax - $ls)/1000 * $this->_font_size / ($ns - 1) : 0;
+                        $this->_word_spacing = ($ns > 1) ? ($wmax - $ls) / 1000 * $this->_font_size / ($ns - 1) : 0;
                         $this->_out(sprintf('%.3F Tw', $this->_word_spacing * $this->_scale));
                     }
                     $this->cell($width, $height, substr($s, $j, $sep - $j), $b, 2, $align, $fill);
@@ -2158,7 +2191,7 @@ public function write($height, $text, $link = '')
                 $sep = $i;
                 $ls = $l;
             }
-            $l += (isset($cw[$c]) ? $cw[$c] : 0);
+            $l += ($cw[$c] ?? 0);
             if ($l > $wmax) {
                 // Automatic line break.
                 if ($sep == -1) {
@@ -2234,9 +2267,16 @@ public function writeRotated($x, $y, $text, $text_angle, $font_angle = 0)
         $font_dx = cos($font_angle);
         $font_dy = sin($font_angle);
 
-        $s= sprintf('BT %.2F %.2F %.2F %.2F %.2F %.2F Tm (%s) Tj ET',
-                    $text_dx, $text_dy, $font_dx, $font_dy,
-                    $x * $this->_scale, ($this->h-$y) * $this->_scale, $text);
+        $s = sprintf(
+            'BT %.2F %.2F %.2F %.2F %.2F %.2F Tm (%s) Tj ET',
+            $text_dx,
+            $text_dy,
+            $font_dx,
+            $font_dy,
+            $x * $this->_scale,
+            ($this->h - $y) * $this->_scale,
+            $text
+        );
 
         if ($this->_draw_color) {
             $s = 'q ' . $this->_draw_color . ' ' . $s . ' Q';
@@ -2293,9 +2333,15 @@ public function writeRotated($x, $y, $text, $text_angle, $font_angle = 0)
      *
      * @see addLink()
      */
-    public function image($file, $x, $y, $width = 0, $height = 0, $type = '',
-                   $link = '')
-    {
+    public function image(
+        $file,
+        $x,
+        $y,
+        $width = 0,
+        $height = 0,
+        $type = '',
+        $link = ''
+    ) {
         if ($x < 0) {
             $x += $this->w;
         }
@@ -2314,7 +2360,9 @@ public function image($file, $x, $y, $width = 0, $height = 0, $type = '',
             }
 
             $mqr = function_exists("get_magic_quotes_runtime") ? @get_magic_quotes_runtime() : 0;
-            if ($mqr) { set_magic_quotes_runtime(0); }
+            if ($mqr) {
+                set_magic_quotes_runtime(0);
+            }
 
             $type = Horde_String::lower($type);
             if ($type == 'jpg' || $type == 'jpeg') {
@@ -2325,7 +2373,9 @@ public function image($file, $x, $y, $width = 0, $height = 0, $type = '',
                 throw new Horde_Pdf_Exception(sprintf('Unsupported image file type: %s', $type));
             }
 
-            if ($mqr) { set_magic_quotes_runtime($mqr); }
+            if ($mqr) {
+                set_magic_quotes_runtime($mqr);
+            }
 
             $info['i'] = count($this->_images) + 1;
             $this->_images[$file] = $info;
@@ -2579,7 +2629,7 @@ protected static function _getFontFile($fontkey)
                 throw new Horde_Pdf_Exception(sprintf('Could not include font metric class: %s', $fontClass));
             }
 
-            $font = new $fontClass;
+            $font = new $fontClass();
 
             self::$_font_widths = array_merge(self::$_font_widths, $font->getWidths());
             if (!isset(self::$_font_widths[$fontkey])) {
@@ -2602,7 +2652,7 @@ protected static function _getFontFile($fontkey)
      */
     protected function _link($x, $y, $width, $height, $link)
     {
-        $this->_page_links[$this->_page][] = array($x, $y, $width, $height, $link);
+        $this->_page_links[$this->_page][] = [$x, $y, $width, $height, $link];
     }
 
     /**
@@ -2706,7 +2756,9 @@ protected function _putFonts()
         }
 
         $mqr = function_exists("get_magic_quotes_runtime") ? @get_magic_quotes_runtime() : 0;
-        if ($mqr) { set_magic_quotes_runtime(0); }
+        if ($mqr) {
+            set_magic_quotes_runtime(0);
+        }
 
         foreach ($this->_font_files as $file => $info) {
             // Font file embedding.
@@ -2731,7 +2783,9 @@ protected function _putFonts()
             $this->_out('endobj');
         }
 
-        if ($mqr) { set_magic_quotes_runtime($mqr); }
+        if ($mqr) {
+            set_magic_quotes_runtime($mqr);
+        }
 
         foreach ($this->_fonts as $k => $font) {
             // Font objects
@@ -2805,7 +2859,7 @@ protected function _putImages()
             $this->_out('/Width ' . $info['w']);
             $this->_out('/Height ' . $info['h']);
             if ($info['cs'] == 'Indexed') {
-                $this->_out('/ColorSpace [/Indexed /DeviceRGB ' . (strlen($info['pal'])/3 - 1) . ' ' . ($this->_n + 1) . ' 0 R]');
+                $this->_out('/ColorSpace [/Indexed /DeviceRGB ' . (strlen($info['pal']) / 3 - 1) . ' ' . ($this->_n + 1) . ' 0 R]');
             } else {
                 $this->_out('/ColorSpace /' . $info['cs']);
                 if ($info['cs'] == 'DeviceCMYK') {
@@ -3089,14 +3143,14 @@ protected function _parseJPG($file)
         } else {
             $colspace = 'DeviceGray';
         }
-        $bpc = isset($img['bits']) ? $img['bits'] : 8;
+        $bpc = $img['bits'] ?? 8;
 
         // Read whole file.
         $f = fopen($file, 'rb');
         $data = fread($f, filesize($file));
         fclose($f);
 
-        return array('w' => $img[0], 'h' => $img[1], 'cs' => $colspace, 'bpc' => $bpc, 'f' => 'DCTDecode', 'data' => $data);
+        return ['w' => $img[0], 'h' => $img[1], 'cs' => $colspace, 'bpc' => $bpc, 'f' => 'DCTDecode', 'data' => $data];
     }
 
     /**
@@ -3165,13 +3219,13 @@ protected function _parsePNG($file)
                 // Read transparency info
                 $t = fread($f, $n);
                 if ($ct == 0) {
-                    $trns = array(ord(substr($t, 1, 1)));
+                    $trns = [ord(substr($t, 1, 1))];
                 } elseif ($ct == 2) {
-                    $trns = array(ord(substr($t, 1, 1)), ord(substr($t, 3, 1)), ord(substr($t, 5, 1)));
+                    $trns = [ord(substr($t, 1, 1)), ord(substr($t, 3, 1)), ord(substr($t, 5, 1))];
                 } else {
                     $pos = strpos($t, chr(0));
                     if (is_int($pos)) {
-                        $trns = array($pos);
+                        $trns = [$pos];
                     }
                 }
                 fread($f, 4);
@@ -3191,7 +3245,7 @@ protected function _parsePNG($file)
         }
         fclose($f);
 
-        return array('w' => $width, 'h' => $height, 'cs' => $colspace, 'bpc' => $bpc, 'f' => 'FlateDecode', 'parms' => $parms, 'pal' => $pal, 'trns' => $trns, 'data' => $data);
+        return ['w' => $width, 'h' => $height, 'cs' => $colspace, 'bpc' => $bpc, 'f' => 'FlateDecode', 'parms' => $parms, 'pal' => $pal, 'trns' => $trns, 'data' => $data];
     }
 
     /**
@@ -3229,9 +3283,11 @@ protected function _textString($s)
      */
     protected function _escape($s)
     {
-        return str_replace(array('\\', ')', '('),
-                           array('\\\\', '\\)', '\\('),
-                           $s);
+        return str_replace(
+            ['\\', ')', '('],
+            ['\\\\', '\\)', '\\('],
+            $s
+        );
     }
 
     /**
@@ -3267,21 +3323,23 @@ protected function _out($s)
      */
     protected function _hexToRgb($hex)
     {
-        if (substr($hex, 0, 1) == '#') { $hex = substr($hex, 1); }
+        if (substr($hex, 0, 1) == '#') {
+            $hex = substr($hex, 1);
+        }
 
         if (strlen($hex) == 6) {
-            list($r, $g, $b) = array(substr($hex, 0, 2),
-                                     substr($hex, 2, 2),
-                                     substr($hex, 4, 2));
+            [$r, $g, $b] = [substr($hex, 0, 2),
+                substr($hex, 2, 2),
+                substr($hex, 4, 2)];
         } elseif (strlen($hex) == 3) {
-            list($r, $g, $b) = array(substr($hex, 0, 1).substr($hex, 0, 1),
-                                     substr($hex, 1, 1).substr($hex, 1, 1),
-                                     substr($hex, 2, 1).substr($hex, 2, 1));
+            [$r, $g, $b] = [substr($hex, 0, 1) . substr($hex, 0, 1),
+                substr($hex, 1, 1) . substr($hex, 1, 1),
+                substr($hex, 2, 1) . substr($hex, 2, 1)];
         }
-        $r = hexdec($r)/255;
-        $g = hexdec($g)/255;
-        $b = hexdec($b)/255;
+        $r = hexdec($r) / 255;
+        $g = hexdec($g) / 255;
+        $b = hexdec($b) / 255;
 
-        return array($r, $g, $b);
+        return [$r, $g, $b];
     }
 }

From 88cfa0e9c8f4b10fd52e965857058bf76ea8571c Mon Sep 17 00:00:00 2001
From: Ralf Lang 
Date: Mon, 27 Apr 2026 20:41:53 +0200
Subject: [PATCH 3/3] docs: metadata and readme

---
 .gitignore | 20 ++++++++++++++++++++
 .horde.yml | 14 +++++++++++++-
 README.md  |  6 ++++++
 Readme.md  |  1 -
 4 files changed, 39 insertions(+), 2 deletions(-)
 create mode 100644 README.md
 delete mode 100644 Readme.md

diff --git a/.gitignore b/.gitignore
index 01fdce2..c20f03e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,23 @@ run-tests.log
 /test/*/*/*/*.log
 /test/*/*/*/*.out
 
+
+# Added by horde-components QC --fix-qc-issues
+# Build artifacts directory
+/build/
+# PHPStorm IDE settings
+/.idea/
+# VSCode IDE settings
+/.vscode/
+# Claude Code CLI cache and state
+/.claude/
+# Cline extension data
+/.cline/
+# PHP CS Fixer cache file
+/.php-cs-fixer.cache
+# PHPUnit result cache
+/.phpunit.result.cache
+# PHPStan local configuration
+/phpstan.neon
+# PHPStan cache directory
+/.phpstan.cache/
diff --git a/.horde.yml b/.horde.yml
index a1f0444..d711ec4 100644
--- a/.horde.yml
+++ b/.horde.yml
@@ -9,11 +9,17 @@ list: dev
 type: library
 homepage: https://www.horde.org/libraries/Horde_Pdf
 authors:
+  -
+    name: Ralf Lang
+    user: rlang
+    email: ralf.lang@ralf-lang.de
+    active: trueB
+    role: lead
   -
     name: Jan Schneider
     user: jan
     email: jan@horde.org
-    active: true
+    active: false
     role: lead
   -
     name: Mike Naberezny
@@ -45,3 +51,9 @@ dependencies:
   optional:
     composer:
       horde/test: ^3
+keywords:
+  - iso32000
+vendor: horde
+quality:
+  phpstan:
+    level: 3
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4be98bc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,6 @@
+# Horde/Pdf
+
+The lib/ version is a very early fork of FPDF
+The src/ version is an incomplete implementation of modern ISO 32000-2 PDF.
+
+
diff --git a/Readme.md b/Readme.md
deleted file mode 100644
index 57f8d67..0000000
--- a/Readme.md
+++ /dev/null
@@ -1 +0,0 @@
-# Horde_Pdf_Writer