Hello
Hello
'; $dom = $obj->exposeGetHTMLDOM($html); $target = null; foreach ($dom as $node) { if ($node['value'] !== 'p') { continue; } $class = $this->getHtmlNodeAttrString($node, 'class'); if ($class === 'probe') { $target = $node; break; } } $this->assertNotNull($target); $this->assertSame(0.59, $target['font-size-adjust']); $this->assertSame('small-caps', $target['font-variant']); $this->assertSame(5, $target['orphans']); $this->assertSame('chapter', $target['page']); $this->assertSame('"[" "]"', $target['quotes']); $this->assertSame(4, $target['widows']); } /** * @throws \Throwable */ public function testGetHTMLFontMetricAppliesFontSizeAdjustAtRenderTime(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $root = $obj->exposeGetHTMLRootProperties(); $none = $root; $none['parent'] = 0; $none['value'] = 'span'; $none['fontsize'] = 12.0; $none['font-size-adjust'] = 'none'; $adjusted = $none; $adjusted['font-size-adjust'] = 0.58; $dom = [ $root, $none, $adjusted, ]; $metricNone = $obj->exposeGetHTMLFontMetricWithDom($dom, 1); $metricAdjusted = $obj->exposeGetHTMLFontMetricWithDom($dom, 2); $noneSize = 0.0; if (\is_numeric($metricNone['size'] ?? null)) { $noneSize = (float) $metricNone['size']; } $adjustedSize = 0.0; if (\is_numeric($metricAdjusted['size'] ?? null)) { $adjustedSize = (float) $metricAdjusted['size']; } $this->assertGreaterThan(0.0, $noneSize); $this->assertGreaterThan(0.0, $adjustedSize); $this->assertNotEqualsWithDelta($noneSize, $adjustedSize, 0.0001); } /** * @throws \Throwable */ public function testApplyHTMLFontVariantSmallCapsUppercasesText(): void { $obj = $this->getInternalTestObject(); $root = $obj->exposeGetHTMLRootProperties(); $smallcaps = $root; $smallcaps['parent'] = 0; $smallcaps['value'] = 'span'; $smallcaps['font-variant'] = 'small-caps'; $normal = $smallcaps; $normal['font-variant'] = 'normal'; $dom = [ $root, $smallcaps, $normal, ]; $input = 'Abc xyz'; $smallcapsOut = $obj->exposeApplyHTMLFontVariantWithDom($dom, 1, $input); $normalOut = $obj->exposeApplyHTMLFontVariantWithDom($dom, 2, $input); $this->assertSame('ABC XYZ', $smallcapsOut); $this->assertSame($input, $normalOut); } /** * @throws \Throwable */ public function testGetHTMLPseudoTextContentResolvesNestedCustomQuotes(): void { $obj = $this->getInternalTestObject(); $root = $obj->exposeGetHTMLRootProperties(); $node = $root; $node['parent'] = 0; $node['value'] = 'span'; $node['quotes'] = '"\\00003C\\00003C" "\\00003E\\00003E" "\\00003C" "\\00003E"'; $dom = [ $root, $node, ]; $style = 'content: open-quote "A" open-quote "B" close-quote close-quote;'; $resolved = $obj->exposeGetHTMLPseudoTextContentWithDom($dom, 1, $style); $this->assertSame('<>>', $resolved); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesDirectionModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['dir' => 'rtl']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'direction:ltr;'], 'dir' => '', ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'direction:inherit;'], 'dir' => '', ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'direction:invalid;'], 'dir' => 'ltr', ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 3, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('ltr', $dom[1]['dir']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('rtl', $dom[2]['dir']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('ltr', $dom[3]['dir']); } /** * @throws \Throwable */ public function testIsHTMLNodeInsideTheadHandlesMissingAndCyclicParents(): void { $obj = $this->getInternalTestObject(); $method = new \ReflectionMethod(\Com\Tecnick\Pdf\HTML::class, 'isHTMLNodeInsideThead'); $root = $obj->exposeGetHTMLRootProperties(); $dom = [ 0 => $root, 1 => $this->makeHtmlNode([ 'parent' => 0, 'opening' => true, 'tag' => true, 'value' => 'thead', ]), 2 => $this->makeHtmlNode([ 'parent' => 1, 'opening' => true, 'tag' => true, 'value' => 'tr', ]), 3 => $this->makeHtmlNode([ 'parent' => 2, 'opening' => true, 'tag' => true, 'value' => 'td', ]), 4 => $this->makeHtmlNode([ 'parent' => 4, 'opening' => true, 'tag' => true, 'value' => 'tbody', ]), ]; /** @var bool $insideThead */ $insideThead = $method->invokeArgs($obj, [&$dom, 3]); /** @var bool $missingNode */ $missingNode = $method->invokeArgs($obj, [&$dom, 99]); /** @var bool $cyclicParent */ $cyclicParent = $method->invokeArgs($obj, [&$dom, 4]); $this->assertTrue($insideThead); $this->assertFalse($missingNode); $this->assertFalse($cyclicParent); } /** * @throws \Throwable */ public function testRecomputeHTMLDOMCSSAgainstFinalTreeHandlesMissingRootAndSparseNodes(): void { $obj = $this->getInternalTestObject(); $method = new \ReflectionMethod(\Com\Tecnick\Pdf\HTML::class, 'recomputeHTMLDOMCSSAgainstFinalTree'); /** @var array' . $marker . '
' . $text . '
'; $obj->getHTMLCell($html, 20, 30, 20, 0); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); assert(isset($trace[0]), "\$trace[0] must be set"); $first = $trace[0]; $this->assertGreaterThan(0.0, $first['bbox_h']); // getTextCell reports the bbox of the last visual line when wrapping; // use vertical delta from input y to that last line to measure run height. $deltaY = $first['bbox_y'] - $first['in_y']; $this->assertGreaterThan( $first['font_size'] + 0.1, $deltaY, 'Expected wrapped text sample to span multiple lines.', ); return $deltaY; } /** * @throws \Throwable */ public function testGetHTMLCellNestedBreakBeforeLeftRightConsistency(): void { $left = $this->renderNestedBreakBeforeScenario('left', 'LEFT-NESTED-MARK'); $right = $this->renderNestedBreakBeforeScenario('right', 'RIGHT-NESTED-MARK'); $this->assertGreaterThan(1, $left['count']); $this->assertGreaterThan(1, $right['count']); $this->assertGreaterThan(1, $left['markerPage']); $this->assertGreaterThan(1, $right['markerPage']); $this->assertSame( 1, \abs((int) $left['count'] - (int) $right['count']), 'Nested break-before left/right should differ by exactly one parity-adjustment page.', ); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesParsesBorderCollapseModes(): void { $obj = $this->getTestObject(); /** @var arrayHello
', 0, 0, 20, 6); $this->assertNotSame('', $out); $this->assertStringContainsString('BT', $out); $this->assertStringContainsString('Hello', $out); } /** * @throws \Throwable */ public function testGetHTMLCellCreatesNamedDestinationFromIdAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('A
', 0, 0, 20, 8, $cell, $styles); $this->assertNotSame('', $out); $this->assertStringContainsString(' re', $out); } /** * @throws \Throwable */ public function testAddHTMLCellAppendsContentToCurrentPage(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $before = $page->getPage(); $beforeCount = \count($before['content']); $obj->addHTMLCell('AddedByMethod
', 0, 0, 30, 10); $after = $page->getPage(); $afterCount = \count($after['content']); $this->assertGreaterThan($beforeCount, $afterCount); $this->assertStringContainsString('AddedByMethod', \implode("\n", $after['content'])); } /** * @throws \Throwable */ public function testAddHTMLCellDrawsStyledOuterCell(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $cell = [ 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'borderpos' => \Com\Tecnick\Pdf\Base::BORDERPOS_DEFAULT, ]; $styles = [ 'all' => [ 'lineWidth' => 0.2, 'lineCap' => 'butt', 'lineJoin' => 'miter', 'miterLimit' => 10, 'dashArray' => [], 'dashPhase' => 0, 'lineColor' => 'black', 'fillColor' => '#eeeeee', ], ]; $obj->addHTMLCell('StyledAdd
', 0, 0, 0, 0, $cell, $styles); $after = $page->getPage(); $content = \implode("\n", $after['content']); $this->assertStringContainsString('StyledAdd', $content); $this->assertStringContainsString(' re', $content); } /** * @throws \Throwable */ public function testAddHTMLCellFlowsIntoSecondColumnRegionNotFirstColumn(): void { // Regression: after a region break, originx must be updated to the new // region's RX so content renders in the second column, not overlapping // the first column again. $obj = $this->getTestObject(); self::setUpFontsPath(); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); /** @var int $pon */ $pon = $this->getObjectProperty($obj, 'pon'); /** @var \Com\Tecnick\Pdf\Font\Stack $font */ $font = $this->getObjectProperty($obj, 'font'); $fontfile = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/core/helvetica.json'); $font->insert($pon, 'helvetica', '', 10, null, null, $fontfile); $leftMargin = 15.0; $rightMargin = 15.0; $topMargin = 20.0; $bottomMargin = 20.0; $columnGap = 8.0; $contentWidth = 210.0 - $leftMargin - $rightMargin; $contentHeight = 297.0 - $topMargin - $bottomMargin; $columnWidth = ($contentWidth - $columnGap) / 2.0; $obj->addPage([ 'margin' => [ 'PL' => $leftMargin, 'PR' => $rightMargin, 'CT' => $topMargin, 'CB' => $bottomMargin, ], 'region' => [ [ 'RX' => $leftMargin, 'RY' => $topMargin, 'RW' => $columnWidth, 'RH' => $contentHeight, ], [ 'RX' => $leftMargin + $columnWidth + $columnGap, 'RY' => $topMargin, 'RW' => $columnWidth, 'RH' => $contentHeight, ], ], ]); $chunk = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.' . ' Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
'; $html = \str_repeat($chunk, 60); // Enough content to overflow the first column and flow into the second. $obj->addHTMLCell($html, $leftMargin, $topMargin, $columnWidth, 0); $pages = $page->getPages(); $allContent = ''; foreach ($pages as $pdata) { $content = $pdata['content']; $allContent .= \implode("\n", $content); } // The second column's X is $leftMargin + $columnWidth + $columnGap ≈ 109 mm. // Convert to points to find PDF Td commands: 1mm ≈ 2.8346 pt. $col2x = ($leftMargin + $columnWidth + $columnGap) * 2.8346; $col2xMin = $col2x - 2.0; // After the fix, at least one text Td command must have an X component // inside the second column (x > col2xMin). Before the fix, all Td X // values stayed in the first column (around 42–72 pt). $tdMatches = []; \preg_match_all('/\b([\d.]+) [\d.-]+ Td\b/', $allContent, $tdMatches); $foundSecondCol = false; assert(isset($tdMatches[1]), "\$tdMatches[1] must be set"); foreach ($tdMatches[1] as $xVal) { if (\floatval($xVal) < $col2xMin) { continue; } $foundSecondCol = true; break; } $this->assertTrue( $foundSecondCol, 'Expected text to flow into the second column (X ≥ ' . \round($col2xMin, 1) . ' pt),' . ' but all Td X values stayed in the first column.', ); } /** * Regression fixture for the inline run-ascent leak. * * A paragraph that mixes a taller-than-body inline run (a bold 14pt lead-in * span) and an inline image inside justified 12pt body text used to let the * tall run's ascent leak into the body lines rendered before the image: those * lines were spaced at ~16.87pt (the leaked 13.5pt glyph box x 1.25) instead of * the correct 15pt (12pt x 1.25). The pitch only snapped back to 15pt at the * image. After the fix every body line is spaced by its own height, so the * spacing is uniform and no line carries the leaked ~16.87pt pitch. * * @throws \Throwable */ public function testAddHTMLCellDoesNotLeakInlineRunAscentIntoBodyLineSpacing(): void { $obj = $this->getTestObject(); self::setUpFontsPath(); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); /** @var int $pon */ $pon = $this->getObjectProperty($obj, 'pon'); /** @var \Com\Tecnick\Pdf\Font\Stack $font */ $font = $this->getObjectProperty($obj, 'font'); $fontout = $font->insert($pon, 'helvetica', '', 12); $img = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-image/test/images/200x100_RGBICC.jpg'); $this->assertNotSame('', $img, 'bundled test image must be present'); $obj->addPage([ 'margin' => ['PL' => 15.0, 'PR' => 15.0, 'CT' => 15.0, 'CB' => 15.0], 'format' => 'A4', ]); $page->addContent($fontout['out']); $lorem = \str_repeat('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod. ', 8); $html = ''
. 'TEST: '
. $lorem
. ' '
. $lorem
. '
Word' . \str_pad((string) $i, 3, '0', \STR_PAD_LEFT) . '
'; } $obj->addHTMLCell($html, $geo['leftMargin'], $geo['topMargin'], $geo['columnWidth'], 0); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $this->assertCount(1, $page->getPages(), 'Content must fit in the two column regions of a single page.'); $trace = $obj->exposeGetBBoxTrace(); $this->assertGreaterThan(1, \count($trace)); // Every fragment placed back at the region top after the first one // belongs to the second column: a fragment at the first column's X at // the region top means the break did not re-anchor the line origin. $foundSecondColumn = false; foreach ($trace as $idx => $row) { if ($row['in_x'] >= ($geo['col2x'] - 0.5)) { $foundSecondColumn = true; } if ($idx === 0 || $row['in_y'] > ($geo['topMargin'] + 1.0)) { continue; } $this->assertGreaterThanOrEqual( $geo['col2x'] - 0.5, $row['in_x'], 'Fragment "' . $row['txt'] . '" rendered at the region top must be in the second column.', ); } $this->assertTrue($foundSecondColumn, 'Expected content to flow into the second column.'); } /** * @throws \Throwable */ public function testAddHTMLCellAutoFlowSpansMultiplePages(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $beforePages = \count($page->getPages()); $chunk = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' . ' Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
'; $html = \str_repeat($chunk, 220); $obj->addHTMLCell($html, 20, 10, 150, 0); $afterPages = \count($page->getPages()); $this->assertGreaterThan($beforePages, $afterPages); } /** * @throws \Throwable */ public function testAddHTMLCellWithFixedHeightDoesNotAutoBreak(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $beforePages = \count($page->getPages()); $chunk = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' . ' Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
'; $html = \str_repeat($chunk, 220); $obj->addHTMLCell($html, 20, 10, 150, 30); $afterPages = \count($page->getPages()); $this->assertSame($beforePages, $afterPages); } /** * Add an A4 portrait page with 100mm top and bottom margins, leaving a * narrow central content band (y 100..197mm) so that content can be placed * in the bottom margin (below the content region). * * @throws \Throwable */ private function addBottomMarginTestPage(\Com\Tecnick\Pdf\Tcpdf $obj, bool $autobreak): void { $this->initFont($obj); $obj->addPage([ 'format' => 'A4', 'orientation' => 'P', 'autobreak' => $autobreak, 'margin' => [ 'PT' => 100.0, 'PB' => 100.0, 'CT' => 100.0, 'CB' => 100.0, ], ]); } /** * Concatenate the content streams of every page into a single string. * * @throws \Throwable */ private function collectPageContent(\Com\Tecnick\Pdf\Page\Page $pageObj): string { $content = ''; foreach ($pageObj->getPages() as $pdata) { $pageContent = $pdata['content']; $content .= "\n" . \implode("\n", $pageContent); } return $content; } /** * Return the top-edge Y coordinate (internal points, origin bottom-left) of * every rectangle emitted with the PDF `re` operator in a content stream. * Table border rectangles are drawn from the top edge with a negative * height, so the top edge is the larger of the two Y endpoints. * * @return listBOTTOM BORDER
text inside the bottom margin.
| Table Cell BOTTOM |
BOTTOM
| Table Cell BOTTOM |
BOTTOM
| Table Cell BOTTOM |
BOTTOM
| Table Cell BOTTOM |
BOTTOM
| Table Cell BOTTOM |
After list block
' . '| A | B |
|---|
| 1L ' . $spanWords . ' |
| 1C ' . $spanWords . ' |
| 1R ' . $spanWords . ' |
| 2L A1 example link ' . 'column span. ' . $plainWords . '. |
| 2C A1 example link ' . 'column span. ' . $plainWords . '. |
| 2R A1 example link ' . 'column span. ' . $plainWords . '. |
| 3L small text ' . $plainWords . ' |
| 3C small text ' . $plainWords . ' |
| 3R small text ' . $plainWords . ' |
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
'; $html = 'denounce
', 0, 0, 8, 0, $cell, []); $content = \implode("\n", $page->getPage()['content']); $this->assertStringContainsString('(de-) Tj', $content); $this->assertStringContainsString('(nounce) Tj', $content); $this->assertStringNotContainsString('(denounce) Tj', $content); } /** * @throws \Throwable */ public function testParseHTMLTextAppliesTextIndentOnlyOnFirstLine(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(20.0, 10.0, 80.0, 0.0); $obj->exposeResetBBoxTrace(); $elm = $this->makeHtmlNode([ 'value' => 'First line sample text', 'text-indent' => 6.0, 'align' => 'L', 'dir' => 'ltr', 'fontname' => 'helvetica', 'fontsize' => 10.0, ]); $tpx = 20.0; $tpy = 10.0; $tpw = 80.0; $tph = 0.0; $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $tpx = 20.0; $tpw = 80.0; $tpy += 6.0; $elm['value'] = 'Second line sample text'; $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $trace = $obj->exposeGetBBoxTrace(); $this->assertGreaterThanOrEqual(2, \count($trace)); assert(isset($trace[0]), "\$trace[0] must be set"); $firstX = $trace[0]['bbox_x']; assert(isset($trace[1]), "\$trace[1] must be set"); $secondX = $trace[1]['bbox_x']; $this->assertEqualsWithDelta(26.0, $firstX, 0.05); $this->assertEqualsWithDelta(20.0, $secondX, 0.05); } /** * @throws \Throwable */ public function testParseHTMLTextSupportsNegativeTextIndentHangingIndent(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(20.0, 10.0, 80.0, 0.0); $obj->exposeResetBBoxTrace(); $elm = $this->makeHtmlNode([ 'value' => 'Hanging indent sample text', 'text-indent' => -4.0, 'align' => 'L', 'dir' => 'ltr', 'fontname' => 'helvetica', 'fontsize' => 10.0, ]); $tpx = 20.0; $tpy = 10.0; $tpw = 80.0; $tph = 0.0; $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotEmpty($trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertEqualsWithDelta(16.0, $trace[0]['bbox_x'], 0.05); } /** * @throws \Throwable */ public function testAddHTMLCellTextIndentIsNotReappliedAfterPageBreak(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(20.0, 10.0, 80.0, 0.0); $obj->exposeResetBBoxTrace(); $elm = $this->makeHtmlNode([ 'value' => 'First line before forced page break', 'text-indent' => 6.0, 'align' => 'L', 'dir' => 'ltr', 'fontname' => 'helvetica', 'fontsize' => 10.0, ]); $tpx = 20.0; $tpy = 10.0; $tpw = 80.0; $tph = 0.0; $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $obj->exposeExecuteHTMLTcpdfPageBreak('true', $tpx, $tpw); $tpy += 6.0; $elm['value'] = 'Continuation after forced page break'; $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $trace = $obj->exposeGetBBoxTrace(); $this->assertGreaterThanOrEqual(2, \count($trace)); assert(isset($trace[0]), "\$trace[0] must be set"); $firstX = $trace[0]['bbox_x']; assert(isset($trace[1]), "\$trace[1] must be set"); $secondX = $trace[1]['bbox_x']; $this->assertEqualsWithDelta(26.0, $firstX, 0.05); $this->assertEqualsWithDelta(20.0, $secondX, 0.05); } /** * @throws \Throwable */ public function testParseHTMLListItemTextIndentAppliesToFirstLineOnly(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); // Use significantly longer text to guarantee wrapping with offset applied $html = 'A
', 0, 0, 20, 8, $nopad, []); $paddedOut = $obj->getHTMLCell('A
', 0, 0, 20, 8, $pad, []); $this->assertNotSame('', $plainOut); $this->assertNotSame('', $paddedOut); $this->assertNotSame($plainOut, $paddedOut); } /** * @throws \Throwable */ public function testGetHTMLCellWidthZeroUsesAvailableRegionWidthForStyledCell(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $cell = [ 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'borderpos' => \Com\Tecnick\Pdf\Base::BORDERPOS_DEFAULT, ]; $styles = [ 'all' => [ 'lineWidth' => 0.2, 'lineCap' => 'butt', 'lineJoin' => 'miter', 'miterLimit' => 10, 'dashArray' => [], 'dashPhase' => 0, 'lineColor' => 'black', 'fillColor' => '#eeeeee', ], ]; $out = $obj->getHTMLCell('A
', 0, 0, 0, 8, $cell, $styles); $this->assertNotSame('', $out); $matches = []; \preg_match('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re/s', $out, $matches); $this->assertNotEmpty($matches); assert(isset($matches[3]), "\$matches[3] must be set"); $this->assertGreaterThan(0.0, \abs(\floatval($matches[3]))); } /** * @throws \Throwable */ public function testGetHTMLCellCoversAllSupportedTagsWithoutErrors(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '' . '' . 'BEFIq' . '
psubsup
' . 'pre' . '
| H |
|---|
| T |
| X |
| A | B |
| A | B |
| A |
| A |
| A |
| A |
| A |
| ' . 'Alfa Bravo Charlie Delta ' . 'Echo Foxtrot Golf Hotel' . ' |
| ' . '1R Alfa Bravo Charlie Delta ' . 'Echo Foxtrot Golf Hotel India ' . 'Juliett Kilo Lima Mike November ' . 'Oscar Papa Quebec Romeo Sierra ' . 'Tango Uniform Victor Whiskey Xray ' . 'Yankee Zulu' . ' |
| Gesch\u{00E4}ftsf\u{00FC}hrer Egon Schrempp Amtsgericht Stuttgart HRB 1234 | ' . 'RIGHTCOL | ' . '
| ' . '3X small text Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett ' . 'Kilo Lima Mike November Oscar Papa Quebec Romeo Sierra Tango Uniform Victor Whiskey Xray ' . 'Yankee Zulu' . ' |
| 3L small text' . ' Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India ' . 'Juliett Kilo Lima Mike November Oscar Papa Quebec Romeo Sierra Tango Uniform Victor Whiskey Xray ' . 'Yankee Zulu |
| 3C small text' . ' Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India ' . 'Juliett Kilo Lima Mike November Oscar Papa Quebec Romeo Sierra Tango Uniform Victor Whiskey Xray ' . 'Yankee Zulu |
| 3R small text' . ' Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India ' . 'Juliett Kilo Lima Mike November Oscar Papa Quebec Romeo Sierra Tango Uniform Victor Whiskey Xray ' . 'Yankee Zulu |
Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett Kilo. ' . 'Lima Mike November Oscar Papa Quebec Romeo (Sierra-Tango) Uniform Victor ' . 'Whiskey (Xray-Yankee). Zulu.
'; $obj->exposeResetBBoxTrace(); $obj->getHTMLCell($html, 20, 100, 180, 0); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); $sierraIdx = null; $xrayIdx = null; foreach ($trace as $idx => $entry) { $txt = $entry['txt']; if ($sierraIdx === null && \str_contains($txt, 'Sierra-Tango')) { $sierraIdx = $idx; } if ($xrayIdx === null && \str_contains($txt, 'Xray-Yankee')) { $xrayIdx = $idx; } } $this->assertNotNull($sierraIdx, 'Sierra-Tango fragment must be present in the trace'); $this->assertNotNull($xrayIdx, 'Xray-Yankee fragment must be present in the trace'); $this->assertGreaterThan(0, (int) $sierraIdx); $prevEntry = $this->getTraceRow($trace, (int) $sierraIdx - 1); $sierraEntry = $this->getTraceRow($trace, (int) $sierraIdx); $xrayEntry = $this->getTraceRow($trace, (int) $xrayIdx); // Em fragment must continue on the same visual line as the "(" prefix // produced by the previous wrapped fragment, not on a new line below. $this->assertEqualsWithDelta( $prevEntry['bbox_y'], $sierraEntry['bbox_y'], 0.01, 'Em fragment "Sierra-Tango" must stay on the same line as the preceding "(" prefix.', ); $this->assertGreaterThanOrEqual( $prevEntry['bbox_end_x'] - 0.01, $sierraEntry['bbox_x'], 'Em fragment "Sierra-Tango" must continue right after the preceding "(" prefix.', ); // The whole paragraph should fit on two visual lines: every fragment's // bbox_y reports the y of the last visual line touched by getTextCell, // so for a 2-line paragraph all five fragments share the same y. /** @var arrayD1_DEFAULT
D2_DEFAULT
D3_DEFAULT
N1_NORMAL
N2_NORMAL
N3_NORMAL
H1_100
H2_100
H3_100
H1_050
H2_050
H3_050
H1_150
H2_150
H3_150
This document demonstrates PDF encryption and permission controls using tc-lib-pdf. ' . 'The file is protected with a user password (demo-user) and an owner password ' . '(demo-owner). Encryption restricts unauthorized access while the owner password ' . 'grants full control.
'; $obj->exposeResetBBoxTrace(); $obj->getHTMLCell($html, 20, 100, 180, 0); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); // Find the demo-owner em fragment and the immediately following plain // continuation that starts with ")". $ownerIdx = null; foreach ($trace as $idx => $entry) { if (!\str_contains($entry['txt'], 'demo-owner')) { continue; } $ownerIdx = $idx; break; } $this->assertNotNull($ownerIdx, 'demo-owner fragment must be present in the trace.'); $this->assertArrayHasKey( (int) $ownerIdx + 1, $trace, 'Continuation fragment after demo-owner must be present.', ); $ownerEntry = $this->getTraceRow($trace, (int) $ownerIdx); $contEntry = $this->getTraceRow($trace, (int) $ownerIdx + 1); // The continuation MUST start with the closing parenthesis "glued" to // demo-owner: it must be passed to getTextCell with the same in_y as // demo-owner (i.e., on the same line cursor) so that its leading ")" // is rendered right after the em fragment, not on a fresh new line. $this->assertStringStartsWith( ')', \ltrim($contEntry['txt']), 'Continuation fragment must start with the closing parenthesis ").".', ); $this->assertEqualsWithDelta( $ownerEntry['in_y'], $contEntry['in_y'], 0.01, 'Closing ")" after demo-owner must stay on the same line cursor as demo-owner.', ); } /** * @throws \Throwable */ public function testParseHTMLTextWrapsLargeInlineFragmentBeforeItOverflowsRemainingWidth(): void { $measure = $this->getBBoxProbeTestObject(); $this->initFontAndPage($measure); $measure->exposeInitHTMLCellContext(0, 0, 200, 0); $prefixElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'self' => false, 'value' => 'medium ', ]); $largeElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'self' => false, 'fontsize' => 12.0, 'value' => 'large', ]); $tpx = 0.0; $tpy = 0.0; $tpw = 200.0; $tph = 0.0; $measure->exposeResetBBoxTrace(); $measure->exposeParseHTMLText($prefixElm, $tpx, $tpy, $tpw, $tph); $prefixTrace = $measure->exposeGetBBoxTrace(); assert(isset($prefixTrace[0]), "\$prefixTrace[0] must be set"); $prefixWidth = $prefixTrace[0]['bbox_w']; $tpx = 0.0; $tpy = 0.0; $tpw = 200.0; $tph = 0.0; $measure->exposeResetBBoxTrace(); $measure->exposeParseHTMLText($largeElm, $tpx, $tpy, $tpw, $tph); $largeTrace = $measure->exposeGetBBoxTrace(); assert(isset($largeTrace[0]), "\$largeTrace[0] must be set"); $largeWidth = $largeTrace[0]['bbox_w']; $cellWidth = $prefixWidth + $largeWidth - 0.1; $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0, 0, $cellWidth, 0); $obj->exposeResetBBoxTrace(); $tpx = 0.0; $tpy = 0.0; $tpw = $cellWidth; $tph = 0.0; $obj->exposeParseHTMLText($prefixElm, $tpx, $tpy, $tpw, $tph); $obj->exposeParseHTMLText($largeElm, $tpx, $tpy, $tpw, $tph); $trace = $obj->exposeGetBBoxTrace(); $this->assertCount(2, $trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertSame('medium ', $trace[0]['txt']); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertSame('large', $trace[1]['txt']); $this->assertGreaterThan(0.0, $trace[0]['bbox_x'] + $trace[0]['bbox_w']); $this->assertEqualsWithDelta(0.0, $trace[1]['bbox_x'], 1e-9); $this->assertLessThanOrEqual($cellWidth + 1e-9, $trace[1]['bbox_end_x']); } /** * @throws \Throwable */ public function testParseHTMLTextKeepsBreakableFragmentOnCurrentLineWhenOnlyTailOverflows(): void { $measure = $this->getBBoxProbeTestObject(); $this->initFontAndPage($measure); $measure->exposeInitHTMLCellContext(0, 0, 300, 0); $prefixElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'self' => false, 'value' => 'A1 example link', ]); $breakableElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'self' => false, 'value' => ' column span one two three four five six seven eight nine ten', ]); $firstChunkElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'self' => false, 'value' => ' column span', ]); $tpx = 0.0; $tpy = 0.0; $tpw = 300.0; $tph = 0.0; $measure->exposeResetBBoxTrace(); $measure->exposeParseHTMLText($prefixElm, $tpx, $tpy, $tpw, $tph); $prefixTrace = $measure->exposeGetBBoxTrace(); assert(isset($prefixTrace[0]), "\$prefixTrace[0] must be set"); $prefixWidth = $prefixTrace[0]['bbox_w']; $tpx = 0.0; $tpy = 0.0; $tpw = 300.0; $tph = 0.0; $measure->exposeResetBBoxTrace(); $measure->exposeParseHTMLText($breakableElm, $tpx, $tpy, $tpw, $tph); $breakableTrace = $measure->exposeGetBBoxTrace(); assert(isset($breakableTrace[0]), "\$breakableTrace[0] must be set"); $breakableWidth = $breakableTrace[0]['bbox_w']; $tpx = 0.0; $tpy = 0.0; $tpw = 300.0; $tph = 0.0; $measure->exposeResetBBoxTrace(); $measure->exposeParseHTMLText($firstChunkElm, $tpx, $tpy, $tpw, $tph); $chunkTrace = $measure->exposeGetBBoxTrace(); assert(isset($chunkTrace[0]), "\$chunkTrace[0] must be set"); $chunkWidth = $chunkTrace[0]['bbox_w']; $cellWidth = $prefixWidth + $chunkWidth + 0.2; $cellWidth = \min($cellWidth, $prefixWidth + $breakableWidth - 0.1); $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0, 0, $cellWidth, 0); $obj->exposeResetBBoxTrace(); $tpx = 0.0; $tpy = 0.0; $tpw = $cellWidth; $tph = 0.0; $obj->exposeParseHTMLText($prefixElm, $tpx, $tpy, $tpw, $tph); $obj->exposeParseHTMLText($breakableElm, $tpx, $tpy, $tpw, $tph); $trace = $obj->exposeGetBBoxTrace(); $this->assertCount(2, $trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertSame('A1 example link', $trace[0]['txt']); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertSame(' column span one two three four five six seven eight nine ten', $trace[1]['txt']); $this->assertEqualsWithDelta(0.0, $trace[1]['bbox_x'], 1e-9); $this->assertGreaterThan($trace[0]['bbox_y'], $trace[1]['bbox_y']); } /** * @throws \Throwable */ public function testParseHTMLTextTreatsLeadingSpaceLongWordAsUnbreakableForPreWrap(): void { $measure = $this->getBBoxProbeTestObject(); $this->initFontAndPage($measure); $measure->exposeInitHTMLCellContext(0, 0, 220, 0); $prefixElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'self' => false, 'value' => 'prefix ', ]); $wordElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'self' => false, 'value' => ' thisisanotherverylongword', ]); $tpx = 0.0; $tpy = 0.0; $tpw = 220.0; $tph = 0.0; $measure->exposeResetBBoxTrace(); $measure->exposeParseHTMLText($prefixElm, $tpx, $tpy, $tpw, $tph); $prefixTrace = $measure->exposeGetBBoxTrace(); assert(isset($prefixTrace[0]), "\$prefixTrace[0] must be set"); $prefixWidth = $prefixTrace[0]['bbox_w']; $tpx = 0.0; $tpy = 0.0; $tpw = 220.0; $tph = 0.0; $measure->exposeResetBBoxTrace(); $measure->exposeParseHTMLText($wordElm, $tpx, $tpy, $tpw, $tph); $wordTrace = $measure->exposeGetBBoxTrace(); assert(isset($wordTrace[0]), "\$wordTrace[0] must be set"); $wordWidth = $wordTrace[0]['bbox_w']; $cellWidth = $prefixWidth + $wordWidth - 0.1; $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0, 0, $cellWidth, 0); $obj->exposeResetBBoxTrace(); $tpx = 0.0; $tpy = 0.0; $tpw = $cellWidth; $tph = 0.0; $obj->exposeParseHTMLText($prefixElm, $tpx, $tpy, $tpw, $tph); $obj->exposeParseHTMLText($wordElm, $tpx, $tpy, $tpw, $tph); $trace = $obj->exposeGetBBoxTrace(); $this->assertCount(2, $trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertSame('prefix ', $trace[0]['txt']); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertSame(' thisisanotherverylongword', $trace[1]['txt']); $this->assertEqualsWithDelta(0.0, $trace[1]['bbox_x'], 1e-9); $this->assertGreaterThan($trace[0]['bbox_y'], $trace[1]['bbox_y']); } /** * @throws \Throwable */ public function testAllParseHTMLTagMethodsCanBeInvoked(): void { $probe = $this->getInternalTestObject(); $methods = $probe->exposeParseHTMLTagMethods(); $this->assertGreaterThanOrEqual(100, \count($methods)); foreach ($methods as $method) { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $obj->exposeGetHTMLRootProperties(); $tag = \preg_replace('/^parseHTMLTag(?:OPEN|CLOSE)/', '', $method) ?? ''; $elm['value'] = \strtolower($tag); $elm['attribute'] = []; if ($method === 'parseHTMLTagOPENa') { $elm['attribute'] = ['href' => 'https://example.com']; } if ($method === 'parseHTMLTagOPENimg') { $elm['attribute'] = ['alt' => 'img']; } if ($method === 'parseHTMLTagOPENinput') { $elm['attribute'] = ['value' => 'v']; } if ($method === 'parseHTMLTagOPENoption') { $elm['attribute'] = ['value' => 'v']; } if ($method === 'parseHTMLTagOPENoutput') { $elm['attribute'] = ['value' => 'o']; } if ($method === 'parseHTMLTagOPENselect') { $elm['attribute'] = ['opt' => 'v#!TaB!#Label#!NwL!#', 'value' => 'v']; } if ($method === 'parseHTMLTagOPENtextarea') { $elm['attribute'] = ['value' => 'txt']; } if ($method === 'parseHTMLTagOPENtcpdf') { $elm['attribute'] = ['method' => 'noop']; } $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod($method, $elm, $tpx, $tpy, $tpw, $tph); } } /** * @throws \Throwable */ public function testParseHTMLTagOpenSpanAppliesColorAttributes(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $obj->exposeGetHTMLRootProperties(); $elm['value'] = 'span'; $elm['fgcolor'] = 'black'; $elm['bgcolor'] = '#ffff00'; $elm['attribute'] = [ 'color' => '#ff0000', 'bgcolor' => '#00ff00', ]; $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENspan', $elm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); assert(isset($hrc['dom'][0]), "\$hrc['dom'][0] must be set"); $this->assertStringContainsString('100%,0%,0%', $hrc['dom'][0]['fgcolor']); $this->assertStringContainsString('0%,100%,0%', (string) $hrc['dom'][0]['bgcolor']); } /** * @throws \Throwable */ public function testParseHTMLTagTheadOpenCloseManageTableStack(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $obj->exposeGetHTMLRootProperties(); $elm['value'] = 'thead'; $elm['cols'] = 2; $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENthead', $elm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $stack = $hrc['tablestack']; $this->assertIsArray($stack); $this->assertCount(1, $stack); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEthead', $elm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $stack = $hrc['tablestack']; $this->assertIsArray($stack); $this->assertCount(0, $stack); } /** * @throws \Throwable */ public function testGetHTMLCellCreatesLinkAnnotationForAnchorText(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('Click', 0, 0, 30, 10); $this->assertNotSame('', $out); $annotation = $this->getAnnotationList($obj); $haslink = false; foreach ($annotation as $annot) { $txt = $this->getMapString($annot, 'txt'); $opt = $this->getAnnotationOptMap($annot); if ($txt === '') { continue; } if ($txt === 'https://example.com' && $this->getMapString($opt, 'subtype') === 'Link') { $haslink = true; break; } } $this->assertTrue($haslink); } /** * @throws \Throwable */ public function testGetHTMLCellAnchorDoesNotLeakToFollowingText(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('AB', 0, 0, 30, 10); $this->assertNotSame('', $out); $annotation = $this->getAnnotationList($obj); $this->assertCount(1, $annotation); } /** * @throws \Throwable */ public function testGetHTMLCellAnchorSurvivesTextareaCloseTag(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('AB', 0, 0, 30, 10); $this->assertNotSame('', $out); $annotation = $this->getAnnotationList($obj); // 2 link annotations + 1 textarea form-field annotation $this->assertCount(3, $annotation); } /** * @throws \Throwable */ public function testGetHTMLCellAnchorSurvivesSelectCloseTag(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('AB', 0, 0, 30, 10); $this->assertNotSame('', $out); $annotation = $this->getAnnotationList($obj); // 2 link annotations + 1 select form-field annotation $this->assertCount(3, $annotation); } /** * @throws \Throwable */ public function testGetHTMLCellAnchorSurvivesDelTag(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('