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