diff --git a/.gitignore b/.gitignore index 4e78ae3..c20f03e 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 @@ -27,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 a6ca767..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 @@ -38,10 +44,16 @@ license: uri: http://www.horde.org/licenses/lgpl21 dependencies: required: - php: ^7.4 || ^8 + php: ^8.1 composer: horde/exception: ^3 horde/util: ^3 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 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/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];
     }
 }
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