<?php

/**
 * TextTest.php
 *
 * @since       2002-08-03
 * @category    Library
 * @package     Pdf
 * @author      Nicola Asuni <info@tecnick.com>
 * @copyright   2002-2026 Nicola Asuni - Tecnick.com LTD
 * @license     https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
 * @link        https://github.com/tecnickcom/tc-lib-pdf
 *
 * This file is part of tc-lib-pdf software library.
 */

namespace Test;

/**
 * @phpstan-import-type TTextDims from \Com\Tecnick\Pdf\Font\Stack
 */
class TextTest extends TestUtil
{
    public static function setUpBeforeClass(): void
    {
        if (!\defined('K_PATH_FONTS')) {
            $fonts = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts');
            \define('K_PATH_FONTS', $fonts);
        }
    }

    /** @throws \Throwable */
    protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
    {
        return new \Com\Tecnick\Pdf\Tcpdf();
    }

    /** @throws \Throwable */
    protected function getInternalTestObject(): TestableText
    {
        return new TestableText();
    }

    /** @throws \Throwable */
    private function initUnicodeFont(\Com\Tecnick\Pdf\Tcpdf $obj): void
    {
        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');
        /** @var int $pon */
        $pon = $this->getObjectProperty($obj, 'pon');
        $fontfile = (string) \realpath(__DIR__
        . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/dejavu/dejavusans.json');
        $font->insert($pon, 'dejavusans', '', 10, null, null, $fontfile);
    }

    /**
     * @param array<string, mixed> $page
     */
    private function requirePageId(array $page): int
    {
        if (!isset($page['pid']) || !\is_int($page['pid'])) {
            $this->fail('Expected addPage to return an integer pid');
        }

        return $page['pid'];
    }

    /** @throws \Throwable */
    public function testGetLastBBoxDefaultsToZeroBox(): void
    {
        $obj = $this->getTestObject();

        $this->assertSame(
            [
                'x' => 0.0,
                'y' => 0.0,
                'w' => 0.0,
                'h' => 0.0,
            ],
            $obj->getLastBBox(),
        );
    }

    /** @throws \Throwable */
    public function testGetLastTextBBoxDefaultsToZeroBox(): void
    {
        $obj = $this->getTestObject();

        $this->assertSame(
            [
                'x' => 0.0,
                'y' => 0.0,
                'w' => 0.0,
                'h' => 0.0,
            ],
            $obj->getLastTextBBox(),
        );
    }

    /** @throws \Throwable */
    public function testGetLastCellBBoxDefaultsToZeroBox(): void
    {
        $obj = $this->getTestObject();

        $this->assertSame(
            [
                'x' => 0.0,
                'y' => 0.0,
                'w' => 0.0,
                'h' => 0.0,
            ],
            $obj->getLastCellBBox(),
        );
    }

    /** @throws \Throwable */
    public function testLoadTexHyphenPatternsParsesFixture(): void
    {
        $obj = $this->getTestObject();
        $file = __DIR__ . '/fixtures/hyphen-test.tex';

        $patterns = $obj->loadTexHyphenPatterns($file);

        $this->assertArrayHasKey('hyphen', $patterns);
        $this->assertArrayHasKey('testing', $patterns);
        $this->assertArrayHasKey('abc', $patterns);
        $this->assertSame('hy4phen', $patterns['hyphen'] ?? null);
        $this->assertSame('test1ing', $patterns['testing'] ?? null);
        $this->assertSame('a1bc', $patterns['abc'] ?? null);
    }

    /** @throws \Throwable */
    public function testSetTexHyphenPatternsStoresPatterns(): void
    {
        $obj = $this->getTestObject();
        $patterns = ['hyphen' => 'hy4phen'];
        $obj->setTexHyphenPatterns($patterns);

        $this->assertSame($patterns, $this->getObjectProperty($obj, 'hyphen_patterns'));
    }

    /** @throws \Throwable */
    public function testEnableZeroWidthBreakPointsTogglesFlag(): void
    {
        $obj = $this->getTestObject();
        $obj->enableZeroWidthBreakPoints(true);
        $this->assertTrue($this->getObjectProperty($obj, 'autozerowidthbreaks'));

        $obj->enableZeroWidthBreakPoints(false);
        $this->assertFalse($this->getObjectProperty($obj, 'autozerowidthbreaks'));
    }

    /** @throws \Throwable */
    public function testAddPageReturnsPageData(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);

        $page = $obj->addPage();

        $this->assertArrayHasKey('pid', $page);
        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        $this->assertSame($page['pid'] ?? null, $pageObj->getPageId());
    }

    /** @throws \Throwable */
    public function testSetCurrentPageMovesToRequestedPageAndReturnsPageData(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);

        $first = $obj->addPage([
            'orientation' => 'P',
            'format' => 'A4',
        ]);
        $second = $obj->addPage([
            'orientation' => 'L',
            'format' => 'A4',
        ]);

        $firstPid = $this->requirePageId($first);
        $secondPid = $this->requirePageId($second);
        $this->assertNotSame($firstPid, $secondPid);

        $page = $obj->setCurrentPage($firstPid);

        $this->assertSame($firstPid, $page['pid']);
        if (!isset($first['width'], $first['height'], $page['width'], $page['height'])) {
            $this->fail('Expected page width/height to be available after setCurrentPage().');
        }

        $this->assertSame($first['width'], $page['width']);
        $this->assertSame($first['height'], $page['height']);

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        $this->assertSame($firstPid, $pageObj->getPageId());

