* @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 $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 $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 $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, 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}, layout: array{lines: array, 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 $ordarr * @phpstan-return array{fontchanged: bool, fontout: string, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array}, layout: array{lines: array, 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, 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}, layout: array{lines: array, 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 $ordarr * @phpstan-return array{fontchanged: bool, fontout: string, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array}, layout: array{lines: array, 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 $before */ $before = $pageObj->getPage($pid)['content']; $obj->addTextCell('Hello', $pid, 1, 2, 20, 6, 0, 0, 'T', 'L'); /** @var array $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 */ 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 * $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 $before */ $before = $pageObj->getPage($pid)['content']; $obj->exposeSetPageContext($pid); /** @var array $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 $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 $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} $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} $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('', $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 <>> BDC', $withActual); $this->assertStringContainsString('EMC', $withActual); $this->assertStringContainsString('/P <> 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 <> 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> $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 $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 <assertStringContainsString('EMC', $wrapped); $obj->beginStructElem('Figure', $pid, 'Open figure'); $obj->addTaggedFigureContent("q\nQ\n", $pid, 'ignored alt'); $obj->exposeRegisterPdfUaAnnotation(42, $pid); /** @var array> $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> $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> $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> $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> $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> $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> $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 $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|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 $ordarr */ $ordarr = [65, 32, 66]; /** @var array $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 $after */ $after = $pageObj->getPage($pid)['content']; $this->assertGreaterThan($beforeCount, \count($after)); $chunk = \implode('', \array_slice($after, $beforeCount)); $this->assertStringContainsString('BT ', $chunk); /** @var array $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()); } }