Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.74% covered (success)
96.74%
623 / 644
81.03% covered (warning)
81.03%
47 / 58
CRAP
0.00% covered (danger)
0.00%
0 / 1
Pdf
96.74% covered (success)
96.74%
623 / 644
81.03% covered (warning)
81.03%
47 / 58
141
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setFont
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setTheme
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAuthor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSubject
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setKeywords
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setCreator
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setViewerPreferences
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 attachFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setOpenAction
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setHeader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setFooter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 enableOutline
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 showPageNumbers
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 setWatermark
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getTheme
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPdfVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEncodingWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addPage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 newPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addHtml
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
3.27
 setColumns
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 addText
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
12
 addHeading
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 addSpacer
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 addRule
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
2.01
 addCallout
97.18% covered (success)
97.18%
69 / 71
0.00% covered (danger)
0.00%
0 / 1
6
 addQuote
98.36% covered (success)
98.36%
60 / 61
0.00% covered (danger)
0.00%
0 / 1
8
 addList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addNumberedList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addListInternal
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
4
 addTable
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
5
 addBarcode
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 addImage
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 save
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 toBytes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 writeTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ensurePage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 recordOutlineEntry
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
11
 applyDecorators
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 drawDefaultWatermark
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 contentWidth
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 totalContentWidth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 columnLeftX
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 topOfColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 advanceOnOverflow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 equalColumns
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 tableContext
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 bottomMargin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveFontName
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
4
 getMetrics
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ensureFontResource
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 wrapText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 measureText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyFillColor
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Writer;
6
7use Phpdftk\FontMetrics\AfmData;
8use Phpdftk\FontMetrics\StandardFontMetrics;
9use Phpdftk\ImageMetadata\ImageParser;
10use Phpdftk\Pdf\Core\Content\ContentStream;
11use Phpdftk\Pdf\Core\Font\StandardFont;
12use Phpdftk\Pdf\Core\Font\Type1Font;
13use Phpdftk\Pdf\Core\PdfVersion;
14
15/**
16 * High-level PDF document builder â€” **zero PDF object-model knowledge
17 * required**.
18 *
19 * `Pdf` is a stateful, cursor-driven builder on top of {@see PdfWriter}.
20 * It maintains a current page, a text cursor, and a default font; each
21 * call flows content downward from the top margin, automatically
22 * breaking to a new page when the content column fills up.
23 *
24 * ### Example
25 *
26 * ```php
27 * $pdf = new Pdf();                              // Letter, 72pt margins, Helvetica 11
28 * $pdf->addHeading('Welcome', 1);
29 * $pdf->addText('This is body text. It wraps automatically to the content');
30 * $pdf->addText('column width, and overflowing content starts a new page.');
31 * $pdf->addSpacer(12);
32 * $pdf->addImage('/path/to/photo.jpg', width: 300);
33 * $pdf->save('/out.pdf');
34 * ```
35 *
36 * ### Output modes
37 *
38 * Three mutually-exclusive ways to emit the finished document:
39 *
40 *   - `save(string $path)` â€” write to a file
41 *   - `toBytes(): string` â€” get the bytes as a string
42 *   - `writeTo($resource): int` â€” write to an open stream resource
43 *
44 * ### Scope
45 *
46 * Phase 1 handles the 80% case: word-wrapped body text, H1–H6 headings,
47 * images with auto-scaling, spacers, horizontal rules, explicit and
48 * automatic page breaks, and Left/Center/Right alignment. Fonts are
49 * limited to the 14 standard PDF fonts (Helvetica / Times / Courier
50 * families plus Symbol and ZapfDingbats). For custom TrueType fonts,
51 * embedded images with precise transforms, tables, or absolute-positioned
52 * graphics, drop to the underlying {@see PdfWriter} via {@see writer()}.
53 *
54 * @api
55 */
56class Pdf
57{
58    private PdfDoc $doc;
59    private PdfWriter $writer;
60    private Theme $theme;
61    private PageSize $pageSize;
62
63    private ?Page $currentPage = null;
64
65    /**
66     * Every page added through {@see addPage()}, in order. Each entry
67     * is `[Page, width, height]` so the deferred decorator pass has
68     * page geometry without re-parsing mediaBox entries.
69     *
70     * @var list<array{Page, float, float}>
71     */
72    private array $pages = [];
73
74    /**
75     * Per-page hooks (header / footer / watermark). Applied in a
76     * deferred pass right before {@see toBytes()}, {@see save()}, or
77     * {@see writeTo()} produces output.
78     */
79    private PageDecorator $decorator;
80
81    /** Guard so the deferred decorator pass only runs once per document. */
82    private bool $decoratorsApplied = false;
83
84    /** Whether auto-outline is active â€” set via {@see enableOutline()}. */
85    private bool $outlineEnabled = false;
86
87    /** Lazily-created outline root once auto-outline is enabled. */
88    private ?\Phpdftk\Pdf\Core\Document\Outline $outlineRoot = null;
89
90    /**
91     * The most recent `OutlineItem` seen at each heading level, used
92     * both to find the parent for a deeper-level heading and to chain
93     * siblings at the same level.
94     *
95     * @var array<int, \Phpdftk\Pdf\Core\Document\OutlineItem>
96     */
97    private array $outlineLastAtLevel = [];
98
99    /** Running count of all outline entries â€” written to Outline::$count. */
100    private int $outlineCount = 0;
101
102    /**
103     * Direct content-stream handle for cursor-based text rendering.
104     * Retrieved from the Writer\Page escape hatch.
105     */
106    private ?ContentStream $currentStream = null;
107
108    /** Current font family (resolved to standard-14 PostScript name family) */
109    private string $font;
110    private float $fontSize;
111    private bool $bold = false;
112    private bool $italic = false;
113
114    /**
115     * Current cursor, top-down from the page top-left corner.
116     * `$cursorY` decreases as content is added. A fresh page starts with
117     * `cursorY = pageHeight - theme->margin`.
118     */
119    private float $cursorY = 0.0;
120
121    /** Number of columns the body region is split into (default 1). */
122    private int $columnCount = 1;
123
124    /** Gap between columns in points. */
125    private float $columnGutter = 12.0;
126
127    /** Zero-based index of the column the cursor is currently in. */
128    private int $currentColumnIndex = 0;
129
130    /** Remember current fill color so we only emit it when it changes. */
131    private ?string $lastFillColor = null;
132
133    /** @var array<string, Font> family+variant key => registered font handle */
134    private array $fontResourceCache = [];
135
136    /** @var array<string, AfmData> family+variant key => AFM metrics for width measurement */
137    private array $fontMetricsCache = [];
138
139    public function __construct(
140        PageSize $pageSize = PageSize::Letter,
141        ?Theme $theme = null,
142        bool $compressStreams = true,
143    ) {
144        $this->doc = new PdfDoc($compressStreams);
145        $this->writer = $this->doc->writer();
146        $this->pageSize = $pageSize;
147        $this->theme = $theme ?? new Theme();
148        $this->font = $this->theme->family;
149        $this->fontSize = $this->theme->fontSize;
150        $this->decorator = new PageDecorator();
151    }
152
153    // -----------------------------------------------------------------------
154    // Theme / font state
155    // -----------------------------------------------------------------------
156
157    /**
158     * Set the default body font for subsequent content. Accepts one of
159     * the standard 14 PDF font families: Helvetica, Times, Courier,
160     * Symbol, ZapfDingbats.
161     */
162    public function setFont(string $family, float $size, bool $bold = false, bool $italic = false): self
163    {
164        $this->font = $family;
165        $this->fontSize = $size;
166        $this->bold = $bold;
167        $this->italic = $italic;
168        return $this;
169    }
170
171    public function setTheme(Theme $theme): self
172    {
173        $this->theme = $theme;
174        $this->font = $theme->family;
175        $this->fontSize = $theme->fontSize;
176        return $this;
177    }
178
179    // -----------------------------------------------------------------------
180    // Document metadata (forwarders to PdfDoc)
181    // -----------------------------------------------------------------------
182
183    public function setTitle(string $title): self
184    {
185        $this->doc->setTitle($title);
186        return $this;
187    }
188
189    public function setAuthor(string $author): self
190    {
191        $this->doc->setAuthor($author);
192        return $this;
193    }
194
195    public function setSubject(string $subject): self
196    {
197        $this->doc->setSubject($subject);
198        return $this;
199    }
200
201    public function setKeywords(string $keywords): self
202    {
203        $this->doc->setKeywords($keywords);
204        return $this;
205    }
206
207    public function setCreator(string $creator): self
208    {
209        $this->doc->setCreator($creator);
210        return $this;
211    }
212
213    /**
214     * Set the document's viewer preferences (display options the
215     * reader honours when opening the file). Forwards to
216     * {@see PdfDoc::setViewerPreferences()}.
217     */
218    public function setViewerPreferences(
219        \Phpdftk\Pdf\Core\Document\ViewerPreferences|\Closure $prefs,
220    ): self {
221        $this->doc->setViewerPreferences($prefs);
222        return $this;
223    }
224
225    /**
226     * Attach a file from disk to the document. Forwards to
227     * {@see PdfDoc::attachFile()}.
228     */
229    public function attachFile(
230        string $path,
231        ?string $description = null,
232        ?string $mimeType = null,
233        ?string $relationship = null,
234    ): self {
235        $this->doc->attachFile($path, $description, $mimeType, $relationship);
236        return $this;
237    }
238
239    /**
240     * Set the document's open action â€” executed by the viewer when
241     * the file is loaded. Forwards to {@see PdfDoc::setOpenAction()}.
242     */
243    public function setOpenAction(\Phpdftk\Pdf\Core\Action\Action $action): self
244    {
245        $this->doc->setOpenAction($action);
246        return $this;
247    }
248
249    // -----------------------------------------------------------------------
250    // Per-page render hooks (header / footer / watermark)
251    // -----------------------------------------------------------------------
252
253    /**
254     * Register a closure invoked on every page after flow content is
255     * placed. The closure receives a {@see PageContext} with the
256     * current page number, total page count, and a {@see Page} handle
257     * for drawing into the header region.
258     *
259     * The body region shrinks by `Theme::headerHeight` to leave room
260     * for the header; configure that on your theme if you want a
261     * non-zero reserved area.
262     */
263    public function setHeader(\Closure $header): self
264    {
265        $this->decorator = $this->decorator->withHeader($header);
266        return $this;
267    }
268
269    /**
270     * Register a closure invoked on every page after flow content is
271     * placed, to draw the footer region. See {@see setHeader()}.
272     */
273    public function setFooter(\Closure $footer): self
274    {
275        $this->decorator = $this->decorator->withFooter($footer);
276        return $this;
277    }
278
279    /**
280     * Enable automatic outline (bookmarks) generation from `addHeading()`
281     * calls. Each heading registers an `OutlineItem` with a destination
282     * pointing at the current page + y; heading level controls the
283     * parent â†’ child nesting (level 2 nests under the previous level 1,
284     * etc.).
285     *
286     * No-op when called before any heading exists. Disable later with
287     * `enableOutline(false)` to stop recording further headings.
288     */
289    public function enableOutline(bool $enabled = true): self
290    {
291        $this->outlineEnabled = $enabled;
292        if ($enabled && $this->outlineRoot === null) {
293            $this->outlineRoot = new \Phpdftk\Pdf\Core\Document\Outline();
294            $this->doc->setOutline($this->outlineRoot);
295        }
296        return $this;
297    }
298
299    /**
300     * Show page numbers in the footer of every page. Sugar over
301     * {@see setFooter()} that uses `PageContext::$totalPages` from the
302     * deferred decorator pass, so `'Page %d of %d'`-style formats work
303     * without manual two-pass logic.
304     *
305     * Set `Theme::footerHeight` to reserve space so the page number
306     * doesn't overlap body content.
307     */
308    public function showPageNumbers(
309        string $format = 'Page %d of %d',
310        Alignment $align = Alignment::Center,
311        float $fontSize = 9.0,
312    ): self {
313        $this->setFooter(function (PageContext $ctx) use ($format, $align, $fontSize): void {
314            $text = sprintf($format, $ctx->pageNumber, $ctx->totalPages);
315
316            $postScriptName = $this->resolveFontName($this->font, $this->bold, $this->italic);
317            $font = $this->ensureFontResource($postScriptName);
318            $metrics = $this->getMetrics($postScriptName);
319            $encoded = $font->getTextEncoder()?->encode($text) ?? $text;
320            $width = TextLayout::measure($encoded, $metrics, $fontSize);
321
322            $contentWidth = $ctx->pageWidth - 2.0 * $ctx->theme->margin;
323            $x = $ctx->theme->margin + match ($align) {
324                Alignment::Left   => 0.0,
325                Alignment::Center => ($contentWidth - $width) / 2.0,
326                Alignment::Right  => $contentWidth - $width,
327            };
328            // Anchor inside the bottom margin (or the footer reserve if set).
329            $y = $ctx->theme->footerHeight > 0
330                ? $ctx->theme->margin + $ctx->theme->footerHeight / 2.0 - $fontSize / 2.0
331                : $ctx->theme->margin / 2.0;
332
333            $ctx->page->contentStream()
334                ->beginText()
335                ->setFont($font->getResourceName(), $fontSize)
336                ->moveTextPosition($x, $y)
337                ->showText($encoded)
338                ->endText();
339        });
340        return $this;
341    }
342
343    /**
344     * Set a watermark drawn on every page. A string is rendered as
345     * centered diagonal grey text; a closure is invoked per page with
346     * a {@see PageContext} for full control.
347     */
348    public function setWatermark(
349        string|\Closure $textOrFn,
350        float $opacity = 0.2,
351        float $angleDeg = 45.0,
352    ): self {
353        if ($textOrFn instanceof \Closure) {
354            $closure = $textOrFn;
355        } else {
356            $text = $textOrFn;
357            $closure = function (PageContext $ctx) use ($text, $opacity, $angleDeg): void {
358                $this->drawDefaultWatermark($ctx, $text, $opacity, $angleDeg);
359            };
360        }
361        $this->decorator = $this->decorator->withWatermark($closure);
362        return $this;
363    }
364
365    public function getTheme(): Theme
366    {
367        return $this->theme;
368    }
369
370    public function getPdfVersion(): PdfVersion
371    {
372        return $this->writer->getPdfVersion();
373    }
374
375    /**
376     * Escape hatch to Level 2: returns the underlying {@see PdfDoc} so
377     * callers can use friendly wrappers (annotations, form fields,
378     * file attachments, viewer prefs, etc.) without leaving the
379     * flow-builder context.
380     */
381    public function doc(): PdfDoc
382    {
383        return $this->doc;
384    }
385
386    /**
387     * Escape hatch to Level 1: returns the underlying {@see PdfWriter}
388     * for byte/resource control (custom fonts, encryption, signing,
389     * conformance). Equivalent to `doc()->writer()`.
390     */
391    public function writer(): PdfWriter
392    {
393        return $this->writer;
394    }
395
396    /**
397     * Codepoints that were substituted with `?` because the active font's
398     * encoding could not represent them. Useful after building a document
399     * to confirm that no unintended replacement characters slipped in.
400     *
401     * @return list<string>
402     */
403    public function getEncodingWarnings(): array
404    {
405        return $this->writer->getEncodingWarnings();
406    }
407
408    // -----------------------------------------------------------------------
409    // Pages
410    // -----------------------------------------------------------------------
411
412    /**
413     * Start a new page. The first `add*` call will also start a page
414     * automatically if one has not yet been created, so calling this
415     * explicitly is only required when you want to force a page break
416     * or use a non-default size.
417     */
418    public function addPage(?PageSize $size = null): self
419    {
420        $size ??= $this->pageSize;
421        $this->pageSize = $size;
422        $this->currentPage = $this->writer->addPage($size->width(), $size->height());
423        $this->currentStream = $this->currentPage->contentStream();
424        $this->cursorY = $size->height() - $this->theme->margin - $this->theme->headerHeight;
425        $this->lastFillColor = null;
426        $this->currentColumnIndex = 0;
427        $this->pages[] = [$this->currentPage, $size->width(), $size->height()];
428        return $this;
429    }
430
431    /** Force a page break. Equivalent to `addPage()` with the current size. */
432    public function newPage(): self
433    {
434        return $this->addPage();
435    }
436
437    /**
438     * Render an HTML + CSS document into the PDF as a sequence of fresh
439     * pages, then invalidate the cursor so subsequent `addText` /
440     * `addHeading` / etc. start on a new page.
441     *
442     * The HTML renderer ships in `phpdftk/html-to-pdf` and depends on
443     * `phpdftk/pdf-writer` â€” so to avoid a circular composer dependency,
444     * this method only works when that package is installed. The class
445     * lookup happens lazily on first call; absent the package, the
446     * method throws a helpful `RuntimeException`.
447     *
448     * Note: this *does not* try to fit content under the current cursor.
449     * For inline HTML rendering at the cursor position, drop down to
450     * `Phpdftk\HtmlToPdf\Renderer` directly and pass `Pdf::writer()` to
451     * `renderInto()`.
452     */
453    public function addHtml(
454        string $html,
455        ?string $css = null,
456        ?\Phpdftk\FontParser\OpenTypeData $font = null,
457    ): self {
458        $rendererClass = '\\Phpdftk\\HtmlToPdf\\Renderer';
459        $optionsClass = '\\Phpdftk\\HtmlToPdf\\RendererOptions';
460        if (!class_exists($rendererClass)) {
461            throw new \RuntimeException(
462                'Pdf::addHtml() requires the phpdftk/html-to-pdf package â€” '
463                . 'install it via Composer or use `composer require phpdftk/pdf`.',
464            );
465        }
466        $options = (new $optionsClass())
467            ->withPageSize($this->pageSize->width(), $this->pageSize->height());
468        if ($font !== null) {
469            $options = $options->withDefaultFont($font);
470        }
471        $renderer = new $rendererClass($options);
472        $renderer->renderInto($this->writer, $html, $css);
473        // Subsequent flow-API calls (`addText` etc.) trigger a fresh page
474        // via `ensurePage`, so we don't try to share the post-HTML page
475        // with cursor-driven content (the HTML renderer manages its own
476        // pages, and the cursor model assumes a margin-based layout).
477        $this->currentPage = null;
478        $this->currentStream = null;
479        return $this;
480    }
481
482    /**
483     * Split the body region into `$count` columns separated by
484     * `$gutter` points. Flow content (text, lists, tables, callouts)
485     * fills the current column first, then advances to the next
486     * column when overflow occurs; a page break only happens after
487     * the last column on a page overflows.
488     *
489     * Set `$count = 1` to return to single-column flow. Calling this
490     * mid-document is allowed but only affects content added *after*
491     * the call; already-rendered content stays where it was placed.
492     */
493    public function setColumns(int $count, float $gutter = 12.0): self
494    {
495        if ($count < 1) {
496            throw new \InvalidArgumentException("Column count must be >= 1, got {$count}.");
497        }
498        if ($gutter < 0) {
499            throw new \InvalidArgumentException("Column gutter must be >= 0, got {$gutter}.");
500        }
501        $this->columnCount = $count;
502        $this->columnGutter = $gutter;
503        $this->currentColumnIndex = 0;
504        return $this;
505    }
506
507    // -----------------------------------------------------------------------
508    // Content
509    // -----------------------------------------------------------------------
510
511    /**
512     * Add a paragraph of body text. Text is word-wrapped at the current
513     * content column width and flows downward from the cursor. If a
514     * paragraph runs past the bottom margin, the remaining lines
515     * continue on a new automatically-created page.
516     */
517    public function addText(string $text, ?TextStyle $style = null): self
518    {
519        $this->ensurePage();
520
521        $style ??= new TextStyle();
522        $family = $style->family ?? $this->font;
523        $size   = $style->size   ?? $this->fontSize;
524        $bold   = $style->bold   ?? $this->bold;
525        $italic = $style->italic ?? $this->italic;
526        $color  = $style->color  ?? $this->theme->color;
527        $align  = $style->alignment ?? Alignment::Left;
528        $link   = $style->link;
529        $underline = $style->underline;
530        $strikethrough = $style->strikethrough;
531
532        $postScriptName = $this->resolveFontName($family, $bold, $italic);
533        $metrics = $this->getMetrics($postScriptName);
534        $fontHandle = $this->ensureFontResource($postScriptName);
535
536        $lineHeight = $size * $this->theme->lineHeight;
537        $columnWidth = $this->contentWidth();
538
539        // Encode UTF-8 to the font's byte encoding up front so wrapText /
540        // measureText (both of which index by byte into a WinAnsi width
541        // table) operate on the correct bytes. With pre-encoded text we
542        // hand showText the string-form setFont so it doesn't double-encode.
543        $encoded = $fontHandle->getTextEncoder()?->encode($text) ?? $text;
544        $lines = $this->wrapText($encoded, $metrics, $size, $columnWidth);
545
546        $this->applyFillColor($color);
547
548        foreach ($lines as $line) {
549            // Need room for one more line? If not, advance â€” to the next
550            // column when one is available, otherwise to a new page.
551            if ($this->advanceOnOverflow($lineHeight)) {
552                $this->applyFillColor($color);
553            }
554
555            // Empty lines (explicit paragraph breaks within the input
556            // string) still consume one line of vertical space.
557            if ($line === '') {
558                $this->cursorY -= $lineHeight;
559                continue;
560            }
561
562            $lineWidth = $this->measureText($line, $metrics, $size);
563            $x = $this->columnLeftX() + match ($align) {
564                Alignment::Left   => 0.0,
565                Alignment::Center => ($columnWidth - $lineWidth) / 2.0,
566                Alignment::Right  => $columnWidth - $lineWidth,
567            };
568            // PDF text origin is at the baseline, so we drop an additional
569            // font size to land the top of the glyph at the cursor.
570            $baselineY = $this->cursorY - $size;
571
572            $this->currentStream
573                ->beginText()
574                ->setFont($fontHandle->getResourceName(), $size)
575                ->moveTextPosition($x, $baselineY)
576                ->showText($line)
577                ->endText();
578
579            // Underline / strikethrough decoration lines, drawn in the
580            // same fill color as the text. Conventions: underline sits
581            // ~12% of the font size below the baseline; strikethrough
582            // sits ~28% above (through x-height).
583            if ($underline || $strikethrough) {
584                $strokeW = max(0.5, $size * 0.05);
585                $this->currentStream
586                    ->saveGraphicsState()
587                    ->setStrokeColorRGB($color[0], $color[1], $color[2])
588                    ->setLineWidth($strokeW);
589                if ($underline) {
590                    $uy = $baselineY - $size * 0.12;
591                    $this->currentStream
592                        ->moveTo($x, $uy)
593                        ->lineTo($x + $lineWidth, $uy)
594                        ->stroke();
595                }
596                if ($strikethrough) {
597                    $sy = $baselineY + $size * 0.28;
598                    $this->currentStream
599                        ->moveTo($x, $sy)
600                        ->lineTo($x + $lineWidth, $sy)
601                        ->stroke();
602                }
603                $this->currentStream->restoreGraphicsState();
604                $this->lastFillColor = null;
605            }
606
607            // For a linked paragraph, register one link annotation per
608            // rendered line on the current page. The clickable area
609            // hugs the text (slightly taller than the font to give some
610            // forgiveness around descenders).
611            if ($link !== null) {
612                $rect = new \Phpdftk\Geometry\Rectangle(
613                    $x,
614                    $baselineY - $size * 0.2,
615                    $lineWidth,
616                    $size * 1.2,
617                );
618                $this->doc->addLink($this->currentPage, $rect, $link);
619            }
620
621            $this->cursorY -= $lineHeight;
622        }
623
624        $this->cursorY -= $this->theme->paragraphSpacing;
625        return $this;
626    }
627
628    /**
629     * Add a heading (H1–H6) using the theme's heading style for the
630     * given level.
631     */
632    public function addHeading(string $text, int $level = 1): self
633    {
634        $style = $this->theme->heading($level);
635
636        $this->addSpacer($style['spaceAbove']);
637        // Capture the destination Y *before* the heading text is drawn:
638        // viewers scroll to land this y near the top of the viewport.
639        $this->recordOutlineEntry($text, $level, $this->cursorY);
640        $this->addText(
641            $text,
642            new TextStyle(
643                size: $style['size'],
644                bold: $style['bold'],
645            ),
646        );
647        // addText already left us one paragraphSpacing below; replace
648        // it with the heading's own spaceBelow.
649        $this->cursorY += $this->theme->paragraphSpacing;
650        $this->cursorY -= $style['spaceBelow'];
651        return $this;
652    }
653
654    /**
655     * Add vertical whitespace (points).
656     */
657    public function addSpacer(float $points): self
658    {
659        $this->ensurePage();
660        $this->cursorY -= $points;
661        if ($this->cursorY < $this->bottomMargin()) {
662            // Spacer that overflows behaves like a hard advance to the
663            // next column / page.
664            $this->advanceOnOverflow(0.0);
665        }
666        return $this;
667    }
668
669    /**
670     * Add a horizontal rule spanning the current content column.
671     */
672    public function addRule(float $lineWidth = 0.5): self
673    {
674        $this->ensurePage();
675        $y = $this->cursorY - $lineWidth;
676        if ($y < $this->bottomMargin()) {
677            $this->advanceOnOverflow($lineWidth);
678            $y = $this->cursorY - $lineWidth;
679        }
680
681        $this->currentStream
682            ->saveGraphicsState()
683            ->setLineWidth($lineWidth)
684            ->setStrokeColorRGB(0, 0, 0)
685            ->moveTo($this->columnLeftX(), $y)
686            ->lineTo($this->columnLeftX() + $this->contentWidth(), $y)
687            ->stroke()
688            ->restoreGraphicsState();
689
690        $this->cursorY -= $lineWidth * 2 + $this->theme->paragraphSpacing;
691        return $this;
692    }
693
694    /**
695     * Add a callout block at the current cursor â€” a coloured panel
696     * with a left bar, optional title row, and wrapped body text. The
697     * built-in {@see CalloutType} cases (`Note`, `Tip`, `Warning`,
698     * `Danger`) carry default bar / background colours; override any of
699     * them via {@see CalloutStyle}.
700     *
701     * In v1, callouts render on a single page â€” they auto-advance to
702     * a new page if the current one can't fit them, but they don't
703     * split mid-content. Callouts taller than a single page throw.
704     */
705    public function addCallout(
706        string $text,
707        CalloutType $type = CalloutType::Note,
708        ?CalloutStyle $style = null,
709    ): self {
710        $this->ensurePage();
711        $style ??= new CalloutStyle();
712
713        $bodyPSN = $this->resolveFontName($this->font, $this->bold, $this->italic);
714        $bodyFont = $this->ensureFontResource($bodyPSN);
715        $bodyMetrics = $this->getMetrics($bodyPSN);
716
717        $titlePSN = $this->resolveFontName($this->font, bold: true, italic: false);
718        $titleFont = $this->ensureFontResource($titlePSN);
719
720        $size = $this->fontSize;
721        $lineHeight = $size * $this->theme->lineHeight;
722        $padding = $style->padding;
723        $barWidth = $style->barWidth;
724
725        $textX = $this->columnLeftX() + $barWidth + $padding;
726        $textWidth = max(0.0, $this->contentWidth() - $barWidth - 2.0 * $padding);
727
728        $encoded = $bodyFont->getTextEncoder()?->encode($text) ?? $text;
729        $bodyLines = $this->wrapText($encoded, $bodyMetrics, $size, $textWidth);
730        $bodyHeight = count($bodyLines) * $lineHeight;
731
732        $titleHeight = 0.0;
733        $titleLabel = null;
734        if ($style->showLabel) {
735            $titleLabel = $style->resolveLabel($type);
736            $titleHeight = $lineHeight;
737        }
738
739        $totalHeight = 2.0 * $padding + $titleHeight + $bodyHeight;
740        $availableHeight = $this->pageSize->height() - 2.0 * $this->theme->margin
741            - $this->theme->headerHeight - $this->theme->footerHeight;
742        if ($totalHeight > $availableHeight) {
743            throw new \RuntimeException(
744                'Callout content is too tall to fit on a single page '
745                . "({$totalHeight} > {$availableHeight}); v1 does not split callouts across pages.",
746            );
747        }
748
749        $this->advanceOnOverflow($totalHeight);
750
751        $topY = $this->cursorY;
752        $bottomY = $topY - $totalHeight;
753        $totalWidth = $this->contentWidth();
754        $left = $this->columnLeftX();
755
756        [$br, $bg, $bb] = $style->resolveBarColor($type);
757        [$bgR, $bgG, $bgB] = $style->resolveBgColor($type);
758        $textColor = $style->textColor ?? $this->theme->color;
759
760        $cs = $this->currentStream;
761        $cs->saveGraphicsState();
762
763        // Background tint covering the whole callout rectangle.
764        $cs->setFillColorRGB($bgR, $bgG, $bgB)
765            ->rectangle($left, $bottomY, $totalWidth, $totalHeight)
766            ->fill();
767
768        // Solid left-edge bar in the type's accent colour.
769        $cs->setFillColorRGB($br, $bg, $bb)
770            ->rectangle($left, $bottomY, $barWidth, $totalHeight)
771            ->fill();
772
773        // Body / title text colour.
774        $cs->setFillColorRGB($textColor[0], $textColor[1], $textColor[2]);
775        $y = $topY - $padding;
776
777        if ($titleLabel !== null) {
778            $encodedTitle = $titleFont->getTextEncoder()?->encode($titleLabel) ?? $titleLabel;
779            $titleBaseline = $y - $size;
780            $cs->beginText()
781                ->setFont($titleFont->getResourceName(), $size)
782                ->moveTextPosition($textX, $titleBaseline)
783                ->showText($encodedTitle)
784                ->endText();
785            $y -= $lineHeight;
786        }
787
788        foreach ($bodyLines as $line) {
789            if ($line === '') {
790                $y -= $lineHeight;
791                continue;
792            }
793            $baseline = $y - $size;
794            $cs->beginText()
795                ->setFont($bodyFont->getResourceName(), $size)
796                ->moveTextPosition($textX, $baseline)
797                ->showText($line)
798                ->endText();
799            $y -= $lineHeight;
800        }
801
802        $cs->restoreGraphicsState();
803
804        $this->cursorY = $bottomY - $this->theme->paragraphSpacing;
805        $this->lastFillColor = null;
806        return $this;
807    }
808
809    /**
810     * Add a blockquote at the current cursor: indented text in italic
811     * with a coloured vertical bar down the left side. The body
812     * paginates like `addText`; the bar is drawn once per page the
813     * quote occupies.
814     *
815     * Override font / colour / alignment via `TextStyle`. If the style
816     * doesn't specify italic, italic is applied by default â€” that's
817     * the visual signature of a blockquote.
818     */
819    public function addQuote(string $text, ?TextStyle $style = null): self
820    {
821        $this->ensurePage();
822        $style ??= new TextStyle();
823
824        $family = $style->family ?? $this->font;
825        $size   = $style->size   ?? $this->fontSize;
826        $bold   = $style->bold   ?? $this->bold;
827        $italic = $style->italic ?? true;
828        $color  = $style->color  ?? $this->theme->color;
829        $align  = $style->alignment ?? Alignment::Left;
830
831        $postScriptName = $this->resolveFontName($family, $bold, $italic);
832        $metrics = $this->getMetrics($postScriptName);
833        $fontHandle = $this->ensureFontResource($postScriptName);
834
835        $lineHeight = $size * $this->theme->lineHeight;
836        $indent = $this->theme->quoteIndent;
837        $textWidth = max(0.0, $this->contentWidth() - $indent);
838
839        $encoded = $fontHandle->getTextEncoder()?->encode($text) ?? $text;
840        $lines = $this->wrapText($encoded, $metrics, $size, $textWidth);
841
842        $this->applyFillColor($color);
843
844        // Track per-segment bar runs. A segment ends when the cursor
845        // transitions to another column or another page mid-quote.
846        $segments = [];
847        $segmentStartY = $this->cursorY;
848        $segmentPage = $this->currentPage;
849        $segmentLeft = $this->columnLeftX();
850
851        foreach ($lines as $line) {
852            if ($this->cursorY - $lineHeight < $this->bottomMargin()) {
853                $segments[] = [$segmentPage, $segmentLeft, $segmentStartY, $this->cursorY];
854                $this->advanceOnOverflow($lineHeight);
855                $this->applyFillColor($color);
856                $segmentStartY = $this->cursorY;
857                $segmentPage = $this->currentPage;
858                $segmentLeft = $this->columnLeftX();
859            }
860
861            if ($line === '') {
862                $this->cursorY -= $lineHeight;
863                continue;
864            }
865
866            $textX = $this->columnLeftX() + $indent;
867            $lineWidth = $this->measureText($line, $metrics, $size);
868            $lineX = $textX + match ($align) {
869                Alignment::Left   => 0.0,
870                Alignment::Center => ($textWidth - $lineWidth) / 2.0,
871                Alignment::Right  => $textWidth - $lineWidth,
872            };
873            $baselineY = $this->cursorY - $size;
874
875            $this->currentStream
876                ->beginText()
877                ->setFont($fontHandle->getResourceName(), $size)
878                ->moveTextPosition($lineX, $baselineY)
879                ->showText($line)
880                ->endText();
881
882            $this->cursorY -= $lineHeight;
883        }
884        $segments[] = [$segmentPage, $segmentLeft, $segmentStartY, $this->cursorY];
885
886        [$br, $bg, $bb] = $this->theme->quoteBarColor;
887        foreach ($segments as [$page, $left, $top, $bottom]) {
888            $barX = $left + $this->theme->quoteBarWidth / 2.0;
889            $cs = $page->contentStream();
890            $cs->saveGraphicsState()
891                ->setStrokeColorRGB($br, $bg, $bb)
892                ->setLineWidth($this->theme->quoteBarWidth)
893                ->moveTo($barX, $top - 2.0)
894                ->lineTo($barX, $bottom + 2.0)
895                ->stroke()
896                ->restoreGraphicsState();
897        }
898
899        $this->cursorY -= $this->theme->paragraphSpacing;
900        $this->lastFillColor = null;
901        return $this;
902    }
903
904    /**
905     * Add a bullet list at the current cursor. Items are plain strings
906     * or nested {@see ListBlock}s; nested blocks indent one level deeper.
907     *
908     * Long items wrap at the available column width; lists auto-paginate
909     * item-by-item.
910     *
911     * @param list<string|ListBlock> $items
912     */
913    public function addList(array $items, ?ListStyle $style = null): self
914    {
915        return $this->addListInternal(new ListBlock($items, numbered: false), $style);
916    }
917
918    /**
919     * Add a numbered list (`1. â€¦ 2. â€¦`). Numbering restarts at each
920     * nested level.
921     *
922     * @param list<string|ListBlock> $items
923     */
924    public function addNumberedList(array $items, ?ListStyle $style = null): self
925    {
926        return $this->addListInternal(new ListBlock($items, numbered: true), $style);
927    }
928
929    private function addListInternal(ListBlock $block, ?ListStyle $style): self
930    {
931        if ($block->items === []) {
932            return $this;
933        }
934        $this->ensurePage();
935        $style ??= new ListStyle();
936
937        $postScriptName = $this->resolveFontName($this->font, $this->bold, $this->italic);
938        $font = $this->ensureFontResource($postScriptName);
939        $metrics = $this->getMetrics($postScriptName);
940        $renderer = new ListRenderer();
941        $maxWidth = $this->contentWidth();
942
943        $itemNumber = 1;
944        foreach ($block->items as $item) {
945            $h = $renderer->measureItem(
946                $item,
947                $maxWidth,
948                $font,
949                $metrics,
950                $this->fontSize,
951                $this->theme->lineHeight,
952                $style,
953            );
954            $this->advanceOnOverflow($h);
955            $consumed = $renderer->drawItem(
956                $this->currentStream,
957                $this->columnLeftX(),
958                $this->cursorY,
959                $item,
960                $maxWidth,
961                $font,
962                $metrics,
963                $this->fontSize,
964                $this->theme->lineHeight,
965                $style,
966                $block->numbered ? $itemNumber : null,
967            );
968            $this->cursorY -= $consumed;
969            $itemNumber++;
970        }
971
972        $this->cursorY -= $this->theme->paragraphSpacing;
973        $this->lastFillColor = null;
974        return $this;
975    }
976
977    /**
978     * Add a tabular block of content. The table is rendered at the
979     * current cursor, with rows auto-paginating across page breaks.
980     * When `$headerRow` is provided, it repeats at the top of every
981     * page the table occupies.
982     *
983     * `$columnWidths` is a list of absolute point widths; pass `null`
984     * to split the content column evenly across the inferred column
985     * count. The widths must sum to at most the content column width.
986     *
987     * @param list<list<string>>   $rows
988     * @param list<float>|null     $columnWidths
989     * @param list<string>|null    $headerRow
990     */
991    public function addTable(
992        array $rows,
993        ?array $columnWidths = null,
994        ?array $headerRow = null,
995        ?TableStyle $style = null,
996    ): self {
997        $this->ensurePage();
998        $style ??= new TableStyle();
999
1000        $colCount = count($columnWidths ?? $headerRow ?? $rows[0] ?? []);
1001        if ($colCount === 0) {
1002            return $this; // empty table â€” no-op
1003        }
1004        $columnWidths ??= $this->equalColumns($colCount);
1005
1006        $ctx = $this->tableContext($style);
1007        $renderer = new TableRenderer();
1008
1009        $drawHeader = function () use ($renderer, $headerRow, $columnWidths, $ctx): void {
1010            if ($headerRow === null) {
1011                return;
1012            }
1013            $hh = $renderer->rowHeight($headerRow, $columnWidths, $ctx, isHeader: true);
1014            $this->advanceOnOverflow($hh);
1015            $renderer->drawRow(
1016                $this->currentStream,
1017                $this->columnLeftX(),
1018                $this->cursorY,
1019                $headerRow,
1020                $columnWidths,
1021                $ctx,
1022                isHeader: true,
1023            );
1024            $this->cursorY -= $hh;
1025        };
1026
1027        $drawHeader();
1028
1029        foreach ($rows as $row) {
1030            $h = $renderer->rowHeight($row, $columnWidths, $ctx, isHeader: false);
1031            if ($this->advanceOnOverflow($h)) {
1032                $drawHeader();
1033            }
1034            $renderer->drawRow(
1035                $this->currentStream,
1036                $this->columnLeftX(),
1037                $this->cursorY,
1038                $row,
1039                $columnWidths,
1040                $ctx,
1041                isHeader: false,
1042            );
1043            $this->cursorY -= $h;
1044        }
1045
1046        $this->cursorY -= $this->theme->paragraphSpacing;
1047        $this->lastFillColor = null; // table reset graphics state
1048        return $this;
1049    }
1050
1051    /**
1052     * Render a barcode in the flow at the current cursor. Width comes
1053     * from the rendered bitmap (modules Ã— moduleWidth + quiet zones);
1054     * `align` controls horizontal placement within the column.
1055     *
1056     * For multi-document reuse, prefer
1057     * {@see PdfDoc::createBarcode()} + `Writer\Page::drawTemplate()`.
1058     */
1059    public function addBarcode(
1060        \Phpdftk\Barcode\Symbology $symbology,
1061        string $data,
1062        ?\Phpdftk\Barcode\BarcodeOptions $options = null,
1063        Alignment $align = Alignment::Left,
1064    ): self {
1065        $this->ensurePage();
1066        $options ??= new \Phpdftk\Barcode\BarcodeOptions();
1067        $bitmap = \Phpdftk\Barcode\BarcodeRenderer::render($symbology, $data, $options);
1068
1069        $w = $bitmap->totalWidth();
1070        $h = $bitmap->totalHeight();
1071
1072        $this->advanceOnOverflow($h);
1073        $columnWidth = $this->contentWidth();
1074        $x = $this->columnLeftX() + match ($align) {
1075            Alignment::Left   => 0.0,
1076            Alignment::Center => ($columnWidth - $w) / 2.0,
1077            Alignment::Right  => $columnWidth - $w,
1078        };
1079        $y = $this->cursorY - $h;
1080
1081        $cs = $this->currentStream;
1082        $cs->saveGraphicsState();
1083        $cs->concatMatrix(1.0, 0.0, 0.0, 1.0, $x, $y);
1084        BarcodeRendering::renderInto($cs, $bitmap);
1085        $cs->restoreGraphicsState();
1086
1087        $this->cursorY -= $h + $this->theme->paragraphSpacing;
1088        $this->lastFillColor = null;
1089        return $this;
1090    }
1091
1092    /**
1093     * Add an image. If neither width nor height is given, the image is
1094     * placed at its natural size in points (1 image pixel = 1 point).
1095     * If one dimension is given the other is scaled proportionally. If
1096     * both are given the image is stretched to fit.
1097     */
1098    public function addImage(
1099        string $path,
1100        ?float $width = null,
1101        ?float $height = null,
1102        Alignment $align = Alignment::Left,
1103    ): self {
1104        $this->ensurePage();
1105
1106        $info = ImageParser::parse($path);
1107        $naturalW = (float) $info->width;
1108        $naturalH = (float) $info->height;
1109
1110        if ($width === null && $height === null) {
1111            $w = $naturalW;
1112            $h = $naturalH;
1113        } elseif ($width !== null && $height === null) {
1114            $w = $width;
1115            $h = $naturalH * ($width / $naturalW);
1116        } elseif ($width === null && $height !== null) {
1117            $h = $height;
1118            $w = $naturalW * ($height / $naturalH);
1119        } else {
1120            $w = (float) $width;
1121            $h = (float) $height;
1122        }
1123
1124        // Advance if the image won't fit in the remaining column / page.
1125        $this->advanceOnOverflow($h);
1126
1127        $columnWidth = $this->contentWidth();
1128        $x = $this->columnLeftX() + match ($align) {
1129            Alignment::Left   => 0.0,
1130            Alignment::Center => ($columnWidth - $w) / 2.0,
1131            Alignment::Right  => $columnWidth - $w,
1132        };
1133        $y = $this->cursorY - $h;
1134
1135        $this->currentPage->drawImage($path, $x, $y, $w, $h);
1136
1137        $this->cursorY -= $h + $this->theme->paragraphSpacing;
1138        return $this;
1139    }
1140
1141    // -----------------------------------------------------------------------
1142    // Output
1143    // -----------------------------------------------------------------------
1144
1145    public function save(string $path): void
1146    {
1147        $this->applyDecorators();
1148        $this->writer->save($path);
1149    }
1150
1151    public function toBytes(): string
1152    {
1153        $this->applyDecorators();
1154        return $this->writer->toBytes();
1155    }
1156
1157    /** @param resource $stream */
1158    public function writeTo($stream): int
1159    {
1160        $this->applyDecorators();
1161        return $this->writer->writeTo($stream);
1162    }
1163
1164    // -----------------------------------------------------------------------
1165    // Internal
1166    // -----------------------------------------------------------------------
1167
1168    private function ensurePage(): void
1169    {
1170        if ($this->currentPage === null) {
1171            $this->addPage();
1172        }
1173    }
1174
1175    /**
1176     * Register an OutlineItem for the current heading and wire it into
1177     * the hierarchy. Called from {@see addHeading()} when auto-outline
1178     * is enabled.
1179     */
1180    private function recordOutlineEntry(string $title, int $level, float $destY): void
1181    {
1182        if (!$this->outlineEnabled || $this->outlineRoot === null) {
1183            return;
1184        }
1185        $this->ensurePage();
1186
1187        $item = new \Phpdftk\Pdf\Core\Document\OutlineItem($title);
1188        $pageRef = new \Phpdftk\Pdf\Core\PdfReference($this->currentPage->corePage()->objectNumber);
1189        $item->dest = new \Phpdftk\Pdf\Core\PdfArray([
1190            $pageRef,
1191            new \Phpdftk\Pdf\Core\PdfName('XYZ'),
1192            new \Phpdftk\Pdf\Core\PdfNumber(0),
1193            new \Phpdftk\Pdf\Core\PdfNumber($destY),
1194            new \Phpdftk\Pdf\Core\PdfNumber(0),
1195        ]);
1196        $ref = $this->doc->addOutlineItem($item);
1197
1198        // Locate parent: most recent item at a shallower level.
1199        $parent = null;
1200        for ($l = $level - 1; $l >= 1; $l--) {
1201            if (isset($this->outlineLastAtLevel[$l])) {
1202                $parent = $this->outlineLastAtLevel[$l];
1203                break;
1204            }
1205        }
1206        $prevSibling = $this->outlineLastAtLevel[$level] ?? null;
1207
1208        $parentRef = $parent !== null
1209            ? new \Phpdftk\Pdf\Core\PdfReference($parent->objectNumber)
1210            : new \Phpdftk\Pdf\Core\PdfReference($this->outlineRoot->objectNumber);
1211        $item->parent = $parentRef;
1212
1213        if ($prevSibling !== null) {
1214            $prevSibling->next = $ref;
1215            $item->prev = new \Phpdftk\Pdf\Core\PdfReference($prevSibling->objectNumber);
1216        } else {
1217            // First child of its parent.
1218            if ($parent !== null) {
1219                $parent->first = $ref;
1220            } else {
1221                $this->outlineRoot->first = $ref;
1222            }
1223        }
1224
1225        // The parent's last child is always the just-registered item.
1226        if ($parent !== null) {
1227            $parent->last = $ref;
1228        } else {
1229            $this->outlineRoot->last = $ref;
1230        }
1231
1232        $this->outlineCount++;
1233        $this->outlineRoot->count = $this->outlineCount;
1234
1235        // The new item becomes the latest at its level and breaks any
1236        // deeper-level sibling chains (they restart under the new item).
1237        $this->outlineLastAtLevel[$level] = $item;
1238        foreach (array_keys($this->outlineLastAtLevel) as $existing) {
1239            if ($existing > $level) {
1240                unset($this->outlineLastAtLevel[$existing]);
1241            }
1242        }
1243    }
1244
1245    /**
1246     * Run the per-page render hooks once, after all flow content has
1247     * been placed but before bytes are emitted. Total-page count is
1248     * resolvable here, which is why it's deferred.
1249     */
1250    private function applyDecorators(): void
1251    {
1252        if ($this->decoratorsApplied || $this->decorator->isEmpty()) {
1253            $this->decoratorsApplied = true;
1254            return;
1255        }
1256        $this->decoratorsApplied = true;
1257
1258        $total = count($this->pages);
1259        foreach ($this->pages as $i => [$page, $width, $height]) {
1260            $ctx = new PageContext(
1261                pageNumber: $i + 1,
1262                totalPages: $total,
1263                page: $page,
1264                pageWidth: $width,
1265                pageHeight: $height,
1266                theme: $this->theme,
1267            );
1268
1269            if ($this->decorator->watermark !== null) {
1270                ($this->decorator->watermark)($ctx);
1271            }
1272            if ($this->decorator->header !== null) {
1273                ($this->decorator->header)($ctx);
1274            }
1275            if ($this->decorator->footer !== null) {
1276                ($this->decorator->footer)($ctx);
1277            }
1278        }
1279    }
1280
1281    /**
1282     * Built-in watermark renderer used when {@see setWatermark()}
1283     * receives a string. Renders large grey diagonal text centered on
1284     * the page; the `$opacity` parameter is approximated by lightening
1285     * the fill color since opacity proper requires an ExtGState (Phase
1286     * 4.4's territory).
1287     */
1288    private function drawDefaultWatermark(
1289        PageContext $ctx,
1290        string $text,
1291        float $opacity,
1292        float $angleDeg,
1293    ): void {
1294        $postScriptName = 'Helvetica-Bold';
1295        $fontHandle = $this->ensureFontResource($postScriptName);
1296        $metrics = $this->getMetrics($postScriptName);
1297        $encoded = $fontHandle->getTextEncoder()?->encode($text) ?? $text;
1298
1299        $fontSize = 72.0;
1300        $textWidth = $this->measureText($encoded, $metrics, $fontSize);
1301
1302        $cx = $ctx->pageWidth / 2.0;
1303        $cy = $ctx->pageHeight / 2.0;
1304        $angleRad = $angleDeg * M_PI / 180.0;
1305        $cos = cos($angleRad);
1306        $sin = sin($angleRad);
1307
1308        $gray = max(0.0, min(1.0, 1.0 - $opacity));
1309
1310        $ctx->page->contentStream()
1311            ->saveGraphicsState()
1312            ->setFillColorRGB($gray, $gray, $gray)
1313            ->concatMatrix($cos, $sin, -$sin, $cos, $cx, $cy)
1314            ->beginText()
1315            ->setFont($fontHandle->getResourceName(), $fontSize)
1316            ->moveTextPosition(-$textWidth / 2.0, -$fontSize / 3.0)
1317            ->showText($encoded)
1318            ->endText()
1319            ->restoreGraphicsState();
1320    }
1321
1322    /**
1323     * Width of one column of body content. When `columnCount = 1`
1324     * this is the full content area between left + right margins;
1325     * with multiple columns each column gets an equal share of the
1326     * remaining width after gutters.
1327     */
1328    private function contentWidth(): float
1329    {
1330        $full = $this->totalContentWidth();
1331        if ($this->columnCount <= 1) {
1332            return $full;
1333        }
1334        $gutters = $this->columnGutter * ($this->columnCount - 1);
1335        return max(0.0, ($full - $gutters) / $this->columnCount);
1336    }
1337
1338    /** Full body width spanning all columns (e.g. for full-bleed headers). */
1339    private function totalContentWidth(): float
1340    {
1341        return $this->pageSize->width() - (2 * $this->theme->margin);
1342    }
1343
1344    /**
1345     * Left X coordinate of the current column. With a single column
1346     * this is just the page's left margin.
1347     */
1348    private function columnLeftX(): float
1349    {
1350        return $this->theme->margin
1351            + $this->currentColumnIndex * ($this->contentWidth() + $this->columnGutter);
1352    }
1353
1354    /**
1355     * Y coordinate the cursor returns to at the top of any column
1356     * (same for every column on a page â€” just below the header reserve).
1357     */
1358    private function topOfColumn(): float
1359    {
1360        return $this->pageSize->height() - $this->theme->margin - $this->theme->headerHeight;
1361    }
1362
1363    /**
1364     * Ensure there's vertical room for `$h` points of body content.
1365     * Advance to the next column if one's available; otherwise start
1366     * a new page. Returns true if a transition happened.
1367     */
1368    private function advanceOnOverflow(float $h): bool
1369    {
1370        if ($this->cursorY - $h >= $this->bottomMargin()) {
1371            return false;
1372        }
1373        if ($this->columnCount > 1 && $this->currentColumnIndex < $this->columnCount - 1) {
1374            $this->currentColumnIndex++;
1375            $this->cursorY = $this->topOfColumn();
1376            $this->lastFillColor = null;
1377            return true;
1378        }
1379        $this->newPage();
1380        return true;
1381    }
1382
1383    /**
1384     * Split the content column equally across `$n` columns.
1385     *
1386     * @return list<float>
1387     */
1388    private function equalColumns(int $n): array
1389    {
1390        if ($n <= 0) {
1391            return [];
1392        }
1393        $w = $this->contentWidth() / $n;
1394        return array_fill(0, $n, $w);
1395    }
1396
1397    /**
1398     * Build a {@see TableRenderContext} from the current theme + style.
1399     * Bold variant for the header row when `style->headerBold` is true.
1400     */
1401    private function tableContext(TableStyle $style): TableRenderContext
1402    {
1403        $bodyName = $this->resolveFontName($this->font, $this->bold, $this->italic);
1404        $headerName = $style->headerBold
1405            ? $this->resolveFontName($this->font, bold: true, italic: $this->italic)
1406            : $bodyName;
1407
1408        return new TableRenderContext(
1409            bodyFont: $this->ensureFontResource($bodyName),
1410            bodyMetrics: $this->getMetrics($bodyName),
1411            headerFont: $this->ensureFontResource($headerName),
1412            headerMetrics: $this->getMetrics($headerName),
1413            fontSize: $this->fontSize,
1414            lineHeight: $this->theme->lineHeight,
1415            style: $style,
1416        );
1417    }
1418
1419    /**
1420     * Y-coordinate of the lowest point body content may occupy before
1421     * a page break is required. This is the page margin plus any
1422     * reserved footer area.
1423     */
1424    private function bottomMargin(): float
1425    {
1426        return $this->theme->margin + $this->theme->footerHeight;
1427    }
1428
1429    /**
1430     * Resolve a (family, bold, italic) tuple to a standard-14 PostScript
1431     * name. Falls back to the regular variant if the bold/italic
1432     * combination is not a standard font.
1433     */
1434    private function resolveFontName(string $family, bool $bold, bool $italic): string
1435    {
1436        $map = [
1437            'Helvetica' => [
1438                '00' => 'Helvetica',
1439                '10' => 'Helvetica-Bold',
1440                '01' => 'Helvetica-Oblique',
1441                '11' => 'Helvetica-BoldOblique',
1442            ],
1443            'Times' => [
1444                '00' => 'Times-Roman',
1445                '10' => 'Times-Bold',
1446                '01' => 'Times-Italic',
1447                '11' => 'Times-BoldItalic',
1448            ],
1449            'Courier' => [
1450                '00' => 'Courier',
1451                '10' => 'Courier-Bold',
1452                '01' => 'Courier-Oblique',
1453                '11' => 'Courier-BoldOblique',
1454            ],
1455            'Symbol' => [
1456                '00' => 'Symbol', '10' => 'Symbol', '01' => 'Symbol', '11' => 'Symbol',
1457            ],
1458            'ZapfDingbats' => [
1459                '00' => 'ZapfDingbats', '10' => 'ZapfDingbats',
1460                '01' => 'ZapfDingbats', '11' => 'ZapfDingbats',
1461            ],
1462        ];
1463        if (!isset($map[$family])) {
1464            throw new \InvalidArgumentException(
1465                "Unknown standard font family: $family (expected Helvetica, Times, Courier, Symbol, or ZapfDingbats)",
1466            );
1467        }
1468        $key = ($bold ? '1' : '0') . ($italic ? '1' : '0');
1469        return $map[$family][$key];
1470    }
1471
1472    private function getMetrics(string $postScriptName): AfmData
1473    {
1474        return $this->fontMetricsCache[$postScriptName]
1475            ??= StandardFontMetrics::get($postScriptName);
1476    }
1477
1478    /**
1479     * Ensure the given standard font is registered in the underlying
1480     * writer and return the font handle. The handle exposes both the
1481     * resource name (for the Tf operator) and the text encoder (so
1482     * showText can take UTF-8 directly).
1483     */
1484    private function ensureFontResource(string $postScriptName): Font
1485    {
1486        if (isset($this->fontResourceCache[$postScriptName])) {
1487            return $this->fontResourceCache[$postScriptName];
1488        }
1489        $standardCase = StandardFont::from($postScriptName);
1490        $fontHandle = $this->writer->addFont(new Type1Font($standardCase));
1491        $this->fontResourceCache[$postScriptName] = $fontHandle;
1492        return $fontHandle;
1493    }
1494
1495    /**
1496     * @return list<string>
1497     */
1498    private function wrapText(string $text, AfmData $metrics, float $size, float $columnWidth): array
1499    {
1500        return TextLayout::wrap($text, $metrics, $size, $columnWidth);
1501    }
1502
1503    private function measureText(string $text, AfmData $metrics, float $size): float
1504    {
1505        return TextLayout::measure($text, $metrics, $size);
1506    }
1507
1508    /** @param array{float,float,float} $color */
1509    private function applyFillColor(array $color): void
1510    {
1511        $key = sprintf('%.4f %.4f %.4f', $color[0], $color[1], $color[2]);
1512        if ($this->lastFillColor === $key) {
1513            return;
1514        }
1515        $this->currentStream->setFillColorRGB($color[0], $color[1], $color[2]);
1516        $this->lastFillColor = $key;
1517    }
1518}