        $current = $obj->setCurrentPage();
        $this->assertSame($firstPid, $current['pid']);
    }

    /** @throws \Throwable */
    public function testDefaultPageContentReturnsPdfCommands(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        $out = $obj->defaultPageContent($pid);

        $this->assertNotSame('', $out);
        $this->assertStringContainsString('BT', $out);
    }

    /** @throws \Throwable */
    public function testDefaultPageContentUsesFooterArtifactInPdfUa(): void
    {
        $obj = new \Com\Tecnick\Pdf\Tcpdf(mode: 'pdfua');
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        $out = $obj->defaultPageContent($pid);

        $this->assertStringContainsString('/Artifact << /Type /Pagination /Subtype /Footer >> BDC', $out);
        $this->assertStringContainsString('EMC', $out);
        $this->assertStringNotContainsString('/MCID', $out);
    }

    /**
     * Regression test:
     *
     * A custom defaultPageContent() that draws vector graphics through the graph
     * component (here a line) must render correctly on the first page. The graph
     * flips the Y axis against the page height; previously that height was still
     * zero when the first page context was generated (it was only set after
     * setPageContext()), so the first page emitted negative, off-page coordinates
     * while every subsequent page reused the prior page height and rendered fine.
     *
     * @throws \Throwable
     */
    public function testCustomDefaultPageContentDrawsGraphicsOnFirstPage(): void
    {
        $obj = new class extends \Com\Tecnick\Pdf\Tcpdf {
            public function defaultPageContent(int $pid = -1): string
            {
                if ($pid < 0) {
                    $pid = $this->page->getPageId();
                }

                // A horizontal line 18 user-units from the top of the page.
                return $this->graph->getLine(10.0, 18.0, 100.0, 18.0, ['lineColor' => 'rgb(192,192,192)']);
            }
        };

        $this->initFont($obj);
        $obj->enableDefaultPageContent();

        $firstPid = $this->requirePageId($obj->addPage());
        $secondPid = $this->requirePageId($obj->addPage());

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');

        $firstY = $this->extractFirstMoveToY($pageObj->getPage($firstPid)['content']);
        $secondY = $this->extractFirstMoveToY($pageObj->getPage($secondPid)['content']);

        // The line must be drawn inside the page (positive Y), not above the top edge.
        $this->assertGreaterThan(0.0, $firstY, 'First page header graphics must not be drawn off-page (issue #246).');

        // The first page must match every subsequent page.
        $this->assertEqualsWithDelta($secondY, $firstY, 0.0001);
    }

    /**
     * Returns the Y coordinate of the first "moveto" (m) operator in a page content stream.
     *
     * @param array<array-key, string> $content
     *
     * @throws \Throwable
     */
    private function extractFirstMoveToY(array $content): float
    {
        $match = [];
        if (\preg_match('/(?:^|\s)(\S+) (-?\d+\.\d+) m(?:\s|$)/', \implode('', $content), $match) !== 1) {
            $this->fail('Expected a moveto (m) operator in the page content stream.');
        }

        $value = $match[2] ?? '0';

        return \is_numeric($value) ? (float) $value : 0.0;
    }

    /** @throws \Throwable */
    public function testArtifactHelpersAndAddArtifactContent(): void
    {
        $obj = $this->getTestObject();
        $this->assertSame('', $obj->beginArtifact('Pagination', 'Header'));
        $this->assertSame('', $obj->endArtifact());

        $pdfua = new \Com\Tecnick\Pdf\Tcpdf(mode: 'pdfua');
        $this->initFont($pdfua);
        $page = $pdfua->addPage();

        $open = $pdfua->beginArtifact('Pagi nation', 'Head/er');
        $this->assertSame('/Artifact << /Type /Pagination /Subtype /Header >> BDC' . "\n", $open);
        $this->assertSame('EMC' . "\n", $pdfua->endArtifact());

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($pdfua, 'page');
        $pid = $this->requirePageId($page);
        $pdfua->addArtifactContent("q\nQ\n", $pid, 'Pagination', 'Header');
        /** @var array<int, string> $content */
        $content = $pageObj->getPage($pid)['content'];

        $lastIdx = \count($content) - 1;
        $this->assertGreaterThanOrEqual(0, $lastIdx);
        $lastContent = $content[$lastIdx] ?? null;
        $this->assertIsString($lastContent);
        $this->assertStringContainsString('/Artifact << /Type /Pagination /Subtype /Header >> BDC', $lastContent);
        $this->assertStringContainsString("q\nQ\n", $lastContent);
        $this->assertStringContainsString('EMC', $lastContent);
        $this->assertStringNotContainsString('/MCID', $lastContent);
    }

    /** @throws \Throwable */
    public function testDefaultPageContentPreservesCurrentUnicodeFont(): void
    {
        $obj = $this->getTestObject();
        $obj->enableDefaultPageContent();
        $this->initUnicodeFont($obj);
        $obj->addPage();

        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');

        $this->assertSame('dejavusans', $font->getCurrentFontKey());
        $this->assertTrue($font->isCurrentUnicodeFont());

        $out = $obj->getTextCell('The quick brown fox', 1, 2, 20, 6, 0, 0, 'T', 'L');

        $this->assertStringContainsString("\000T\000h\000e", $out);
    }

    /** @throws \Throwable */
    public function testGetTextLineAndGetTextCellHandleBasicInput(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $this->assertSame('', $obj->getTextLine(''));
        $this->assertSame('', $obj->getTextCell(''));

        $line = $obj->getTextLine('Hello', 1, 2);
        $cell = $obj->getTextCell('Hello', 1, 2, 20, 6, 0, 0, 'T', 'L');

        $this->assertNotSame('', $line);
        $this->assertNotSame('', $cell);
    }

    /** @throws \Throwable */
    private function textCellHeight(\Com\Tecnick\Pdf\Tcpdf $obj, string $txt): float
    {
        $obj->getTextCell($txt, 10, 20, 60, 0, 0, 0, 'T', 'L');

        return $obj->getLastTextBBox()['h'];
    }

    /**
     * Regression: a blank line inside a multi-line text cell must occupy a full
     * line of vertical space. An empty line renders no glyphs, so getLastBBox()
     * used to return the stale box of the previously rendered line and the blank
     * line collapsed (the following line jumped up to overlap it). outTextLines()
     * now synthesises a zero-width box at the blank line's own position so the
     * text bounding box grows by one line height per blank line.
     *
     * @throws \Throwable
     */
    public function testGetTextCellBlankLinesOccupyFullLineHeight(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $oneLine = $this->textCellHeight($obj, 'A');
        $twoLines = $this->textCellHeight($obj, "A\nB");
        $threeLines = $this->textCellHeight($obj, "A\nC\nB");
        $blankMiddle = $this->textCellHeight($obj, "A\n\nB");
        $twoBlanks = $this->textCellHeight($obj, "A\n\n\nB");

        // Each additional rendered line adds one constant line height.
        $lineHeight = $twoLines - $oneLine;
        $this->assertGreaterThan(0.0, $lineHeight);

        // A blank middle line counts as a full line: "A\n\nB" spans three lines,
        // exactly like "A\nC\nB". Before the fix it collapsed to two lines.
        $this->assertEqualsWithDelta($threeLines, $blankMiddle, 1.0e-6);
        $this->assertGreaterThan($twoLines, $blankMiddle);

        // Two consecutive blank lines add two full line heights.
        $this->assertEqualsWithDelta($twoLines + (2.0 * $lineHeight), $twoBlanks, 1.0e-6);
    }

    /** @throws \Throwable */
    public function testGetTextCellAcceptsNamedAndNumericBorderStyleSides(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $top = ['lineWidth' => 0.4, 'lineColor' => '#ff0000'];
        $right = ['lineWidth' => 0.5, 'lineColor' => '#00aa00'];
        $bottom = ['lineWidth' => 0.6, 'lineColor' => '#0000ff'];
        $left = ['lineWidth' => 0.7, 'lineColor' => '#222222'];

        $namedStyles = [
            'T' => $top,
            'R' => $right,
            'B' => $bottom,
            'L' => $left,
        ];

        $numericStyles = [
            0 => $top,
            1 => $right,
            2 => $bottom,
            3 => $left,
        ];

        $namedOut = $obj->getTextCell('Hello', 10, 20, 40, 12, 0, 0, 'T', 'L', null, $namedStyles);
        $numericOut = $obj->getTextCell('Hello', 10, 20, 40, 12, 0, 0, 'T', 'L', null, $numericStyles);

        $this->assertNotSame('', $namedOut);
        $this->assertSame($numericOut, $namedOut);
    }

    /** @throws \Throwable */
    public function testGetTextCellFitUnknownModeFallsBackToDisabled(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $txt = 'This is a long sentence that should overflow in a small fixed-height box.';
        $base = $obj->getTextCell(
            $txt,
            10,
            20,
            40,
            6,
            0,
            0,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            '',
        );
        $fallback = $obj->getTextCell(
            $txt,
            10,
            20,
            40,
            6,
            0,
            0,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            'x',
        );

        $this->assertSame($base, $fallback);
    }

    /** @throws \Throwable */
    public function testGetTextCellFitDisabledWhenCellSizePreconditionsFail(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $txt = 'Overflow text should keep baseline behavior when width or height is automatic.';

        $baseWidthAuto = $obj->getTextCell($txt, 10, 20, 0, 6, 0, 0, 'T', 'L');
        $fitWidthAuto = $obj->getTextCell(
            $txt,
            10,
            20,
            0,
            6,
            0,
            0,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            'F',
        );
        $this->assertSame($baseWidthAuto, $fitWidthAuto);

        $baseHeightAuto = $obj->getTextCell($txt, 10, 20, 40, 0, 0, 0, 'T', 'L');
        $fitHeightAuto = $obj->getTextCell(
            $txt,
            10,
            20,
            40,
            0,
            0,
            0,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            'T',
        );
        $this->assertSame($baseHeightAuto, $fitHeightAuto);
    }

    /** @throws \Throwable */
    public function testGetTextCellFitFontSizeUsesHeuristicSearchForHyphenatedLongWord(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();
        $obj->setTexHyphenPatterns([
            'a' => 'a1',
            'b' => 'b1',
            'c' => 'c1',
            'd' => 'd1',
            'e' => 'e1',
            'f' => 'f1',
            'g' => 'g1',
            'h' => 'h1',
            'i' => 'i1',
            'j' => 'j1',
            'k' => 'k1',
            'l' => 'l1',
            'm' => 'm1',
            'n' => 'n1',
            'o' => 'o1',
            'p' => 'p1',
            'q' => 'q1',
            'r' => 'r1',
            's' => 's1',
            't' => 't1',
            'u' => 'u1',
            'v' => 'v1',
            'w' => 'w1',
            'x' => 'x1',
            'y' => 'y1',
            'z' => 'z1',
        ]);

        $prepared = $obj->exposePrepareText(
            'Loremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtemporincididuntutlaboreetdoloremagnaaliqua.',
        );
        $ordarr = $prepared[1];
        $fit = $obj->exposeFitTextCellByFontSize($ordarr, 160.0, 5.0, 0.0, 0.0);

        $this->assertTrue($fit['fontchanged']);
        $this->assertGreaterThan(150.0, $fit['layout']['maxwidth']);
        $this->assertStringContainsString('4.000000 Tf', $fit['fontout']);
    }

    /** @throws \Throwable */
    public function testFitTextCellByFontSizeReturnsBaseWhenLayoutAlreadyFits(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        [, $ordarr] = $obj->exposePrepareText('fit');
        $fit = $obj->exposeFitTextCellByFontSize($ordarr, 200.0, 200.0, 0.0, 0.0);

        $this->assertFalse($fit['fontchanged']);
        $this->assertSame('', $fit['fontout']);
    }

    /** @throws \Throwable */
    public function testFitTextCellByFontSizeStopsWhenCurrentSizeIsMinimum(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');
        /** @var int $pon */
        $pon = $this->getObjectProperty($obj, 'pon');
        $curfont = $font->getCurrentFont();
        $font->cloneFont($pon, $curfont['idx'], null, 4.0);

        [, $ordarr] = $obj->exposePrepareText('A long word that cannot fit inside a tiny box');
        $fit = $obj->exposeFitTextCellByFontSize($ordarr, 5.0, 2.0, 0.0, 0.0);

        $this->assertFalse($fit['fontchanged']);
        $this->assertSame('', $fit['fontout']);
    }

    /** @throws \Throwable */
    public function testFitTextCellByStretchReturnsBaseForNoOverflowAndHeightOnlyOverflow(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        [, $ordarr, $dim] = $obj->exposePrepareText('short text');

        $noOverflow = $obj->exposeFitTextCellByStretch($ordarr, $dim, 300.0, 300.0, 0.0, 0.0);
        $heightOnlyOverflow = $obj->exposeFitTextCellByStretch($ordarr, $dim, 300.0, 0.1, 0.0, 0.0);

        $this->assertFalse($noOverflow['fontchanged']);
        $this->assertFalse($heightOnlyOverflow['fontchanged']);
        $this->assertSame(300.0, $noOverflow['linewidth']);
        $this->assertSame(300.0, $heightOnlyOverflow['linewidth']);
    }

    /** @throws \Throwable */
    public function testFitTextCellByFontSizeFallbackProbeBranchWithTestDouble(): void
    {
        $obj = new class extends TestableText {
            private int $probeCalls = 0;

            /** @phpstan-return array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float} */
            protected function getTextCellLayout(
                array $ordarr,
                array $dim,
                float $splitWidth,
                float $displayWidth,
                float $scale,
                float $offsetPoints,
                float $lineSpacePoints,
                bool $rtl = false,
            ): array {
                return [
                    'lines' => [[
                        'pos' => 0,
                        'chars' => 1,
                        'spaces' => 0,
                        'septype' => 'BN',
                        'totwidth' => 100.0,
                        'totspacewidth' => 0.0,
                        'words' => 1,
                    ]],
                    'maxwidth' => 100.0,
                    'txtheight' => 100.0,
                ];
            }

            /** @phpstan-return array{size: float, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array<int, array{pos: int, ord: int, septype: string, spaces: int, totwidth: float, totspacewidth: float, wordwidth: float}>}, layout: array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float}, fits: bool} */
            protected function probeTextCellFontSizeFit(
                array $ordarr,
                float $size,
                float $maxWidth,
                float $maxHeight,
                float $offsetPoints,
                float $lineSpacePoints,
                bool $rtl = false,
            ): array {
                ++$this->probeCalls;
                if ($this->probeCalls === 1) {
                    return [
                        'size' => $size,
                        'dim' => self::DIM_DEFAULT,
                        'layout' => ['lines' => [], 'maxwidth' => 100.0, 'txtheight' => 100.0],
                        'fits' => false,
                    ];
                }

                if ($this->probeCalls === 2) {
                    return [
                        'size' => $size,
                        'dim' => self::DIM_DEFAULT,
                        'layout' => ['lines' => [], 'maxwidth' => 1.0, 'txtheight' => 1.0],
                        'fits' => true,
                    ];
                }

                $fits = ($this->probeCalls % 2) === 0;
                return [
                    'size' => $size,
                    'dim' => self::DIM_DEFAULT,
                    'layout' => ['lines' => [], 'maxwidth' => $fits ? 1.0 : 100.0, 'txtheight' => $fits ? 1.0 : 100.0],
                    'fits' => $fits,
                ];
            }

            /**
             * @phpstan-param array<int, int> $ordarr
             * @phpstan-return array{fontchanged: bool, fontout: string, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array<int, array{pos: int, ord: int, septype: string, spaces: int, totwidth: float, totspacewidth: float, wordwidth: float}>}, layout: array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float}}
             * @throws \Com\Tecnick\Pdf\Font\Exception
             */
            public function runFit(array $ordarr): array
            {
                return $this->fitTextCellByFontSize($ordarr, 10.0, 10.0, 0.0, 0.0);
            }
        };

        $this->initFont($obj);
        $obj->addPage();
        $result = $obj->runFit([65]);
        $this->assertIsArray($result);
        $this->assertArrayHasKey('fontchanged', $result);
    }

    /** @throws \Throwable */
    public function testFitTextCellByFontSizeNoChangeBranchWithTestDouble(): void
    {
        $obj = new class extends TestableText {
            /** @phpstan-return array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float} */
            protected function getTextCellLayout(
                array $ordarr,
                array $dim,
                float $splitWidth,
                float $displayWidth,
                float $scale,
                float $offsetPoints,
                float $lineSpacePoints,
                bool $rtl = false,
            ): array {
                return [
                    'lines' => [[
                        'pos' => 0,
                        'chars' => 1,
                        'spaces' => 0,
                        'septype' => 'BN',
                        'totwidth' => 100.0,
                        'totspacewidth' => 0.0,
                        'words' => 1,
                    ]],
                    'maxwidth' => 100.0,
                    'txtheight' => 100.0,
                ];
            }

            /** @phpstan-return array{size: float, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array<int, array{pos: int, ord: int, septype: string, spaces: int, totwidth: float, totspacewidth: float, wordwidth: float}>}, layout: array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float}, fits: bool} */
            protected function probeTextCellFontSizeFit(
                array $ordarr,
                float $size,
                float $maxWidth,
                float $maxHeight,
                float $offsetPoints,
                float $lineSpacePoints,
                bool $rtl = false,
            ): array {
                return [
                    'size' => 10.0,
                    'dim' => self::DIM_DEFAULT,
                    'layout' => ['lines' => [], 'maxwidth' => 100.0, 'txtheight' => 100.0],
                    'fits' => true,
                ];
            }

            /**
             * @phpstan-param array<int, int> $ordarr
             * @phpstan-return array{fontchanged: bool, fontout: string, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array<int, array{pos: int, ord: int, septype: string, spaces: int, totwidth: float, totspacewidth: float, wordwidth: float}>}, layout: array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float}}
             * @throws \Com\Tecnick\Pdf\Font\Exception
             */
            public function runFit(array $ordarr): array
            {
                return $this->fitTextCellByFontSize($ordarr, 10.0, 10.0, 0.0, 0.0);
            }
        };

        $this->initFont($obj);
        $obj->addPage();
        $result = $obj->runFit([65]);
        $this->assertFalse($result['fontchanged']);
        $this->assertSame('', $result['fontout']);
    }

    /** @throws \Throwable */
    public function testGetTextCellFitTruncateKeepsTextInsideCellHeight(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $txt = 'Truncation mode must cut content to fit both width and height constraints.';
        $base = $obj->getTextCell(
            $txt,
            10,
            20,
            35,
            5,
            0,
            0.5,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            '',
        );
        $fit = $obj->getTextCell(
            $txt,
            10,
            20,
            35,
            5,
            0,
            0.5,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            'T',
        );
        $bbox = $obj->getLastTextBBox();

        $this->assertNotSame($base, $fit);
        $this->assertLessThanOrEqual(5.0 + 0.01, $bbox['h']);
    }

    /** @throws \Throwable */
    public function testGetTextCellFitStretchRestoresFontState(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');
        $before = $font->getCurrentFont();

        $txt = 'UNBREAKABLETOKENUNBREAKABLETOKENUNBREAKABLETOKENUNBREAKABLETOKEN';
        $out = $obj->getTextCell(
            $txt,
            10,
            20,
            30,
            5,
            0,
            0,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            'S',
        );

        $after = $font->getCurrentFont();
        $this->assertSame($before['stretching'], $after['stretching']);
        $this->assertStringContainsString(' Tz ', $out);
    }

    /** @throws \Throwable */
    public function testAddTextCellFitModesMatchGetTextCellAndRestoreFontState(): void
    {
        $txt = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
        $styles = [
            'all' => [
                'lineWidth' => 0.3,
                'lineCap' => 'butt',
                'lineJoin' => 'miter',
                'dashArray' => [],
                'dashPhase' => 0,
                'lineColor' => '#4a5b70',
                'fillColor' => '#ffffff',
            ],
        ];

        foreach (['', 'T', 'S', 'F'] as $fit) {
            $getObj = $this->getTestObject();
            $this->initFont($getObj);
            $getObj->addPage();

            $expected = $getObj->getTextCell(
                $txt,
                10,
                20,
                80,
                10,
                0,
                0,
                'T',
                'L',
                null,
                $styles,
                0,
                0,
                0,
                0,
                true,
                true,
                false,
                false,
                false,
                false,
                false,
                false,
                '',
                null,
                $fit,
            );

            $addObj = $this->getTestObject();
            $this->initFont($addObj);
            $page = $addObj->addPage();
            $pid = $this->requirePageId($page);

            /** @var \Com\Tecnick\Pdf\Font\Stack $font */
            $font = $this->getObjectProperty($addObj, 'font');
            $before = $font->getCurrentFont();

            /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
            $pageObj = $this->getObjectProperty($addObj, 'page');
            $beforeContent = $pageObj->getPage($pid)['content'];
            $beforeCount = \count($beforeContent);

            $addObj->addTextCell(
                $txt,
                $pid,
                10,
                20,
                80,
                10,
                0,
                0,
                'T',
                'L',
                null,
                $styles,
                0,
                0,
                0,
                0,
                true,
                true,
                false,
                false,
                false,
                false,
                false,
                false,
                '',
                null,
                $fit,
            );

            $after = $font->getCurrentFont();
            $content = $pageObj->getPage($pid)['content'];
            $this->assertGreaterThanOrEqual($beforeCount, \count($content));
            $actual = \implode('', \array_slice($content, $beforeCount));

            $this->assertSame($before['size'], $after['size']);
            $this->assertSame($before['stretching'], $after['stretching']);
            $this->assertSame($expected, $actual);
        }

        $seqObj = $this->getTestObject();
        $this->initFont($seqObj);
        $seqPage = $seqObj->addPage();
        $seqPid = $this->requirePageId($seqPage);

        /** @var \Com\Tecnick\Pdf\Page\Page $seqPageObj */
        $seqPageObj = $this->getObjectProperty($seqObj, 'page');

        $seqObj->addTextCell(
            $txt,
            $seqPid,
            10,
            20,
            80,
            10,
            0,
            0,
            'T',
            'L',
            null,
            $styles,
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            false,
            '',
            null,
            'F',
        );

        $contentBeforeDisabled = $seqPageObj->getPage($seqPid)['content'];
        $beforeDisabledCount = \count($contentBeforeDisabled);

        $disabledTxt = 'Disabled mode should use base font.';
        $seqObj->addTextCell(
            $disabledTxt,
            $seqPid,
            10,
            40,
            80,
            10,
            0,
            0,
            'T',
            'L',
            null,
            $styles,
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            false,
            '',
            null,
            '',
        );

        $contentAfterDisabled = $seqPageObj->getPage($seqPid)['content'];
        $this->assertGreaterThan($beforeDisabledCount, \count($contentAfterDisabled));
        $disabledChunk = \implode('', \array_slice($contentAfterDisabled, $beforeDisabledCount));

        $expectedObj = $this->getTestObject();
        $this->initFont($expectedObj);
        $expectedObj->addPage();
        $expectedDisabled = $expectedObj->getTextCell(
            $disabledTxt,
            10,
            40,
            80,
            10,
            0,
            0,
            'T',
            'L',
            null,
            $styles,
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            false,
            '',
            null,
            '',
        );

        $this->assertSame($expectedDisabled, $disabledChunk);
    }

    /** @throws \Throwable */
    public function testGetTextCellFitStretchIgnoredWhenCompressionCannotReduceOverflow(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        // "Short" easily fits in 80mm width, but the 2mm height is too small even for a single
        // line. Horizontal compression cannot reduce the line count, so fit=S must be a no-op.
        $txt = 'Short';
        $out = $obj->getTextCell(
            $txt,
            10,
            20,
            80,
            2,
            0,
            0,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            'S',
        );

        $this->assertStringNotContainsString(' Tz ', $out);
    }

    /** @throws \Throwable */
    public function testGetTextCellFitStretchAppliesCompressionOnHeightOverflowFromWrapping(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');
        $before = $font->getCurrentFont();

        // Long text that wraps to multiple lines within 160mm but overflows the 6mm height.
        // fit=S should apply horizontal compression to reduce line count.
        $txt = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
        $out = $obj->getTextCell(
            $txt,
            10,
            20,
            160,
            6,
            0,
            0,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            'S',
        );

        $after = $font->getCurrentFont();
        $this->assertSame($before['stretching'], $after['stretching']);
        $this->assertStringContainsString(' Tz ', $out);
    }

    /** @throws \Throwable */
    public function testGetTextCellFitFontSizeRestoresFontState(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');
        $before = $font->getCurrentFont();

        $txt = 'Font-size fitting should reduce the size when needed and restore it after rendering.';
        $out = $obj->getTextCell(
            $txt,
            10,
            20,
            40,
            6,
            0,
            0.5,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            'F',
        );
        $bbox = $obj->getLastTextBBox();

        $after = $font->getCurrentFont();
        $this->assertSame($before['size'], $after['size']);
        $this->assertStringContainsString(' Tf', $out);
        $this->assertGreaterThanOrEqual(2, \substr_count($out, ' Tf'));
        $this->assertLessThanOrEqual(6.0 + 0.01, $bbox['h']);
    }

    /** @throws \Throwable */
    public function testAddTextCellAppendsContentToPage(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        /** @var array<int, string> $before */
        $before = $pageObj->getPage($pid)['content'];

        $obj->addTextCell('Hello', $pid, 1, 2, 20, 6, 0, 0, 'T', 'L');

        /** @var array<int, string> $after */
        $after = $pageObj->getPage($pid)['content'];
        $this->assertGreaterThan(\count($before), \count($after));
        $lastKey = \array_key_last($after);
        $this->assertIsInt($lastKey);
        $lastValue = $after[$lastKey] ?? null;
        $this->assertIsString($lastValue);
        $this->assertNotSame('', $lastValue);
    }

    /** @throws \Throwable */
    public function testAddTextCellImplicitCurrentPageTracksLastAutoBrokenPage(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $firstPage = $obj->addPage([
            'region' => [
                ['RX' => 0.0, 'RY' => 0.0, 'RW' => 80.0, 'RH' => 6.0],
            ],
        ]);
        $firstPid = $this->requirePageId($firstPage);

        $txt = \str_repeat("Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n", 40);
        $obj->addTextCell(
            $txt,
            -1,
            1,
            1,
            78,
            0,
            0,
            0,
            'T',
            'J',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            false,
        );

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        $lastPid = $pageObj->getPageId();

        $this->assertGreaterThan($firstPid, $lastPid);
        $this->assertSame($lastPid, $pageObj->getPage()['pid']);
        $this->assertSame($lastPid, $obj->setCurrentPage()['pid']);
    }

    /** @throws \Throwable */
    public function testAddTextCellXYForwardsRegionRelativeCoordinates(): void
    {
        $obj = new class extends TestableText {
            /** @var array<string, mixed> */
            public array $captured = [];

            public function addTextCell(
                string $txt,
                int $pid = -1,
                float $posx = 0,
                float $posy = 0,
                float $width = 0,
                float $height = 0,
                float $offset = 0,
                float $linespace = 0,
                string|\Com\Tecnick\Pdf\TextVAlign $valign = 'T',
                string|\Com\Tecnick\Pdf\TextHAlign $halign = '',
                ?array $cell = null,
                array $styles = [],
                float $strokewidth = 0,
                float $wordspacing = 0,
                float $leading = 0,
                float $rise = 0,
                bool $jlast = true,
                bool $fill = true,
                bool $stroke = false,
                bool $underline = false,
                bool $linethrough = false,
                bool $overline = false,
                bool $clip = false,
                bool $drawcell = true,
                string|\Com\Tecnick\Unicode\TextDirection $forcedir = '',
                ?array $shadow = null,
                string|\Com\Tecnick\Pdf\TextFitMode $fit = '',
            ): void {
                $this->captured = [
                    'txt' => $txt,
                    'pid' => $pid,
                    'posx' => $posx,
                    'posy' => $posy,
                    'width' => $width,
                    'height' => $height,
                    'offset' => $offset,
                    'linespace' => $linespace,
                    'valign' => $valign,
                    'halign' => $halign,
                    'cell' => $cell,
                    'styles' => $styles,
                    'strokewidth' => $strokewidth,
                    'wordspacing' => $wordspacing,
                    'leading' => $leading,
                    'rise' => $rise,
                    'jlast' => $jlast,
                    'fill' => $fill,
                    'stroke' => $stroke,
                    'underline' => $underline,
                    'linethrough' => $linethrough,
                    'overline' => $overline,
                    'clip' => $clip,
                    'drawcell' => $drawcell,
                    'forcedir' => $forcedir,
                    'shadow' => $shadow,
                    'fit' => $fit,
                ];
            }
        };

        $this->initFont($obj);
        $page = $obj->addPage([
            'region' => [
                [
                    'RX' => 15.0,
                    'RY' => 20.0,
                    'RW' => 120.0,
                    'RH' => 80.0,
                ],
            ],
        ]);
        $pid = $this->requirePageId($page);

        $obj->addTextCellXY('Hello', $pid, 40.0, 55.0, 20.0, 6.0, 1.5, 2.5, 'B', 'R');

        $this->assertSame(
            [
                'txt' => 'Hello',
                'pid' => $pid,
                'posx' => 25.0,
                'posy' => 35.0,
                'width' => 20.0,
                'height' => 6.0,
                'offset' => 1.5,
                'linespace' => 2.5,
                'valign' => 'B',
                'halign' => 'R',
                'cell' => null,
                'styles' => [],
                'strokewidth' => 0.0,
                'wordspacing' => 0.0,
                'leading' => 0.0,
                'rise' => 0.0,
                'jlast' => true,
                'fill' => true,
                'stroke' => false,
                'underline' => false,
                'linethrough' => false,
                'overline' => false,
                'clip' => false,
                'drawcell' => true,
                'forcedir' => '',
                'shadow' => null,
                'fit' => '',
            ],
            $obj->captured,
        );
    }

    /** @throws \Throwable */
    public function testGetLastTextBBoxAndCellBBoxUpdatedByGetTextCell(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $obj->getTextCell('Hello world', 10, 20, 40, 12, 0, 0, 'T', 'L');

        $textbbox = $obj->getLastTextBBox();
        $cellbbox = $obj->getLastCellBBox();

        $this->assertGreaterThan(0.0, $textbbox['w']);
        $this->assertGreaterThan(0.0, $textbbox['h']);
        $this->assertGreaterThan(0.0, $cellbbox['w']);
        $this->assertGreaterThan(0.0, $cellbbox['h']);
    }

    /** @throws \Throwable */
    public function testGetLastCellBBoxUpdatedByAddTextCell(): void
    {
        $obj = $this->getTestObject();
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        $obj->addTextCell('Hello world', $pid, 10, 20, 40, 12, 0, 0, 'T', 'L');

        $cellbbox = $obj->getLastCellBBox();
        $textbbox = $obj->getLastTextBBox();
        $this->assertGreaterThan(0.0, $cellbbox['w']);
        $this->assertGreaterThan(0.0, $cellbbox['h']);
        $this->assertGreaterThan(0.0, $textbbox['w']);
        $this->assertGreaterThan(0.0, $textbbox['h']);
    }

    /** @throws \Throwable */
    public function testTextOperatorHelpersCoverModesAndFormatting(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $this->assertSame('1.000000 2.000000 3.000000 0.500000 re f' . "\n", $obj->exposeGetOutUTOLine(1, 2, 3, 0.5));
        $this->assertSame(0, $obj->exposeGetTextRenderingMode(true, false, false));
        $this->assertSame(3, $obj->exposeGetTextRenderingMode(false, false, false));
        $this->assertSame(7, $obj->exposeGetTextRenderingMode(false, false, true));

        $this->assertStringContainsString('Td raw', $obj->exposeGetOutTextPosXY('raw', 1, 2, 'Td'));
        $this->assertStringContainsString('TD raw', $obj->exposeGetOutTextPosXY('raw', 1, 2, 'TD'));
        $this->assertSame('T* raw', $obj->exposeGetOutTextPosXY('raw', 0, 0, 'T*'));
        $this->assertSame('', $obj->exposeGetOutTextPosXY('raw', 0, 0, 'NOPE'));

        /** @var array<int, array{0: callable(TestableText): string, 1: callable(TestableText): string, 2: string}>
         * $stateOperatorCases
         */
        $stateOperatorCases = [
            [
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTc('raw', 0),
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTc('raw', 1.5),
                ' Tc raw 0 Tc',
            ],
            [
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTw('raw', 0),
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTw('raw', 2),
                ' Tw raw 0 Tw',
            ],
            [
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTz('raw', 1),
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTz('raw', 80),
                ' Tz raw 100 Tz',
            ],
            [
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTL('raw', 0),
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTL('raw', 10),
                ' TL raw 0 TL',
            ],
            [
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTs('raw', 0),
                static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTs('raw', 3),
                ' Ts raw 0 Ts',
            ],
        ];
        foreach ($stateOperatorCases as [$defaultCase, $customCase, $expectedFragment]) {
            $this->assertSame('raw', $defaultCase($obj));
            $this->assertStringContainsString($expectedFragment, $customCase($obj));
        }

        $this->assertSame('raw', $obj->exposeGetOutTextStateOperatorTr('raw', 99));
        $this->assertStringContainsString('2 Tr raw', $obj->exposeGetOutTextStateOperatorTr('raw', 2));
        $this->assertStringContainsString('0.000000 w raw', $obj->exposeGetOutTextStateOperatorw('raw', -1));

        $this->assertSame('', $obj->exposeGetOutTextPosMatrix('raw', [1, 2]));
        $this->assertStringContainsString(' Tm raw', $obj->exposeGetOutTextPosMatrix('raw', [1, 0, 0, 1, 10, 20]));
        $this->assertSame('(abc) Tj', $obj->exposeGetOutTextShowing('abc', 'Tj'));
        $this->assertSame('[(abc)] TJ', $obj->exposeGetOutTextShowing('abc', 'TJ'));
        $this->assertSame("(abc) '", $obj->exposeGetOutTextShowing('abc', "'"));
        $this->assertSame('', $obj->exposeGetOutTextShowing('abc', 'X'));
        $this->assertSame("BT xyz ET\n", $obj->exposeGetOutTextObject('xyz'));
    }

    /** @throws \Throwable */
    public function testTextCleanupHyphenationAndEscapingHelpers(): void
    {
        $obj = $this->getInternalTestObject();

        $this->assertSame('A B C', $obj->exposeCleanupText("A\rB\u{00A0}C\u{00AD}"));
        $this->assertSame([65, 173], \array_values($obj->exposeRemoveOrdArrSoftHyphens([65, 173, 8203, 173])));
        $this->assertSame([36, 8203, 65], $obj->exposeAddOrdArrBreakPoints([36, 65]));
        $this->assertSame([65, 66], $obj->exposeReplaceUnicodeChars([65, 66]));
        $this->assertSame('100%% ready', $obj->exposeEscapePerc('100% ready'));
    }

    /** @throws \Throwable */
    public function testSetPageContextAndStringWidthHelpers(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        /** @var array<int, string> $before */
        $before = $pageObj->getPage($pid)['content'];
        $obj->exposeSetPageContext($pid);
        /** @var array<int, string> $after */
        $after = $pageObj->getPage($pid)['content'];

        $this->assertGreaterThan(\count($before), \count($after));
        $this->assertSame(0.0, $obj->exposeGetStringWidth(''));
        $this->assertGreaterThan(0, $obj->exposeGetStringWidth('Hello'));
    }

    /** @throws \Throwable */
    public function testPrepareTextAndSplitLinesCoverEmptyAndMultiLineCases(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        [$txt, $ordarr, $dim] = $obj->exposePrepareText("Hello\r world");
        $linesWide = $obj->exposeSplitLines($ordarr, $dim, 1000);
        $linesNarrow = $obj->exposeSplitLines($ordarr, $dim, 10);

        $this->assertSame('Hello  world', $txt);
        $this->assertNotEmpty($ordarr);
        $this->assertGreaterThan(0, $dim['totwidth']);
        $this->assertCount(1, $linesWide);
        $this->assertGreaterThan(1, \count($linesNarrow));
        $this->assertSame([], $obj->exposeSplitLines([], $dim, 10));

        [$textWithNewline, $ordarrWithNewline, $dimWithNewline] = $obj->exposePrepareText("Cell Borders\nnextline");
        $newlineLines = $obj->exposeSplitLines($ordarrWithNewline, $dimWithNewline, 1000);
        $this->assertSame("Cell Borders\nnextline", $textWithNewline);
        $this->assertCount(2, $newlineLines);
    }

    /**
     * Stage 1 RTL fix: a wrapped RTL paragraph must stack the logically-first
     * words on the TOP line and the logically-last words on the BOTTOM line.
     *
     * @throws \Throwable
     */
    public function testSplitLinesReversesLineOrderForRtlBaseDirection(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initUnicodeFont($obj);
        $obj->addPage();
        $this->setObjectProperty($obj, 'isunicode', true);

        // Six distinct Hebrew "words" (each three identical letters) in logical order.
        // Pure RTL + spaces, so the Bidi visual array is the exact reverse of the logical one.
        $firstLetter = 0x05D0; // first logical character (top of the page when correct)
        $lastLetter = 0x05D5; // last logical character (bottom of the page when correct)
        $word = static fn(int $cp): string => (string) \mb_chr($cp) . (string) \mb_chr($cp) . (string) \mb_chr($cp);
        $txt = \implode(' ', \array_map($word, [0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5]));

        [, $ordarr, $dim] = $obj->exposePrepareText($txt, 'R');
        $logical = \array_reverse($ordarr); // pure-RTL: visual is the exact reverse of logical
        $this->assertSame($firstLetter, $logical[0] ?? null);
        $this->assertSame($lastLetter, $logical[\count($logical) - 1] ?? null);

        // Force a wrap into several lines (roughly a third of the paragraph per line).
        $wrapWidth = $dim['totwidth'] / 3.0;

        $visualLines = $obj->exposeSplitLines($ordarr, $dim, $wrapWidth, 0, false);
        $rtlLines = $obj->exposeSplitLines($ordarr, $dim, $wrapWidth, 0, true);

        // The reversal must not change how many lines the text wraps to.
        $this->assertGreaterThan(1, \count($rtlLines));
        $this->assertSameSize($visualLines, $rtlLines);

        $visualTop = $visualLines[0] ?? null;
        $visualBottom = $visualLines[\count($visualLines) - 1] ?? null;
        $rtlTop = $rtlLines[0] ?? null;
        $rtlBottom = $rtlLines[\count($rtlLines) - 1] ?? null;
        $this->assertIsArray($visualTop);
        $this->assertIsArray($visualBottom);
        $this->assertIsArray($rtlTop);
        $this->assertIsArray($rtlBottom);

        // Slice of the visual array rendered on each line.
        $topVisual = \array_slice($ordarr, $visualTop['pos'], $visualTop['chars']);
        $bottomVisual = \array_slice($ordarr, $visualBottom['pos'], $visualBottom['chars']);
        $topRtl = \array_slice($ordarr, $rtlTop['pos'], $rtlTop['chars']);
        $bottomRtl = \array_slice($ordarr, $rtlBottom['pos'], $rtlBottom['chars']);

        // Default (rtl=false) walks the visual array forward and stacks bottom-up: the
        // logically-LAST word ends up on the top line and the logically-FIRST on the bottom.
        $this->assertContains($lastLetter, $topVisual);
        $this->assertNotContains($firstLetter, $topVisual);
        $this->assertContains($firstLetter, $bottomVisual);

        // With the RTL flag the order is corrected: logically-FIRST word on top,
        // logically-LAST word on the bottom.
        $this->assertContains($firstLetter, $topRtl);
        $this->assertNotContains($lastLetter, $topRtl);
        $this->assertContains($lastLetter, $bottomRtl);

        // Each RTL line slice is still in visual (reversed) order, so reversing it back to
        // logical and concatenating the lines top-to-bottom reproduces the logical reading
        // sequence. Compare the non-space glyphs (line breaks drop their separator space,
        // while spaces inside a line are kept) to assert the letters keep logical order.
        $reconstructed = [];
        foreach ($rtlLines as $line) {
            foreach (\array_reverse(\array_slice($ordarr, $line['pos'], $line['chars'])) as $cp) {
                $reconstructed[] = $cp;
            }
        }
        $stripSpaces = static fn(array $arr): array => \array_values(\array_filter(
            $arr,
            static fn(int $cp): bool => $cp !== 0x20,
        ));
        $this->assertSame($stripSpaces($logical), $stripSpaces($reconstructed));

        // Single line => reversal is a no-op (same glyphs in the same position). Only the
        // recomputed totwidth may differ by a float ULP due to summation order.
        $singleVisual = $obj->exposeSplitLines($ordarr, $dim, $dim['totwidth'] * 2.0, 0, false);
        $singleRtl = $obj->exposeSplitLines($ordarr, $dim, $dim['totwidth'] * 2.0, 0, true);
        $this->assertCount(1, $singleRtl);
        $singleVisualLine = $singleVisual[0] ?? null;
        $singleRtlLine = $singleRtl[0] ?? null;
        $this->assertIsArray($singleVisualLine);
        $this->assertIsArray($singleRtlLine);
        $this->assertSame((int) $singleVisualLine['pos'], (int) $singleRtlLine['pos']);
        $this->assertSame((int) $singleVisualLine['chars'], (int) $singleRtlLine['chars']);
        $this->assertSame(0, (int) $singleRtlLine['pos']);
        $this->assertSame(\count($ordarr), (int) $singleRtlLine['chars']);

        // Default flag is unchanged for the LTR/empty cases.
        $this->assertSame([], $obj->exposeSplitLines([], $dim, 10, 0, true));
    }

    /** @throws \Throwable */
    public function testIsOrdArrBaseRtlResolvesDirection(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initUnicodeFont($obj);
        $obj->addPage();
        $this->setObjectProperty($obj, 'isunicode', true);

        $latin = [0x41, 0x42, 0x43]; // ABC
        $hebrew = [0x05D0, 0x05D1]; // strong R
        $neutral = [0x20, 0x2E, 0x31]; // space, dot, digit -> no strong char

        // Explicit forcedir always wins.
        $this->assertTrue($obj->exposeIsOrdArrBaseRtl($latin, 'R'));
        $this->assertFalse($obj->exposeIsOrdArrBaseRtl($hebrew, 'L'));

        // Auto: first strong character decides.
        $this->assertFalse($obj->exposeIsOrdArrBaseRtl($latin, ''));
        $this->assertTrue($obj->exposeIsOrdArrBaseRtl($hebrew, ''));
        // A leading neutral run is skipped until the first strong (R) character.
        $this->assertTrue($obj->exposeIsOrdArrBaseRtl([...$neutral, ...$hebrew], ''));

        // Latin letters are not listed in the bidirectional type table, so a mixed
        // paragraph that starts with them stays left-to-right.
        $this->assertFalse($obj->exposeIsOrdArrBaseRtl([...$latin, 0x20, ...$hebrew], ''));
        // The same holds for the other characters of type L that are not listed.
        $this->assertFalse($obj->exposeIsOrdArrBaseRtl([0x4E00, 0x20, ...$hebrew], ''));

        // No strong character: fall back to the document default ($this->rtl).
        $this->assertFalse($obj->exposeIsOrdArrBaseRtl($neutral, ''));
        $this->setObjectProperty($obj, 'rtl', true);
        $this->assertTrue($obj->exposeIsOrdArrBaseRtl($neutral, ''));

        // prepareText surfaces the resolved base direction (and only when Bidi-reordered).
        [, , , $baseRtl] = $obj->exposePrepareTextWithDir((string) \mb_chr(0x05D0) . (string) \mb_chr(0x05D1), '');
        $this->assertTrue($baseRtl);
        [, , , $baseLtr] = $obj->exposePrepareTextWithDir('abc', '');
        $this->assertFalse($baseLtr);
    }

    /**
     * The text level hyphenation collects the words by bidirectional type L, so it must
     * hyphenate them exactly like the word level pass.
     *
     * @throws \Throwable
     */
    public function testHyphenateTextOrdArrHyphenatesLatinWords(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initUnicodeFont($obj);
        $obj->addPage();

        $patterns = ['hyphen' => 'hy3phen'];
        $hypWord = $obj->exposeHyphenateWordOrdArr($patterns, $obj->exposeStrToOrdArr('hyphen'));
        $hypText = $obj->exposeHyphenateTextOrdArr($patterns, $obj->exposeStrToOrdArr('hyphen,test'));

        $this->assertContains(0x00AD, $hypWord);
        $this->assertSame($hypWord, \array_slice($hypText, 0, \count($hypWord)));
        $this->assertContains(0x00AD, $hypText);
    }

    /**
     * End-to-end: addTextCell() on a wrapping RTL paragraph (E003's call pattern:
     * width set, no height) must emit the glyph runs top-to-bottom in logical order,
     * i.e. matching splitLines(rtl=true) and NOT the visual splitLines(rtl=false).
     *
     * @throws \Throwable
     */
    public function testAddTextCellStacksWrappedRtlParagraphTopDown(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initUnicodeFont($obj);
        $this->setObjectProperty($obj, 'isunicode', true);
        $obj->setRTL(true);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        $word = static fn(int $cp): string => (string) \mb_chr($cp) . (string) \mb_chr($cp) . (string) \mb_chr($cp);
        $txt = \implode(' ', \array_map($word, [0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5]));

        // A borderless cell (drawcell:false, no styles) has zero padding, so the internal
        // split width equals toPoints(width); reuse it to derive the reference lines.
        $cellWidthMm = 18.0;
        [, $ordarr, $dim] = $obj->exposePrepareText($txt, '');
        $renderWidthPts = $obj->toPoints($cellWidthMm);

        $logicalLines = $obj->exposeSplitLines($ordarr, $dim, $renderWidthPts, 0, true);
        $visualLines = $obj->exposeSplitLines($ordarr, $dim, $renderWidthPts, 0, false);
        $this->assertGreaterThan(1, \count($logicalLines));

        // Ordered list of text-show operands (one per rendered line) in the content stream.
        $glyphTokens = static function (string $content): array {
            $matches = [];
            \preg_match_all('/(\([^)]*\)|<[0-9A-Fa-f]*>|\[[^\]]*\])\s*T[jJ]/s', $content, $matches);
            return $matches[1] ?? [];
        };

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        $beforeContent = $pageObj->getPage($pid)['content'];
        $beforeCount = \count($beforeContent);

        $obj->addTextCell(txt: $txt, pid: $pid, posx: 5, posy: 20, width: $cellWidthMm, drawcell: false);

        $content = $pageObj->getPage($pid)['content'];
        $actual = \implode('', \array_slice($content, $beforeCount));
        $actualTokens = $glyphTokens($actual);

        // Reference renders through the same outTextLines() the cell uses internally; with
        // halign 'R' (the RTL default) no justification spacing is added, so the glyph-token
        // sequence depends only on the line breaks and their top-to-bottom order.
        $width = $obj->toUnit($renderWidthPts);
        $expectedRtl = $glyphTokens($obj->exposeOutTextLines($ordarr, $logicalLines, 5, 20, $width, 0, 5, 0));
        $expectedVisual = $glyphTokens($obj->exposeOutTextLines($ordarr, $visualLines, 5, 20, $width, 0, 5, 0));

        // The emitted glyph order matches the logical (top-down) stacking, not the visual one.
        $this->assertNotEmpty($actualTokens);
        $this->assertSame($expectedRtl, $actualTokens);
        $this->assertNotSame($expectedVisual, $actualTokens);
    }

    /**
     * Stage 2a: getTextCell() is the path every HTML fragment renders through, so it
     * must also stack a wrapping RTL paragraph top-down in logical order (matching
     * splitLines(rtl=true)), not the visual (rtl=false) order. Before Stage 2a only
     * addTextCell() reversed the line order; getTextCell() kept the visual stacking.
     *
     * @throws \Throwable
     */
    public function testGetTextCellStacksWrappedRtlParagraphTopDown(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initUnicodeFont($obj);
        $this->setObjectProperty($obj, 'isunicode', true);
        $obj->setRTL(true);
        $obj->addPage();

        $word = static fn(int $cp): string => (string) \mb_chr($cp) . (string) \mb_chr($cp) . (string) \mb_chr($cp);
        $txt = \implode(' ', \array_map($word, [0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5]));

        $cellWidthMm = 18.0;
        [, $ordarr, $dim] = $obj->exposePrepareText($txt, '');
        $renderWidthPts = $obj->toPoints($cellWidthMm);

        $logicalLines = $obj->exposeSplitLines($ordarr, $dim, $renderWidthPts, 0, true);
        $visualLines = $obj->exposeSplitLines($ordarr, $dim, $renderWidthPts, 0, false);
        $this->assertGreaterThan(1, \count($logicalLines));

        $glyphTokens = static function (string $content): array {
            $matches = [];
            \preg_match_all('/(\([^)]*\)|<[0-9A-Fa-f]*>|\[[^\]]*\])\s*T[jJ]/s', $content, $matches);
            return $matches[1] ?? [];
        };

        // drawcell:false makes getTextCell return only the text-show output; the default
        // halign 'C' adds no justification spacing, so the glyph-token order depends only
        // on the line breaks and their top-to-bottom stacking.
        $out = $obj->getTextCell(txt: $txt, posx: 5, posy: 20, width: $cellWidthMm, drawcell: false);
        $actualTokens = $glyphTokens($out);

        $width = $obj->toUnit($renderWidthPts);
        $expectedRtl = $glyphTokens($obj->exposeOutTextLines($ordarr, $logicalLines, 5, 20, $width, 0, 5, 0));
        $expectedVisual = $glyphTokens($obj->exposeOutTextLines($ordarr, $visualLines, 5, 20, $width, 0, 5, 0));

        $this->assertNotEmpty($actualTokens);
        $this->assertSame($expectedRtl, $actualTokens);
        $this->assertNotSame($expectedVisual, $actualTokens);
    }

    /** @throws \Throwable */
    public function testGetOutTextLineAndOutTextLineRenderFromPreparedText(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        [$txt, $ordarr, $dim] = $obj->exposePrepareText('Hello world');

        $this->assertSame('', $obj->exposeGetOutTextLine('', [], []));
        $this->assertSame('', $obj->exposeOutTextLine('', [], []));

        $raw = $obj->exposeGetOutTextLine(
            $txt,
            $ordarr,
            $dim,
            1,
            2,
            0,
            0,
            0,
            0,
            0,
            true,
            false,
            true,
            true,
            true,
            false,
        );
        $out = $obj->exposeOutTextLine($txt, $ordarr, $dim, 1, 2, 0, 0, 0, 0, 0, true, false, true, true, true, false);

        $this->assertStringContainsString('BT ', $raw);
        $this->assertStringContainsString(' ET', $raw);
        $this->assertStringContainsString('re f', $out);
    }

    /** @throws \Throwable */
    public function testOutTextLinesGetJustifiedStringAndHyphenationHelpers(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        [$txt, $ordarr, $dim] = $obj->exposePrepareText('Hello world again');
        $lines = $obj->exposeSplitLines($ordarr, $dim, 20);

        $this->assertSame('', $obj->exposeOutTextLines([], [], 0, 0, 0, 0, 0));

        $block = $obj->exposeOutTextLines($ordarr, $lines, 1, 1, 30, 0, 1.5, 0, 0, 0, 0, 0, 'J', false);
        $this->assertStringContainsString('BT ', $block);

        $just = $obj->exposeGetJustifiedString($txt, $ordarr, $dim, 40);
        $this->assertStringContainsString('Tw', $just);

        $patterns = ['hyphen' => 'hy4phen'];
        $word = $obj->exposeStrToOrdArr('hyphen');
        $text = $obj->exposeStrToOrdArr('hyphen,test');
        $trailingWordText = $obj->exposeStrToOrdArr('hyphen,word');
        $hypWord = $obj->exposeHyphenateWordOrdArr($patterns, $word);
        $hypText = $obj->exposeHyphenateTextOrdArr($patterns, $text);
        $hypTrailingWordText = $obj->exposeHyphenateTextOrdArr($patterns, $trailingWordText);

        $this->assertNotEmpty($hypWord);
        $this->assertNotEmpty($hypText);
        $this->assertSame($obj->exposeStrToOrdArr('word'), \array_slice($hypTrailingWordText, -4));
        $this->assertSame($word, $obj->exposeHyphenateWordOrdArr([], $word));
    }

    /** @throws \Throwable */
    public function testOutTextLinesLowercaseAlignAndInlineWordSpacingBranches(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        [, $ordarr] = $obj->exposePrepareText('a b c d');

        /** @var array<int, array{pos:int, chars:int, spaces:int, totwidth:float, totspacewidth:float, words:int, septype:string}> $lines */
        $lines = [
            [
                'pos' => 0,
                'chars' => 3,
                'spaces' => 1,
                'totwidth' => 10.0,
                'totspacewidth' => 2.0,
                'words' => 2,
                'septype' => 'S',
            ],
            [
                'pos' => 4,
                'chars' => 3,
                'spaces' => 1,
                'totwidth' => 10.0,
                'totspacewidth' => 2.0,
                'words' => 2,
                'septype' => 'S',
            ],
        ];

        $center = $obj->exposeOutTextLines(
            $ordarr,
            $lines,
            1.0,
            1.0,
            40.0,
            0.0,
            1.5,
            0.0,
            0.0,
            0.6,
            0.0,
            0.0,
            'c',
            false,
        );
        $right = $obj->exposeOutTextLines(
            $ordarr,
            $lines,
            1.0,
            1.0,
            40.0,
            0.0,
            1.5,
            0.0,
            0.0,
            0.6,
            0.0,
            0.0,
            'r',
            false,
        );
        $auto = $obj->exposeOutTextLines($ordarr, $lines, 1.0, 1.0, 40.0, 0.0, 1.5, 0.0, 0.0, 0.6, 0.0, 0.0, '', false);

        $this->assertStringContainsString('BT ', $center);
        $this->assertStringContainsString('BT ', $right);
        $this->assertStringContainsString('BT ', $auto);
    }

    /** @throws \Throwable */
    public function testLastBBoxHelpersFallbackToZeroForSparseArrayIndexes(): void
    {
        $obj = $this->getTestObject();

        /** @var array<string, float> $box */
        $box = ['x' => 1.0, 'y' => 2.0, 'w' => 3.0, 'h' => 4.0];
        $this->setObjectProperty($obj, 'bbox', [5 => $box]);
        $this->setObjectProperty($obj, 'textbbox', [7 => $box]);
        $this->setObjectProperty($obj, 'cellbbox', [9 => $box]);

        $zero = ['x' => 0.0, 'y' => 0.0, 'w' => 0.0, 'h' => 0.0];
        $this->assertSame($zero, $obj->getLastBBox());
        $this->assertSame($zero, $obj->getLastTextBBox());
        $this->assertSame($zero, $obj->getLastCellBBox());

        $this->setObjectProperty($obj, 'bbox', []);
        $this->setObjectProperty($obj, 'textbbox', []);
        $this->setObjectProperty($obj, 'cellbbox', []);
        $this->assertSame($zero, $obj->getLastBBox());
        $this->assertSame($zero, $obj->getLastTextBBox());
        $this->assertSame($zero, $obj->getLastCellBBox());
    }

    /** @throws \Throwable */
    public function testSplitLinesHandlesMissingSplitEntriesDefensively(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        /** @var array{chars:int, spaces:int, totwidth:float, totspacewidth:float, words:int, split:array<int, array{pos:int, ord:int, septype:string, spaces:int, totwidth:float, totspacewidth:float, wordwidth:float}>} $dim */
        $dim = [
            'chars' => 10,
            'spaces' => 1,
            'totwidth' => 20.0,
            'totspacewidth' => 2.0,
            'words' => 2,
            'split' => [
                0 => [
                    'pos' => 0,
                    'ord' => 32,
                    'septype' => 'WS',
                    'spaces' => 0,
                    'totwidth' => 5.0,
                    'totspacewidth' => 1.0,
                    'wordwidth' => 4.0,
                ],
            ],
        ];

        $lines = $obj->exposeSplitLines([65, 32, 66], $dim, 8.0);
        $this->assertIsArray($lines);
    }

    /** @throws \Throwable */
    public function testRawTextLineMethodsReturnEmptyOnEmptyOrdinalArray(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        /** @var array{chars:int, spaces:int, totwidth:float, totspacewidth:float, words:int, split:array<int, array{pos:int, ord:int, septype:string, spaces:int, totwidth:float, totspacewidth:float, wordwidth:float}>} $dim */
        $dim = [
            'chars' => 1,
            'spaces' => 0,
            'totwidth' => 1.0,
            'totspacewidth' => 0.0,
            'words' => 1,
            'split' => [],
        ];

        $this->assertSame('', $obj->exposeRawGetOutTextLine('X', [], $dim));
        $this->assertSame('', $obj->exposeRawOutTextLine('X', [], $dim));
    }

    /** @throws \Throwable */
    public function testGetOutTextPosMatrixReturnsEmptyForInvalidMatrixSizeViaReflection(): void
    {
        $obj = $this->getInternalTestObject();
        $method = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'getOutTextPosMatrix');
        /** @var array{float, float, float, float, float, float} $invalid */
        $invalid = [1.0, 0.0, 0.0, 1.0, 0.0];
        $this->assertSame('', $method->invoke($obj, 'X', $invalid));
    }

    /** @throws \Throwable */
    public function testTextAdditionalBranchesForCoverage(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $page = $obj->addPage();

        $cellNoBox = $obj->getTextCell(
            'NoBox',
            1,
            2,
            0,
            0,
            0,
            0,
            'T',
            'L',
            null,
            [],
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            false,
        );
        $this->assertNotSame('', $cellNoBox);

        $obj->addTextCell('', -1, 1, 1, 0, 0, 0, 0, 'T', '');
        $obj->addTextCell('AutoAlign', -1, 1, 1, 0, 0, 0, 0, 'T', '');

        $this->assertSame('', $obj->exposeRawOutTextLines([], [], 0, 0, 0, 0, 0));

        $obj->setTexHyphenPatterns(['hyphen' => 'hy3phen']);
        $obj->enableZeroWidthBreakPoints(true);
        [, $ordarrPrepared] = $obj->exposePrepareText('hyphen,word');
        $this->assertNotEmpty($ordarrPrepared);

        $ordarr = $obj->exposeHyphenateWordOrdArr(['testing' => 'te3st2ing'], $obj->exposeStrToOrdArr('testing'));
        $dim = $obj->exposeGetOrdArrDims($ordarr);
        $lines = $obj->exposeSplitLines($ordarr, $dim, 5);
        $this->assertNotEmpty($lines);

        $this->setObjectProperty($obj, 'isunicode', true);
        $justOrdArr = $obj->exposeStrToOrdArr('word word');
        $justDim = $obj->exposeGetOrdArrDims($justOrdArr);
        $just = $obj->exposeGetJustifiedString('word word', $justOrdArr, $justDim, 20);
        $this->assertNotSame('', $just);

        $tmp = \tempnam(\sys_get_temp_dir(), 'tc-hyp-');
        $this->assertNotFalse($tmp);
        \file_put_contents($tmp, "\\patterns{\n\n hy4phen   test1ing \n}");
        $parsed = $obj->loadTexHyphenPatterns($tmp);
        if (\file_exists($tmp)) {
            \unlink($tmp);
        }
        $this->assertArrayHasKey('hyphen', $parsed);

        $this->setObjectProperty($obj, 'defPageContentEnabled', true);
        $pid = $this->requirePageId($page);
        $obj->exposeSetPageContext($pid);
        $defaultOut = $obj->defaultPageContent();
        $this->assertStringContainsString('BT', $defaultOut);
    }

    /** @throws \Throwable */
    public function testTextRemainingBranchCoverageBatch(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $obj->getTextLine('Hello', 10, 20, 0, 0, 0, 0, 0, true, false, false, false, false, false, '', 'M');
        $middleLtr = $obj->getLastBBox();
        $obj->getTextLine('Hello', 10, 20, 0, 0, 0, 0, 0, true, false, false, false, false, false, 'R', 'M');
        $middleRtl = $obj->getLastBBox();
        $obj->getTextLine('Hello', 10, 20, 0, 0, 0, 0, 0, true, false, false, false, false, false, '', 'E');
        $endLtr = $obj->getLastBBox();
        $obj->getTextLine('Hello', 10, 20, 0, 0, 0, 0, 0, true, false, false, false, false, false, 'R', 'E');
        $endRtl = $obj->getLastBBox();

        $this->assertLessThan(10, $middleLtr['x'] ?? 0);
        $this->assertGreaterThan(10, $middleRtl['x'] ?? 0);
        $this->assertLessThan(10, $endLtr['x'] ?? 0);
        $this->assertGreaterThan(10, $endRtl['x'] ?? 0);

        $shadow = [
            'xoffset' => -1.5,
            'yoffset' => -2.0,
            'opacity' => 0.5,
            'mode' => 'Normal',
            'color' => 'gray',
        ];
        $shadowOut = $obj->getTextLine(
            'Hello world',
            5,
            6,
            0,
            0,
            0,
            0,
            0,
            true,
            false,
            false,
            false,
            false,
            false,
            '',
            '',
            $shadow,
        );
        $this->assertSame(2, \substr_count($shadowOut, 'BT '));
        $this->assertStringContainsString('/GS', $shadowOut);
        $soft = $obj->exposeStrToOrdArr("test\u{00AD}ing words");
        $softDim = $obj->exposeGetOrdArrDims($soft);
        $softLines = $obj->exposeSplitLines($soft, $softDim, 5);
        $this->assertGreaterThan(1, \count($softLines));
        $lastIndex = \count($softLines) - 1;
        $this->assertGreaterThanOrEqual(0, $lastIndex);
        $lastLine = $softLines[$lastIndex] ?? null;
        $this->assertIsArray($lastLine);
        $this->assertGreaterThan(0, $lastLine['chars']);

        $this->assertSame('', $obj->exposeRawGetOutTextLine('Hello', [], []));
        $this->assertSame('', $obj->exposeRawOutTextLine('Hello', [], []));
        $this->assertSame('', $obj->exposeRawGetOutTextPosMatrix('raw', [1, 2]));

        $unicode = $this->getInternalTestObject();
        $this->initUnicodeFont($unicode);
        $unicode->addPage();
        $this->setObjectProperty($unicode, 'isunicode', true);

        [$unicodeText, $unicodeOrdArr, $unicodeDim] = $unicode->exposePrepareText("A \u{05D0} B", 'R');
        $this->assertNotEmpty($unicodeOrdArr);
        $this->assertGreaterThan(0, $unicodeDim['totwidth']);

        $unicodePlain = $unicode->exposeGetJustifiedString($unicodeText, $unicodeOrdArr, $unicodeDim, 0);
        $unicodeJustified = $unicode->exposeGetJustifiedString($unicodeText, $unicodeOrdArr, $unicodeDim, 40);
        $this->assertStringContainsString('Tj', $unicodePlain);
        $this->assertStringContainsString('TJ', $unicodeJustified);

        $invalid = \tempnam(\sys_get_temp_dir(), 'tc-hyp-invalid-');
        $this->assertNotFalse($invalid);
        \file_put_contents($invalid, "% comment only\n\\patternsMissing{hy4phen}");

        try {
            $obj->loadTexHyphenPatterns($invalid);
            $this->fail('Expected invalid hyphenation pattern section exception');
        } catch (\Com\Tecnick\Pdf\Exception $e) {
            $this->assertStringContainsString('Invalid hyphenation pattern section', $e->getMessage());
        } finally {
            if (\file_exists($invalid)) {
                \unlink($invalid);
            }
        }
    }

    /** @throws \Throwable */
    public function testPdfUaActualTextLigatureHelpersAndTagging(): void
    {
        $obj = $this->getInternalTestObject();

        $noLigature = $obj->exposeGetActualTextForOrdarr($obj->exposeStrToOrdArr('office'));
        $this->assertSame('', $noLigature);

        $this->assertSame('fi', $obj->exposeGetActualTextForOrdarr([0xFB01]));
        $this->assertSame('ffi', $obj->exposeGetActualTextForOrdarr([0xFB03]));

        $mixed = $obj->exposeGetActualTextForOrdarr([0x0061, 0xFB01, 0x0062]);
        $this->assertSame('afib', $mixed);

        $formatted = $obj->exposeFormatPdfUaActualText('fi');
        $this->assertSame('<feff00660069>', $formatted);

        $pdfua = new TestableText('mm', true, false, true, 'pdfua');
        $withActual = $pdfua->exposeTagPdfUaTextContent("BT (x) Tj ET\n", 0, 'fi');
        $withoutActual = $pdfua->exposeTagPdfUaTextContent("BT (x) Tj ET\n", 0);

        $this->assertStringContainsString('/P <</MCID 0 /ActualText <feff00660069>>> BDC', $withActual);
        $this->assertStringContainsString('EMC', $withActual);
        $this->assertStringContainsString('/P <</MCID 1>> BDC', $withoutActual);
        $this->assertStringNotContainsString('/ActualText', $withoutActual);

        $multiLine = "BT (line1) Tj ET\nBT (line2) Tj ET\n";
        $wrappedMultiLine = $pdfua->exposeTagPdfUaTextContent($multiLine, 0);

        $this->assertSame(1, \substr_count($wrappedMultiLine, '/P <</MCID 2>> BDC'));
        $this->assertSame(1, \substr_count($wrappedMultiLine, 'EMC'));
        $this->assertStringContainsString("BT (line1) Tj ET\nBT (line2) Tj ET\n", $wrappedMultiLine);
    }

    /** @throws \Throwable */
    public function testGetTextLineOmitsShadowAlphaInPdfx3(): void
    {
        $obj = new TestableText('mm', true, false, true, 'pdfx3');
        $this->initFont($obj);
        $obj->addPage();

        $shadow = [
            'xoffset' => -1.5,
            'yoffset' => -2.0,
            'opacity' => 0.5,
            'mode' => 'Normal',
            'color' => 'gray',
        ];

        $shadowOut = $obj->getTextLine(
            'Hello world',
            5,
            6,
            0,
            0,
            0,
            0,
            0,
            true,
            false,
            false,
            false,
            false,
            false,
            '',
            '',
            $shadow,
        );

        $this->assertSame(2, \substr_count($shadowOut, 'BT '));
        $this->assertStringNotContainsString('/GS', $shadowOut);
    }

    /** @throws \Throwable */
    public function testPdfUaStructElementLifecycleAndNesting(): void
    {
        $plain = $this->getInternalTestObject();
        $plain->beginStructElem('P', 0, 'ignored');
        $plain->endStructElem();
        $this->assertSame([], $this->getObjectProperty($plain, 'pdfuaStructStack'));

        $obj = new TestableText('mm', true, false, true, 'pdfua');
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        $obj->beginStructElem('Figure', $pid, 'Parent alt', ['Lang' => 'en-US']);
        $obj->beginStructElem('P', $pid);
        $obj->exposeTagPdfUaTextContent("BT (nested) Tj ET\n", $pid);
        $obj->endStructElem();
        $obj->endStructElem();

        /** @var array<int, array<string, mixed>> $log */
        $log = $this->getObjectProperty($obj, 'pdfuaStructLog');
        $this->assertCount(2, $log);
        $this->assertSame('P', $log[0]['role'] ?? null);
        $this->assertSame('Figure', $log[1]['role'] ?? null);
        $this->assertSame('Parent alt', $log[1]['alt'] ?? null);
        $this->assertSame(['Lang' => 'en-US'], $log[1]['attr'] ?? null);

        /** @var array<int, mixed> $kids */
        $kids = \is_array($log[1]['kids'] ?? null) ? $log[1]['kids'] : [];
        $this->assertNotEmpty($kids);
    }

    /** @throws \Throwable */
    public function testPdfUaFigureTaggingAndAnnotationRegistrationBranches(): void
    {
        $obj = new TestableText('mm', true, false, true, 'pdfua');
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        $beforeContent = $pageObj->getPage($pid)['content'];
        $beforeCount = \count($beforeContent);
        $obj->addTaggedFigureContent('', $pid, '');
        $afterEmpty = $pageObj->getPage($pid)['content'];
        $this->assertCount($beforeCount, $afterEmpty);

        $wrapped = $obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'Standalone figure');
        $this->assertStringContainsString('/Figure <</MCID', $wrapped);
        $this->assertStringContainsString('EMC', $wrapped);

        $obj->beginStructElem('Figure', $pid, 'Open figure');
        $obj->addTaggedFigureContent("q\nQ\n", $pid, 'ignored alt');
        $obj->exposeRegisterPdfUaAnnotation(42, $pid);

        /** @var array<int, array<string, mixed>> $stack */
        $stack = $this->getObjectProperty($obj, 'pdfuaStructStack');
        $topKey = \array_key_last($stack);
        $top = \is_int($topKey) && isset($stack[$topKey]) ? $stack[$topKey] : null;
        $this->assertIsArray($top);
        $this->assertSame([42], $top['annots'] ?? null);
        $this->assertSame('Open figure', $top['alt'] ?? null);

        $obj->endStructElem();

        $obj->exposeRegisterPdfUaAnnotation(77, $pid);

        /** @var array<int, array<string, mixed>> $log */
        $log = $this->getObjectProperty($obj, 'pdfuaStructLog');
        $lastKey = \array_key_last($log);
        $last = \is_int($lastKey) && isset($log[$lastKey]) ? $log[$lastKey] : null;
        $this->assertIsArray($last);
        $this->assertSame('Link', $last['role'] ?? null);
        $this->assertSame([77], $last['annots'] ?? null);
    }

    /** @throws \Throwable */
    public function testPdfUaFigureBoundingBoxIsRecordedAndMerged(): void
    {
        $obj = new TestableText('mm', true, false, true, 'pdfua');
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        // A standalone figure records the box it was given, normalized to [llx, lly, urx, ury].
        $obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'Standalone', [30.0, 80.0, 10.0, 20.0]);

        /** @var array<int, array<string, mixed>> $log */
        $log = $this->getObjectProperty($obj, 'pdfuaStructLog');
        $lastKey = \array_key_last($log);
        $last = \is_int($lastKey) && isset($log[$lastKey]) ? $log[$lastKey] : null;
        $this->assertIsArray($last);
        $this->assertSame([10.0, 20.0, 30.0, 80.0], $last['bbox'] ?? null);

        // Content added to an open Figure bracket grows the box to the union of both.
        $obj->beginStructElem('Figure', $pid, 'Open figure');
        $obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, '', [10.0, 20.0, 30.0, 40.0]);
        $obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, '', [25.0, 5.0, 50.0, 35.0]);

        /** @var array<int, array<string, mixed>> $stack */
        $stack = $this->getObjectProperty($obj, 'pdfuaStructStack');
        $topKey = \array_key_last($stack);
        $top = \is_int($topKey) && isset($stack[$topKey]) ? $stack[$topKey] : null;
        $this->assertIsArray($top);
        $this->assertSame([10.0, 5.0, 50.0, 40.0], $top['bbox'] ?? null);

        // A figure tagged without a box gets no bbox entry.
        $obj->endStructElem();
        $obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'No box');

        /** @var array<int, array<string, mixed>> $log */
        $log = $this->getObjectProperty($obj, 'pdfuaStructLog');
        $lastKey = \array_key_last($log);
        $last = \is_int($lastKey) && isset($log[$lastKey]) ? $log[$lastKey] : null;
        $this->assertIsArray($last);
        $this->assertArrayNotHasKey('bbox', $last);
    }

    /** @throws \Throwable */
    public function testRemoveOrdArrSoftHyphensKeepsTrailingSoftHyphenOnlyWhenPresent(): void
    {
        $obj = $this->getInternalTestObject();

        $withoutTrailing = $obj->exposeRemoveOrdArrSoftHyphens([65, 173, 8203, 66]);
        $withTrailing = $obj->exposeRemoveOrdArrSoftHyphens([65, 8203, 173]);

        $this->assertSame([65, 66], $withoutTrailing);
        $this->assertSame([65, 173], $withTrailing);
    }

    /** @throws \Throwable */
    public function testTextBaseCleanupAndSoftHyphenRemovalImplementationsAreCallable(): void
    {
        $obj = $this->getInternalTestObject();

        $cleanupMethod = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'cleanupText');
        $this->assertSame('A B C', $cleanupMethod->invoke($obj, "A\rB\u{00A0}C\u{00AD}"));

        $removeMethod = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'removeOrdArrSoftHyphens');
        $this->assertSame([65, 66], $removeMethod->invoke($obj, [65, 173, 8203, 66]));
        $this->assertSame([65, 173], $removeMethod->invoke($obj, [65, 8203, 173]));
    }

    /** @throws \Throwable */
    public function testLoadTexHyphenPatternsSkipsEmptyPatternTokens(): void
    {
        $obj = $this->getInternalTestObject();
        $tmp = \tempnam(\sys_get_temp_dir(), 'tc-hyp-empty-');
        $this->assertNotFalse($tmp);

        \file_put_contents($tmp, "\\patterns{\n\n hy4phen      test1ing   \n}");
        $patterns = $obj->loadTexHyphenPatterns($tmp);
        if (\file_exists($tmp)) {
            \unlink($tmp);
        }

        $this->assertSame('hy4phen', $patterns['hyphen'] ?? null);
        $this->assertSame('test1ing', $patterns['testing'] ?? null);
    }

    /** @throws \Throwable */
    public function testFitTextCellByTruncationGuardBranchesViaReflection(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $method = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'fitTextCellByTruncation');
        $this->assertSame([], $method->invoke($obj, [], 20.0, 10.0, 0.0, 0.0));

        [, $shortOrdarr] = $obj->exposePrepareText('fit');
        $this->assertSame($shortOrdarr, $method->invoke($obj, $shortOrdarr, 300.0, 300.0, 0.0, 0.0));

        [, $longOrdarr] = $obj->exposePrepareText(
            'This sentence is intentionally long to force truncation checks inside tiny cells.',
        );
        $this->assertSame([], $method->invoke($obj, $longOrdarr, 20.0, 0.1, 0.0, 0.0));
    }

    /** @throws \Throwable */
    public function testPdfUaTaggingGuardsAndArtifactBranches(): void
    {
        $plain = $this->getInternalTestObject();
        $this->assertSame('q Q', $plain->exposeTagPdfUaFigureContent('q Q', 0, 'alt'));

        $obj = new TestableText('mm', true, false, true, 'pdfua');
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        $this->assertSame('/Artifact BMC' . "\n", $obj->beginArtifact());

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        $beforeCount = \count($pageObj->getPage($pid)['content']);
        $obj->addArtifactContent('', $pid, 'Pagination', 'Footer');
        $afterEmptyCount = \count($pageObj->getPage($pid)['content']);
        $this->assertSame($beforeCount, $afterEmptyCount);

        $obj->addArtifactContent('q 0 0 1 rg Q', $pid, 'Pagination', 'Footer');
        $artifactContent = \implode("\n", $pageObj->getPage($pid)['content']);
        $this->assertStringContainsString('/Artifact << /Type /Pagination /Subtype /Footer >> BDC', $artifactContent);
        $this->assertStringContainsString('q 0 0 1 rg Q' . "\n", $artifactContent);

        $taggedInvalid = $obj->exposeTagPdfUaTextContent('q 1 0 0 1 0 0 cm Q', $pid, 'fi');
        $this->assertSame('q 1 0 0 1 0 0 cm Q', $taggedInvalid);

        $logBefore = \json_encode($this->getObjectProperty($obj, 'pdfuaStructLog'), \JSON_THROW_ON_ERROR);
        $obj->exposeRegisterPdfUaAnnotation(0, $pid);
        $logAfter = \json_encode($this->getObjectProperty($obj, 'pdfuaStructLog'), \JSON_THROW_ON_ERROR);
        $this->assertSame($logBefore, $logAfter);
    }

    /** @throws \Throwable */
    public function testPdfUaFigureTaggingAddsAltOnOpenFigureAndParentKid(): void
    {
        $obj = new TestableText('mm', true, false, true, 'pdfua');
        $this->initFont($obj);
        $page = $obj->addPage();
        $pid = $this->requirePageId($page);

        $obj->beginStructElem('Figure', $pid);
        $obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'late alt');
        /** @var array<int, array<string, mixed>> $stack */
        $stack = $this->getObjectProperty($obj, 'pdfuaStructStack');
        $figureKey = \array_key_last($stack);
        $figureTop = \is_int($figureKey) && isset($stack[$figureKey]) ? $stack[$figureKey] : null;
        $this->assertIsArray($figureTop);
        $this->assertSame('late alt', $figureTop['alt'] ?? null);
        $obj->endStructElem();

        $obj->beginStructElem('P', $pid);
        $obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'child figure');
        /** @var array<int, array<string, mixed>> $stack */
        $stack = $this->getObjectProperty($obj, 'pdfuaStructStack');
        $parentKey = \array_key_last($stack);
        $parent = \is_int($parentKey) && isset($stack[$parentKey]) ? $stack[$parentKey] : null;
        $this->assertIsArray($parent);
        /** @var array<int, mixed> $kids */
        $kids = \is_array($parent['kids'] ?? null) ? $parent['kids'] : [];
        $this->assertNotEmpty($kids);

        $inlineNoNl = $obj->exposeTagPdfUaFigureContent('q Q', $pid, 'inline');
        $this->assertStringContainsString("q Q\nEMC\n", $inlineNoNl);
        $obj->endStructElem();
    }

    /** @throws \Throwable */
    public function testEndStructElemReturnsWhenTopIndexIsMissing(): void
    {
        $obj = new TestableText('mm', true, false, true, 'pdfua');
        /** @var array<int, array<string, mixed>|null> $sparse */
        $sparse = [
            5 => null,
        ];
        $this->setObjectProperty($obj, 'pdfuaStructStack', $sparse);
        $obj->endStructElem();
        $this->assertSame([], $this->getObjectProperty($obj, 'pdfuaStructLog'));
    }

    /** @throws \Throwable */
    public function testSplitLinesSkipsMissingWordEntriesViaReflection(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $obj->addPage();

        $method = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'splitLines');
        /** @var array<int, int> $ordarr */
        $ordarr = [65, 32, 66];
        /** @var array<string, mixed> $dim */
        $dim = [
            'chars' => 3,
            'spaces' => 1,
            'words' => 2,
            'totwidth' => 12.0,
            'totspacewidth' => 2.0,
            'split' => [
                1 => [
                    'pos' => 1,
                    'ord' => 32,
                    'septype' => 'WS',
                    'spaces' => 1,
                    'totwidth' => 6.0,
                    'totspacewidth' => 2.0,
                    'wordwidth' => 4.0,
                ],
            ],
        ];

        $this->assertIsArray($method->invoke($obj, $ordarr, $dim, 8.0, 0.0));
    }

    /** @throws \Throwable */
    public function testUnicodeJustifiedStringHandlesZeroFontSizeGuard(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initUnicodeFont($obj);
        $obj->addPage();
        $this->setObjectProperty($obj, 'isunicode', true);

        $markerMethod = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'getTextCellTruncationMarkerOrdArr');
        $this->assertSame([0x2026], $markerMethod->invoke($obj));

        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');
        /** @var int $pon */
        $pon = $this->getObjectProperty($obj, 'pon');
        $cur = $font->getCurrentFont();
        $font->cloneFont($pon, $cur['idx'], null, 0.0);

        [, $ordarr, $dim] = $obj->exposePrepareText("A \u{05D0} B", 'R');
        $just = $obj->exposeGetJustifiedString("A \u{05D0} B", $ordarr, $dim, 20.0);
        $this->assertStringContainsString('TJ', $just);
    }

    /** @throws \Throwable */
    public function testAddTextCellMultiBlockDrawCellUsesFillColorOnContinuationBorders(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $page = $obj->addPage([
            'region' => [
                ['RX' => 0.0, 'RY' => 0.0, 'RW' => 60.0, 'RH' => 4.0],
                ['RX' => 0.0, 'RY' => 6.0, 'RW' => 60.0, 'RH' => 4.0],
                ['RX' => 0.0, 'RY' => 12.0, 'RW' => 60.0, 'RH' => 4.0],
            ],
        ]);
        $pid = $this->requirePageId($page);

        $styles = [
            'all' => [
                'lineWidth' => 0.4,
                'lineColor' => '#111111',
                'fillColor' => '#00ff00',
                'lineCap' => 'butt',
                'lineJoin' => 'miter',
                'dashArray' => [],
                'dashPhase' => 0,
            ],
        ];

        $obj->addTextCell(
            'Long long long long long long long long long long long long long long long long long long long text',
            $pid,
            1,
            1,
            25,
            0,
            0,
            0,
            'T',
            'L',
            null,
            $styles,
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
        );

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        $content = \implode("\n", $pageObj->getPage($pid)['content']);
        $this->assertStringContainsString('0.000000 1.000000 0.000000 rg', $content);
    }

    /** @throws \Throwable */
    public function testAddTextCellSpansRegionsAndRestoresFontOutputSuffix(): void
    {
        $obj = $this->getInternalTestObject();
        $this->initFont($obj);
        $page = $obj->addPage([
            'region' => [
                ['RX' => 0.0, 'RY' => 0.0, 'RW' => 80.0, 'RH' => 6.0],
                ['RX' => 0.0, 'RY' => 8.0, 'RW' => 80.0, 'RH' => 6.0],
            ],
        ]);
        $pid = $this->requirePageId($page);

        /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
        $pageObj = $this->getObjectProperty($obj, 'page');
        $before = $pageObj->getPage($pid)['content'];
        $beforeCount = \count($before);

        $styles = [
            'all' => [
                'lineWidth' => 0.4,
                'lineCap' => 'butt',
                'lineJoin' => 'miter',
                'dashArray' => [],
                'dashPhase' => 0,
                'lineColor' => '#3a4a5a',
                'fillColor' => '#dddddd',
            ],
        ];

        $txt =
            'This is a long text block that must wrap across tiny regions to exercise multi-block handling.'
            . ' The renderer should continue to the next region and still close with restored font output.';

        $obj->addTextCell(
            $txt,
            $pid,
            1,
            1,
            78,
            0,
            0,
            0,
            'T',
            'J',
            null,
            $styles,
            0,
            0,
            0,
            0,
            true,
            true,
            false,
            false,
            false,
            false,
            false,
            true,
            '',
            null,
            'F',
        );

        /** @var array<int, string> $after */
        $after = $pageObj->getPage($pid)['content'];
        $this->assertGreaterThan($beforeCount, \count($after));
        $chunk = \implode('', \array_slice($after, $beforeCount));
        $this->assertStringContainsString('BT ', $chunk);

        /** @var array<int, array{x: float, y: float, w: float, h: float}> $cellBoxes */
        $cellBoxes = $this->getObjectProperty($obj, 'cellbbox');
        $this->assertGreaterThan(1, \count($cellBoxes));
    }

    /** @throws \Throwable */
    public function testGetOutTextStateOperatorTzEmitsPercentageFromStretchRatio(): void
    {
        $obj = $this->getInternalTestObject();

        // Stretching is a ratio (1.0 == 100%): ratio 1.0 emits no Tz.
        $this->assertSame('raw', $obj->exposeGetOutTextStateOperatorTz('raw', 1.0));

        // The PDF Tz operator is a percentage, so the ratio is multiplied by 100.
        $compressed = $obj->exposeGetOutTextStateOperatorTz('raw', 0.9);
        $this->assertStringContainsString('90.000000 Tz', $compressed);
        $this->assertStringContainsString('raw 100 Tz', $compressed);

        $this->assertStringContainsString('200.000000 Tz', $obj->exposeGetOutTextStateOperatorTz('raw', 2.0));
    }

    /** @throws \Throwable */
    public function testGetOutTextStateOperatorTcUsesPointsWithSign(): void
    {
        $obj = $this->getInternalTestObject();

        // Spacing is in points: 0 emits no Tc.
        $this->assertSame('raw', $obj->exposeGetOutTextStateOperatorTc('raw', 0.0));

        $positive = $obj->exposeGetOutTextStateOperatorTc('raw', 0.72);
        $this->assertStringContainsString('0.720000 Tc', $positive);
        $this->assertStringContainsString('raw 0 Tc', $positive);

        // Negative tracking must round-trip with its sign preserved.
        $this->assertStringContainsString('-0.720000 Tc', $obj->exposeGetOutTextStateOperatorTc('raw', -0.72));
    }

    /** @throws \Throwable */
    public function testStretchingRatioScalesLayoutWidthAndEmittedTz(): void
    {
        $obj = $this->getTestObject();
        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');
        /** @var int $pon */
        $pon = $this->getObjectProperty($obj, 'pon');
        $fontfile = (string) \realpath(__DIR__
        . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/core/helvetica.json');

        $font->insert($pon, 'helvetica', '', 11, 0.0, 1.0, $fontfile);
        $obj->addPage();
        $ordarr = \array_values($obj->uniconv->strToOrdArr('LEFT'));
        $widthNeutral = $font->getOrdArrWidth($ordarr);

        $font->insert($pon, 'helvetica', '', 11, 0.0, 0.9, $fontfile);
        $widthStretched = $font->getOrdArrWidth($ordarr);

        // Layout width tracks the ratio (90% of the unstretched width) ...
        $this->assertEqualsWithDelta($widthNeutral * 0.9, $widthStretched, 0.001);

        // ... and the rendered line emits the matching 90% Tz (not 0.9).
        $line = $obj->getTextLine(txt: 'LEFT', posx: 10, posy: 20);
        $this->assertStringContainsString('90.000000 Tz', $line);
    }

    /** @throws \Throwable */
    public function testGetJustifiedStringDividesWordSpacingByStretchRatio(): void
    {
        $width = 100.0;
        $txt = 'a b c';
        $dim = [
            'chars' => 5,
            'spaces' => 2,
            'totwidth' => 50.0,
            'totspacewidth' => 10.0,
            'words' => 3,
            'split' => [],
        ];

        $neutral = $this->justifyWordSpacing($txt, $dim, $width, 1.0);
        $stretched = $this->justifyWordSpacing($txt, $dim, $width, 1.1);

        // Tw is multiplied by the horizontal scale at render time, so the emitted
        // word spacing must be the neutral value divided by the stretching ratio.
        $this->assertEqualsWithDelta($neutral / 1.1, $stretched, 0.0001);
    }

    /**
     * Build a justified single line at the given stretching ratio (forcing the
     * Tw word-spacing path with a non-unicode core font) and return the emitted
     * Tw value in points.
     *
     * @param TTextDims $dim
     *
     * @throws \Throwable
     */
    private function justifyWordSpacing(string $txt, array $dim, float $width, float $stretching): float
    {
        $obj = new TestableText(isunicode: false);
        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');
        /** @var int $pon */
        $pon = $this->getObjectProperty($obj, 'pon');
        $fontfile = (string) \realpath(__DIR__
        . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/core/helvetica.json');
        $font->insert($pon, 'helvetica', '', 12, 0.0, $stretching, $fontfile);

        $ordarr = $obj->exposeStrToOrdArr($txt);
        $out = $obj->exposeGetJustifiedString($txt, $ordarr, $dim, $width);

        $matches = [];
        if (\preg_match('/(-?\d+\.\d+) Tw/', $out, $matches) !== 1) {
            $this->fail('Expected a Tw word-spacing operator, got: ' . $out);
        }

        $value = $matches[1] ?? '0';

        return \is_numeric($value) ? (float) $value : 0.0;
    }

    /**
     * A caller-supplied $linespace that exactly cancels the font height makes
     * the per-region line pitch zero. addTextCell must not divide by it
     * (regression: DivisionByZeroError). Using point units keeps the unit
     * conversion an identity so $linespace can cancel the font height exactly.
     *
     * @throws \Throwable
     */
    public function testAddTextCellLineSpaceCancelingFontHeightDoesNotDivideByZero(): void
    {
        $obj = new \Com\Tecnick\Pdf\Tcpdf('pt');
        $this->initFontAndPage($obj);

        /** @var \Com\Tecnick\Pdf\Font\Stack $font */
        $font = $this->getObjectProperty($obj, 'font');
        $curfont = $font->getCurrentFont();
        $fontHeight = $curfont['height'];
        $this->assertGreaterThan(0.0, $fontHeight);

        $obj->addTextCell(txt: 'Hello world', linespace: -$fontHeight);

        $this->assertNotSame('', $obj->getOutPDFString());
    }
}
