* @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; use PHPUnit\Framework\Attributes\DataProvider; /** * @phpstan-import-type THTMLAttrib from \Com\Tecnick\Pdf\HTML * @phpstan-import-type THTMLTableState from \Com\Tecnick\Pdf\HTML */ class HTMLTest extends TestUtil { public static function setUpBeforeClass(): void { self::setUpFontsPath(); } /** * @throws \Throwable */ protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf { return new \Com\Tecnick\Pdf\Tcpdf(); } /** * @throws \Throwable */ protected function getInternalTestObject(): TestableHTML { return new TestableHTML(); } /** * @throws \Throwable */ protected function getNobrProbeTestObject(): TestableHTMLNobrProbe { return new TestableHTMLNobrProbe(); } /** * @throws \Throwable */ protected function getBBoxProbeTestObject(): TestableHTMLBBoxProbe { return new TestableHTMLBBoxProbe(); } /** * @param array $overrides * * @phpstan-return THTMLAttrib */ private function makeHtmlNode(array $overrides = []): array { $node = [ 'align' => '', 'attribute' => [], 'caption-side' => 'top', 'clear' => 'none', 'bgcolor' => '', 'block' => false, 'border' => [], 'border-collapse' => 'separate', 'border-spacing' => ['H' => 0.0, 'V' => 0.0], 'clip' => false, 'cols' => 0, 'content' => '', 'cssdata' => [], 'csssel' => [], 'dir' => 'ltr', 'display' => 'inline', 'empty-cells' => 'show', 'elkey' => 0, 'fgcolor' => 'black', 'fill' => false, 'font-size-adjust' => 'none', 'font-stretch' => 100.0, 'font-variant' => 'normal', 'fontname' => 'helvetica', 'fontsize' => 10.0, 'fontstyle' => '', 'height' => 0.0, 'hide' => false, 'letter-spacing' => 0.0, 'line-height' => 1.0, 'list-style-position' => 'outside', 'listtype' => '', 'float' => 'none', 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'opening' => false, 'orphans' => 2, 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'page' => 'auto', 'parent' => 0, 'position' => 'static', 'quotes' => 'auto', 'rows' => 0, 'self' => false, 'stroke' => 0.0, 'strokecolor' => '', 'table-layout' => 'auto', 'style' => [], 'tag' => true, 'text-indent' => 0.0, 'text-transform' => '', 'thead' => '', 'trids' => [], 'valign' => 'top', 'value' => '', 'white-space' => 'normal', 'widows' => 2, 'word-spacing' => 0.0, 'width' => 0.0, 'x' => 0.0, 'y' => 0.0, ]; /** @var THTMLAttrib */ return \array_replace($node, $overrides); } /** @return array{buffer: string} */ private function makeHtmlCellContext(string $buffer = ''): array { return ['buffer' => $buffer]; } /** * @param array $overrides * @phpstan-return THTMLTableState */ private function makeHtmlTableState(array $overrides = []): array { $state = [ 'cellpadding' => 0.0, 'cells' => [], 'cellspacingh' => 0.0, 'cellspacingv' => 0.0, 'colindex' => 0, 'collapse' => false, 'cols' => 0, 'colwidth' => 0.0, 'colwidths' => [], 'dir' => 'ltr', 'hascellborders' => false, 'occupied' => [], 'originx' => 0.0, 'originy' => 0.0, 'prevrowbottom' => [], 'regionoffset' => 0.0, 'rowheight' => 0.0, 'rowspans' => [], 'rowtop' => 0.0, 'width' => 0.0, ]; /** @var THTMLTableState */ return \array_replace($state, $overrides); } /** * @param THTMLAttrib $node */ private function getHtmlNodeAttrString(array $node, string $key): ?string { $value = $node['attribute'][$key] ?? null; return \is_string($value) ? $value : null; } /** * @param THTMLAttrib $node * @return array */ private function getHtmlNodeAttrMap(array $node, string $key): array { $value = $node['attribute'][$key] ?? null; if (!\is_array($value)) { return []; } return $value; } /** * @param THTMLAttrib $node */ private function getHtmlNodeStyleString(array $node, string $key): ?string { $value = $node['style'][$key] ?? null; return \is_string($value) ? $value : null; } /** * @param THTMLAttrib $node */ private function getHtmlNodeMargin(array $node, string $side): float { return $node['margin'][$side] ?? 0.0; } /** * @param THTMLAttrib $node * @return array{H: float, V: float} */ private function getHtmlNodeBorderSpacing(array $node): array { $spacing = $node['border-spacing'] ?? null; if (!\is_array($spacing)) { return ['H' => 0.0, 'V' => 0.0]; } return [ 'H' => $spacing['H'], 'V' => $spacing['V'], ]; } /** * @param THTMLAttrib $node * @return array> */ private function getHtmlNodeBorders(array $node): array { return $node['border']; } /** * @param THTMLAttrib $node * @return array */ private function getHtmlNodeBorderSide(array $node, string $side): array { $borders = $this->getHtmlNodeBorders($node); $border = $borders[$side] ?? null; if (!\is_array($border)) { $this->fail('Missing border side: ' . $side); } return $border; } /** * @param mixed $value * @return array */ private function requireMixedArray(mixed $value, string $error): array { if (!\is_array($value)) { $this->fail($error); } return $value; } /** @return array> */ private function getObjectArrayProperty(object $obj, string $property): array { $rawValue = $this->requireMixedArray( $this->getObjectProperty($obj, $property), 'Expected array property: ' . $property, ); /** @var array> $entries */ $entries = \array_values(\array_filter($rawValue, static fn(mixed $entry): bool => \is_array($entry))); /** @var array> $typed */ $typed = []; foreach ($entries as $entry) { $row = []; foreach (\array_keys($entry) as $key) { if (!\is_string($key)) { continue; } $row[$key] = $entry[$key] ?? null; } $typed[] = $row; } return $typed; } /** * @return array}> */ private function getFormAnnotations(\Com\Tecnick\Pdf\Tcpdf $obj): array { $raw = $this->getObjectArrayProperty($obj, 'annotation'); /** @var array}> $annotations */ $annotations = []; foreach ($raw as $entry) { if (!isset($entry['opt']) || !\is_array($entry['opt'])) { continue; } $rawOpt = $entry['opt']; $typedOpt = []; foreach (\array_keys($rawOpt) as $optKey) { if (!\is_string($optKey)) { continue; } $typedOpt[$optKey] = $rawOpt[$optKey] ?? null; } $annotations[] = ['opt' => $typedOpt]; } return $annotations; } /** * @param THTMLAttrib $node */ private function getHtmlNodeBoxSide(array $node, string $box, string $side): float { $boxData = $node[$box] ?? null; if (!\is_array($boxData) || !isset($boxData[$side]) || !\is_float($boxData[$side])) { $this->fail('Missing numeric ' . $box . '[' . $side . '] value.'); } return $boxData[$side]; } /** * @param array}> $annotations * @return array */ private function getFormFieldTypes(array $annotations): array { $types = []; foreach ($annotations as $annotation) { if (!(isset($annotation['opt']['ft']) && \is_string($annotation['opt']['ft']))) { continue; } $types[] = $annotation['opt']['ft']; } return $types; } /** * @param array}> $annotations * @return array */ private function getLastFormAnnotationOpt(array $annotations): array { $last = $annotations[\count($annotations) - 1] ?? null; if ($last === null) { $this->fail('Expected at least one annotation.'); } return $last['opt']; } /** * @return array> */ private function getAnnotationList(object $obj): array { $raw = $this->getObjectArrayProperty($obj, 'annotation'); if ($raw === []) { $this->fail('Expected non-empty annotation array.'); } /** @var array> $annotations */ $annotations = []; foreach ($raw as $entry) { $typed = []; foreach (\array_keys($entry) as $key) { $typed[$key] = $entry[$key] ?? null; } $annotations[] = $typed; } if ($annotations === []) { $this->fail('Expected at least one typed annotation entry.'); } return $annotations; } /** * @param array $annotation * @return array */ private function getAnnotationOptMap(array $annotation): array { if (!isset($annotation['opt']) || !\is_array($annotation['opt'])) { $this->fail('Expected annotation option map.'); } $opt = $annotation['opt']; /** @var array $typed */ $typed = []; foreach (\array_keys($opt) as $key) { if (!\is_string($key)) { continue; } $typed[$key] = $opt[$key] ?? null; } return $typed; } /** * @return array */ private function getLastAnnotationOptFromObject(object $obj): array { $annotations = $this->getAnnotationList($obj); $last = $annotations[\count($annotations) - 1] ?? null; if ($last === null) { $this->fail('Expected annotation.'); } return $this->getAnnotationOptMap($last); } /** @param array $map */ private function getMapString(array $map, string $key): string { return isset($map[$key]) && \is_string($map[$key]) ? $map[$key] : ''; } /** @param array $map */ private function getMapInt(array $map, string $key): int { return isset($map[$key]) && \is_int($map[$key]) ? $map[$key] : 0; } /** * @param array $map * @return array */ private function getMapIntList(array $map, string $key): array { if (!isset($map[$key]) || !\is_array($map[$key])) { return []; } $value = $map[$key]; return \array_values(\array_filter($value, static fn(mixed $item): bool => \is_int($item))); } /** * @param mixed $value * @return array */ private function extractComboBoxLabels(mixed $value): array { if (!\is_array($value)) { return []; } $labels = \array_map(static function (mixed $item): ?string { if (\is_array($item)) { return isset($item[1]) && \is_string($item[1]) ? $item[1] : null; } return \is_string($item) ? $item : null; }, \array_values($value)); return \array_values(\array_filter($labels, static fn(?string $label): bool => $label !== null)); } /** * @param array $lineboxes * @return array{left: float, right: float} */ private function getLineBox(array $lineboxes, int $idx): array { $linebox = $lineboxes[$idx] ?? null; if (!\is_array($linebox)) { $this->fail('Missing line box at index ' . (string) $idx); } $left = $linebox['left'] ?? null; $right = $linebox['right'] ?? null; if (!\is_float($left) || !\is_float($right)) { $this->fail('Invalid line box values at index ' . (string) $idx); } return ['left' => $left, 'right' => $right]; } /** * @param array> $trace * * @return array{bbox_end_x: float, bbox_h: float, bbox_w: float, bbox_x: float, bbox_y: float, font_size: float, in_x: float, in_y: float, txt: string} */ private function getTraceRow(array $trace, int $idx): array { $row = $trace[$idx] ?? null; if (!\is_array($row)) { $this->fail('Missing trace row at index ' . (string) $idx); } if ( !isset( $row['bbox_end_x'], $row['bbox_h'], $row['bbox_w'], $row['bbox_x'], $row['bbox_y'], $row['font_size'], $row['in_x'], $row['in_y'], $row['txt'], ) || !\is_float($row['bbox_end_x']) || !\is_float($row['bbox_h']) || !\is_float($row['bbox_w']) || !\is_float($row['bbox_x']) || !\is_float($row['bbox_y']) || !\is_float($row['font_size']) || !\is_float($row['in_x']) || !\is_float($row['in_y']) || !\is_string($row['txt']) ) { $this->fail('Invalid trace row shape at index ' . (string) $idx); } return [ 'bbox_end_x' => $row['bbox_end_x'], 'bbox_h' => $row['bbox_h'], 'bbox_w' => $row['bbox_w'], 'bbox_x' => $row['bbox_x'], 'bbox_y' => $row['bbox_y'], 'font_size' => $row['font_size'], 'in_x' => $row['in_x'], 'in_y' => $row['in_y'], 'txt' => $row['txt'], ]; } /** * @param array> $trace * * @throws \RuntimeException */ private function getTraceTokenBBoxY(array $trace, #[\SensitiveParameter] string $token): float { foreach (\array_keys($trace) as $idx) { $row = $this->getTraceRow($trace, $idx); if (!\hash_equals(\trim($row['txt']), $token)) { continue; } return $row['bbox_y']; } throw new \RuntimeException('Missing trace token: ' . $token); } /** * @throws \Throwable */ public function testStrTrimHelpers(): void { $obj = $this->getTestObject(); $this->assertSame('abc ', $obj->strTrimLeft(' abc ')); $this->assertSame(' abc', $obj->strTrimRight(' abc ')); $this->assertSame('abc', $obj->strTrim(' abc ')); $this->assertSame('-abc-', $obj->strTrim(' abc ', '-')); } /** * @throws \Throwable */ public function testSetULLIDotUsesDefaultsAndCustomImageValue(): void { $obj = $this->getTestObject(); $obj->setULLIDot('disc'); $this->assertSame('disc', $this->getObjectProperty($obj, 'ullidot')); $obj->setULLIDot('invalid-bullet'); $this->assertSame('!', $this->getObjectProperty($obj, 'ullidot')); $obj->setULLIDot('img|png|4|4|bullet.png'); $this->assertSame('img|png|4|4|bullet.png', $this->getObjectProperty($obj, 'ullidot')); } /** * @throws \Throwable */ public function testHrcReferenceParameterIsFirstWhenPresent(): void { $ref = new \ReflectionClass(\Com\Tecnick\Pdf\HTML::class); foreach ($ref->getMethods(\ReflectionMethod::IS_PROTECTED) as $method) { $params = $method->getParameters(); foreach ($params as $idx => $param) { if ($param->getName() !== 'hrc') { continue; } $ptype = $param->getType(); if (!$ptype instanceof \ReflectionNamedType) { continue; } if ($ptype->getName() !== 'array' || !$param->isPassedByReference()) { continue; } $this->assertSame( 0, $idx, 'Expected array &$hrc to be the first parameter in protected method ' . $method->getName(), ); } } } /** * @throws \Throwable */ public function testDomHelperMethodsUseKeyParameter(): void { $ref = new \ReflectionClass(\Com\Tecnick\Pdf\HTML::class); $methods = [ 'estimateHTMLTextHeight', 'getHTMLFontMetric', 'getHTMLTextPrefix', 'getHTMLLineAdvance', 'getCurrentHTMLLineAdvance', ]; foreach ($methods as $name) { $method = $ref->getMethod($name); $params = $method->getParameters(); $this->assertCount(2, \array_slice($params, 0, 2), 'Unexpected leading parameters in ' . $name); assert(isset($params[0]), "\$params[0] must be set"); $this->assertSame('hrc', $params[0]->getName(), 'First parameter must be hrc in ' . $name); $this->assertTrue($params[0]->isPassedByReference(), 'First parameter must be by-reference in ' . $name); assert(isset($params[1]), "\$params[1] must be set"); $this->assertSame('key', $params[1]->getName(), 'Second parameter must be key in ' . $name); $ptype = $params[1]->getType(); $this->assertInstanceOf( \ReflectionNamedType::class, $ptype, 'Second parameter must have named type in ' . $name, ); $this->assertSame('int', $ptype->getName(), 'Second parameter type must be int in ' . $name); } } /** * @throws \Throwable */ public function testHtmlHelperBranchesCoverLanguageSelectorsAndSizing(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode([ 'value' => 'div', 'tag' => true, 'opening' => true, 'parent' => 0, 'attribute' => ['lang' => 'en-US'], ]), 1 => $this->makeHtmlNode([ 'value' => 'span', 'tag' => true, 'opening' => true, 'parent' => 0, 'attribute' => ['disabled' => '', 'xml:lang' => ' fr-ca '], ]), 2 => $this->makeHtmlNode([ 'value' => 'span', 'tag' => true, 'opening' => true, 'parent' => 0, 'attribute' => ['checked' => 'false', 'lang' => 'en-GB'], ]), 3 => $this->makeHtmlNode([ 'value' => 'p', 'tag' => true, 'opening' => true, 'parent' => 0, 'attribute' => ['selected' => 'yes'], ]), 4 => $this->makeHtmlNode([ 'value' => 'p', 'tag' => false, 'opening' => false, 'parent' => 0, 'content' => 'visible text', ]), ]; $this->assertTrue($obj->exposeHasHTMLBooleanAttribute($dom, 1, 'disabled')); $this->assertFalse($obj->exposeHasHTMLBooleanAttribute($dom, 2, 'checked')); $this->assertTrue($obj->exposeHasHTMLBooleanAttribute($dom, 3, 'selected')); $this->assertSame('fr-ca', $obj->exposeGetHTMLEffectiveLang($dom, 1)); $this->assertSame('en-gb', $obj->exposeGetHTMLEffectiveLang($dom, 2)); $this->assertSame('en-us', $obj->exposeGetHTMLEffectiveLang($dom, 3)); $this->assertFalse($obj->exposeMatchesHTMLPseudoLang($dom, 1, 'en')); $this->assertTrue($obj->exposeMatchesHTMLPseudoLang($dom, 2, 'en-gb')); $this->assertTrue($obj->exposeMatchesHTMLPseudoLang($dom, 3, 'en')); $this->assertFalse($obj->exposeMatchesHTMLPseudoLang($dom, 3, 'fr')); $this->assertFalse($obj->exposeMatchesHTMLPseudoLang($dom, 1, '')); $siblings = [0, 1, 2, 3]; $typedSiblings = $obj->exposeGetHTMLSiblingKeysByTagName($dom, $siblings, 2); $this->assertSame([1, 2], $typedSiblings); $this->assertTrue($obj->exposeMatchesHTMLPseudoLastOfType($dom, $siblings, 2)); $this->assertFalse($obj->exposeMatchesHTMLPseudoLastOfType($dom, $siblings, 1)); $this->assertTrue($obj->exposeMatchesHTMLPseudoNthChild($siblings, 1, 'even')); $this->assertTrue($obj->exposeMatchesHTMLPseudoNthChild($siblings, 2, 'odd')); $this->assertFalse($obj->exposeMatchesHTMLPseudoNthChild($siblings, 1, '')); $emptyDom = [ 0 => $this->makeHtmlNode([ 'value' => 'div', 'tag' => true, 'opening' => true, 'parent' => 0, ]), ]; $this->assertTrue($obj->exposeMatchesHTMLPseudoEmpty($emptyDom, 0)); $emptyDom[1] = $this->makeHtmlNode([ 'value' => 'text', 'tag' => false, 'opening' => false, 'parent' => 0, 'content' => 'not empty', ]); $this->assertFalse($obj->exposeMatchesHTMLPseudoEmpty($emptyDom, 0)); $this->assertNull($obj->exposeGetHTMLStyleLengthValue('auto')); $this->assertNull($obj->exposeGetHTMLStyleLengthValue('none')); $this->assertEqualsWithDelta(10.0, $obj->exposeGetHTMLStyleLengthValue('10mm'), 0.001); $this->assertSame('', $obj->exposeGetHTMLBackgroundShorthandColor('')); $this->assertSame( 'rgb(7%,20%,34%)', $obj->exposeGetHTMLBackgroundShorthandColor('url(bg.png) rgb(7%,20%,34%) no-repeat'), ); $adjusted = $obj->exposeResolveHTMLFontSizeAdjust( $this->makeHtmlNode(['font-size-adjust' => 0.5]), 'helvetica', '', 10.0, ); $this->assertGreaterThan(0.0, $adjusted); $sameSize = $obj->exposeResolveHTMLFontSizeAdjust( $this->makeHtmlNode(['font-size-adjust' => 'none']), 'helvetica', '', 10.0, ); $this->assertSame(10.0, $sameSize); $hrc = $obj->exposeGetHTMLRenderContext(); $hrc['cellctx']['originx'] = 0.0; $hrc['cellctx']['originy'] = 0.0; $hrc['cellctx']['lineadvance'] = 0.0; $hrc['cellctx']['linebottom'] = 0.0; $hrc['cellctx']['activepage'] = 'auto'; $namedElm = $this->makeHtmlNode([ 'block' => true, 'display' => 'block', 'page' => 'chapter-1', ]); $this->assertTrue($obj->exposeApplyHTMLNamedPageSemantics($hrc, $namedElm, false, 10.0, 10.0)); $this->assertSame('chapter-1', $hrc['cellctx']['activepage'] ?? null); $hrc2 = $obj->exposeGetHTMLRenderContext(); $hrc2['cellctx']['originx'] = 0.0; $hrc2['cellctx']['originy'] = 0.0; $hrc2['cellctx']['lineadvance'] = 0.0; $hrc2['cellctx']['linebottom'] = 0.0; $this->assertFalse($obj->exposeApplyHTMLNamedPageSemantics($hrc2, $namedElm, true, 10.0, 10.0)); $this->assertFalse($obj->exposeApplyHTMLNamedPageSemantics( $hrc2, $this->makeHtmlNode(['page' => 'auto']), false, 10.0, 10.0, )); } /** * @throws \Throwable */ public function testTidyHTMLReturnsStyledXhtml(): void { if (!\function_exists('tidy_parse_string')) { $this->markTestSkipped('Tidy extension is not available.'); } $obj = $this->getTestObject(); $html = '

Hello


'; $out = $obj->tidyHTML($html, 'body{font-size:10pt;}'); $this->assertStringStartsWith('' . '

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 $domWithoutRoot */ $domWithoutRoot = [1 => $this->makeHtmlNode(['value' => 'div'])]; $method->invokeArgs($obj, [&$domWithoutRoot, ['div' => 'color:#f00;']]); $domWithoutRoot = $this->requireMixedArray($domWithoutRoot, 'Expected rootless DOM array.'); $this->assertCount(1, $domWithoutRoot); $root = $obj->exposeGetHTMLRootProperties(); /** @var array $dom */ $dom = [ 0 => $root, 1 => $this->makeHtmlNode([ 'parent' => 0, 'tag' => true, 'opening' => true, 'value' => 'div', ]), 2 => 'invalid-node', ]; $method->invokeArgs($obj, [&$dom, []]); $dom = $this->requireMixedArray($dom, 'Expected sparse DOM array.'); $this->assertSame('invalid-node', $dom[2] ?? null); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesPositionModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['position' => 'fixed']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'position:relative;'], 'position' => 'static', ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'position:inherit;'], 'position' => 'static', ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'position:invalid;'], 'position' => 'static', ]), ]; $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('relative', $dom[1]['position']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('fixed', $dom[2]['position']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('static', $dom[3]['position']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesPositionInheritFallsBackToParentStyle(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'position' => '', 'style' => ['position' => 'absolute'], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'position:inherit;'], 'position' => 'static', ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('absolute', $dom[1]['position']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesPositionOffsetsApplyForNonStaticModes(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'position:relative;left:2mm;top:3mm;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'position:static;left:2mm;top:3mm;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'position:absolute;left:4mm;top:5mm;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 3, 1); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertGreaterThan(0.0, $this->getHtmlNodeMargin($dom[1], 'L')); $this->assertGreaterThan(0.0, $this->getHtmlNodeMargin($dom[1], 'T')); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame(0.0, $this->getHtmlNodeMargin($dom[2], 'L')); $this->assertSame(0.0, $this->getHtmlNodeMargin($dom[2], 'T')); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertGreaterThan(0.0, $this->getHtmlNodeMargin($dom[3], 'L')); $this->assertGreaterThan(0.0, $this->getHtmlNodeMargin($dom[3], 'T')); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesPositionOffsetsRespectInheritedAndAutoValues(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'style' => ['left' => '4mm', 'top' => 'auto'], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'position:absolute;left:inherit;top:inherit;'], 'margin' => [], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'position:relative;left:auto;top:auto;'], ]), 3 => $this->makeHtmlNode(), 4 => $this->makeHtmlNode([ 'parent' => 3, 'attribute' => ['style' => 'position:fixed;left:inherit;top:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 4, 3); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertGreaterThan(0.0, $this->getHtmlNodeMargin($dom[1], 'L')); $this->assertSame(0.0, $this->getHtmlNodeMargin($dom[1], 'T')); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame(0.0, $this->getHtmlNodeMargin($dom[2], 'L')); $this->assertSame(0.0, $this->getHtmlNodeMargin($dom[2], 'T')); assert(isset($dom[4]), "\$dom[4] must be set"); $this->assertSame(0.0, $this->getHtmlNodeMargin($dom[4], 'L')); $this->assertSame(0.0, $this->getHtmlNodeMargin($dom[4], 'T')); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesPositionOffsetsIgnoreImplicitStaticNodes(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'position' => '', 'attribute' => ['style' => 'left:2mm;top:3mm;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(0.0, $this->getHtmlNodeMargin($dom[1], 'L')); $this->assertSame(0.0, $this->getHtmlNodeMargin($dom[1], 'T')); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesFloatModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['float' => 'right']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'float:left;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'float:inherit;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'float:invalid;'], ]), ]; $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('left', $dom[1]['float']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('right', $dom[2]['float']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('none', $dom[3]['float']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesFloatInheritFallsBackToParentStyle(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'float' => '', 'style' => ['float' => 'left'], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'float:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('left', $dom[1]['float']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesClearModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['clear' => 'both']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'clear:left;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'clear:inherit;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'clear:invalid;'], ]), ]; $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('left', $dom[1]['clear']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('both', $dom[2]['clear']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('none', $dom[3]['clear']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesClearInheritFallsBackToParentStyle(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'clear' => '', 'style' => ['clear' => 'right'], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'clear:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('right', $dom[1]['clear']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENdivFloatRightUsesSidePlacedWidth(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 40.0, 20.0); $elm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'float' => 'right', 'width' => 10.0, ]); $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENdiv', $elm, $tpx, $tpy, $tpw, $tph); $this->assertGreaterThan(0.0, $tpx); $this->assertEqualsWithDelta(10.0, $tpw, 0.001); } /** * @throws \Throwable */ public function testParseHTMLTagOPENdivWidthWithoutFloatConstrainsBlockWidth(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 40.0, 20.0); $elm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'float' => 'none', 'width' => 12.0, ]); $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENdiv', $elm, $tpx, $tpy, $tpw, $tph); $this->assertSame(0.0, $tpx); $this->assertEqualsWithDelta(12.0, $tpw, 0.001); } /** * @throws \Throwable */ public function testParseHTMLTagOPENdivClearForcesLineBreakWhenMidLine(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 40.0, 20.0); $obj->exposeSetHTMLLineState(5.0, 0.0, false); $elm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'clear' => 'both', ]); $tpx = 5.0; $tpy = 0.0; $tpw = 35.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENdiv', $elm, $tpx, $tpy, $tpw, $tph); $this->assertSame(0.0, $tpx); $this->assertSame(0.0, $tpy); } /** * @throws \Throwable */ public function testOpenAndCloseHTMLBlockSiblingFloatsStayOnSameRow(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 40.0, 30.0); $leftOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'float' => 'left', 'width' => 10.0, ]); $leftClose = $this->makeHtmlNode([ 'opening' => false, 'value' => 'div', 'block' => true, 'float' => 'left', 'parent' => 0, 'ctxoriginx' => 0.0, 'ctxmaxwidth' => 40.0, 'ctxregionoffset' => 0.0, ]); $rightOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'float' => 'right', 'width' => 10.0, ]); $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $obj->exposeOpenHTMLBlock($leftOpen, $tpx, $tpy, $tpw); $this->assertSame(0.0, $tpx); $this->assertEqualsWithDelta(10.0, $tpw, 0.001); $obj->exposeSetHTMLLineState(5.0, 0.0, false); $tpx = 5.0; $obj->exposeCloseHTMLBlock($leftClose, $tpx, $tpy, $tpw); $this->assertSame(0.0, $tpy); $obj->exposeOpenHTMLBlock($rightOpen, $tpx, $tpy, $tpw); $this->assertGreaterThan(20.0, $tpx); $this->assertEqualsWithDelta(10.0, $tpw, 0.001); $this->assertSame(0.0, $tpy); } /** * @throws \Throwable */ public function testOpenAndCloseHTMLBlockSiblingFloatsStayTopAlignedBelowOrigin(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 40.0, 40.0); $leftOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'float' => 'left', 'width' => 10.0, ]); $leftClose = $this->makeHtmlNode([ 'opening' => false, 'value' => 'div', 'block' => true, 'float' => 'left', 'parent' => 0, ]); $rightOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'float' => 'right', 'width' => 10.0, ]); $tpx = 0.0; $tpy = 14.0; $tpw = 40.0; $obj->exposeOpenHTMLBlock($leftOpen, $tpx, $tpy, $tpw); $leftY = $tpy; $obj->exposeSetHTMLLineState(5.0, 0.0, false); $tpx = 3.0; $obj->exposeCloseHTMLBlock($leftClose, $tpx, $tpy, $tpw); $this->assertEqualsWithDelta($leftY, $tpy, 0.001); $obj->exposeOpenHTMLBlock($rightOpen, $tpx, $tpy, $tpw); $this->assertEqualsWithDelta($leftY, $tpy, 0.001); } /** * @throws \Throwable */ public function testOpenAndCloseHTMLBlockSiblingInlineBlocksAdvanceToSavedNextX(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 120.0, 60.0); $firstOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'display' => 'inline-block', 'float' => 'none', 'width' => 30.0, 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'margin' => ['T' => 0.0, 'R' => 3.0, 'B' => 0.0, 'L' => 0.0], ]); $firstClose = $this->makeHtmlNode([ 'opening' => false, 'value' => 'div', 'block' => true, 'display' => 'inline-block', 'float' => 'none', 'parent' => 0, 'margin' => ['T' => 0.0, 'R' => 3.0, 'B' => 0.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'ctxoriginx' => 0.0, 'ctxmaxwidth' => 120.0, 'ctxregionoffset' => 0.0, 'inlineblockrowy' => 0.0, 'inlineblocknextx' => 33.0, ]); $secondOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'display' => 'inline-block', 'float' => 'none', 'width' => 30.0, 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 120.0; $obj->exposeOpenHTMLBlock($firstOpen, $tpx, $tpy, $tpw); $this->assertEqualsWithDelta(0.0, $tpx, 0.001); $obj->exposeSetHTMLLineState(8.0, 8.0, false); $tpx = 30.0; $obj->exposeCloseHTMLBlock($firstClose, $tpx, $tpy, $tpw); $this->assertEqualsWithDelta(33.0, $tpx, 0.001, 'Inline-block close must restore the saved next x position.'); $this->assertEqualsWithDelta(0.0, $tpy, 0.001, 'Inline-block siblings should stay on the same row.'); $obj->exposeOpenHTMLBlock($secondOpen, $tpx, $tpy, $tpw); $this->assertGreaterThanOrEqual( 33.0, $tpx, 'The next inline-block sibling must start to the right of the first sibling.', ); } /** * @throws \Throwable */ public function testUpdateHTMLParentBlockBottomKeepsTallestChildBottom(): void { $obj = $this->getInternalTestObject(); $obj->exposeSetHTMLDom([ 0 => $this->makeHtmlNode([ 'value' => 'div', 'opening' => true, 'parent' => 0, 'childblockbottom' => 0.0, ]), 1 => $this->makeHtmlNode([ 'value' => 'div', 'opening' => true, 'parent' => 0, ]), 2 => $this->makeHtmlNode([ 'value' => 'div', 'opening' => true, 'parent' => 0, ]), ]); $obj->exposeUpdateHTMLParentBlockBottom(1, 42.0); $obj->exposeUpdateHTMLParentBlockBottom(2, 18.0); $hrc = $obj->exposeGetHTMLRenderContext(); assert(isset($hrc['dom'][0]), "\$hrc['dom'][0] must be set"); $this->assertSame(42.0, $hrc['dom'][0]['childblockbottom'] ?? null); } /** * @throws \Throwable */ public function testOpenHTMLBlockClearBothFlushesActiveFloatRow(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 40.0, 30.0); $floatOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'float' => 'left', 'width' => 12.0, ]); $floatClose = $this->makeHtmlNode([ 'opening' => false, 'value' => 'div', 'block' => true, 'float' => 'left', 'parent' => 0, ]); $clearOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'clear' => 'both', 'float' => 'none', ]); $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $obj->exposeOpenHTMLBlock($floatOpen, $tpx, $tpy, $tpw); $obj->exposeSetHTMLLineState(6.0, 0.0, false); $tpx = 4.0; $obj->exposeCloseHTMLBlock($floatClose, $tpx, $tpy, $tpw); $this->assertSame(0.0, $tpy); $obj->exposeOpenHTMLBlock($clearOpen, $tpx, $tpy, $tpw); $this->assertGreaterThanOrEqual(6.0, $tpy); } /** * @throws \Throwable */ public function testOpenHTMLBlockNormalBlockCanFlowBesideActiveFloatRow(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 40.0, 30.0); $floatOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'float' => 'left', 'width' => 12.0, ]); $floatClose = $this->makeHtmlNode([ 'opening' => false, 'value' => 'div', 'block' => true, 'float' => 'left', 'parent' => 0, ]); $normalOpen = $this->makeHtmlNode([ 'opening' => true, 'value' => 'div', 'block' => true, 'float' => 'none', 'clear' => 'none', ]); $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $obj->exposeOpenHTMLBlock($floatOpen, $tpx, $tpy, $tpw); $obj->exposeSetHTMLLineState(6.0, 0.0, false); $tpx = 4.0; $obj->exposeCloseHTMLBlock($floatClose, $tpx, $tpy, $tpw); $obj->exposeOpenHTMLBlock($normalOpen, $tpx, $tpy, $tpw); $this->assertEqualsWithDelta(0.0, $tpy, 0.001); $this->assertEqualsWithDelta(12.0, $tpx, 0.001); $this->assertGreaterThan(0.0, $tpw); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesDisplayModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['hide' => true, 'display' => 'block', 'block' => true]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'display:inherit;'], 'hide' => false, 'display' => 'inline', 'block' => false, ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'display:none;'], 'hide' => false, 'display' => 'inline', 'block' => false, ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'display:list-item;'], 'hide' => true, 'display' => 'inline', 'block' => false, ]), ]; $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->assertTrue($dom[1]['hide']); $this->assertSame('block', $dom[1]['display']); $this->assertTrue($dom[1]['block']); $this->assertTrue($dom[2]['hide']); $this->assertSame('none', $dom[2]['display']); $this->assertFalse($dom[3]['hide']); $this->assertSame('list-item', $dom[3]['display']); $this->assertTrue($dom[3]['block']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesTextAlignModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['align' => 'J']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-align:center;'], 'align' => '', ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-align:inherit;'], 'align' => '', ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-align:right;'], 'align' => '', ]), 4 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-align:invalid;'], 'align' => 'L', ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 3, 0); $obj->parseHTMLStyleAttributes($dom, 4, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('C', $dom[1]['align']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('J', $dom[2]['align']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('R', $dom[3]['align']); assert(isset($dom[4]), "\$dom[4] must be set"); $this->assertSame('L', $dom[4]['align']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesVerticalAlignModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['valign' => 'middle']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'vertical-align:bottom;'], 'valign' => 'top', ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'vertical-align:inherit;'], 'valign' => 'top', ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'vertical-align:invalid;'], 'valign' => 'top', ]), ]; $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('bottom', $dom[1]['valign']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('middle', $dom[2]['valign']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('top', $dom[3]['valign']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesTableLayoutModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['table-layout' => 'fixed']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'table-layout:auto;'], 'table-layout' => 'fixed', ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'table-layout:inherit;'], 'table-layout' => 'auto', ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'table-layout:invalid;'], 'table-layout' => 'auto', ]), ]; $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('auto', $dom[1]['table-layout']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('fixed', $dom[2]['table-layout']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('auto', $dom[3]['table-layout']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesEmptyCellsModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['empty-cells' => 'hide']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'empty-cells:show;'], 'empty-cells' => 'hide', ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'empty-cells:inherit;'], 'empty-cells' => 'show', ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'empty-cells:invalid;'], 'empty-cells' => 'show', ]), ]; $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('show', $dom[1]['empty-cells']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('hide', $dom[2]['empty-cells']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('show', $dom[3]['empty-cells']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesCaptionSideModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['caption-side' => 'bottom']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'caption-side:top;'], 'caption-side' => 'bottom', ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'caption-side:inherit;'], 'caption-side' => 'top', ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'caption-side:invalid;'], 'caption-side' => 'top', ]), ]; $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('top', $dom[1]['caption-side']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('bottom', $dom[2]['caption-side']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('top', $dom[3]['caption-side']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesAlignInheritFallsBackToParentStyleValues(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'align' => '', 'valign' => '', 'style' => [ 'text-align' => 'justify', 'vertical-align' => 'middle', ], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-align:inherit;vertical-align:inherit;'], 'align' => '', 'valign' => '', ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('J', $dom[1]['align']); $this->assertSame('middle', $dom[1]['valign']); } /** * @throws \Throwable */ public function testParseHTMLAttributesSetsTagSpecificDefaults(): void { $obj = $this->getTestObject(); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'a', 'attribute' => [], 'style' => [], 'fontstyle' => '', 'fontsize' => 10.0, 'parent' => 0, 'align' => '', 'hide' => false, ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertStringContainsString('U', $dom[1]['fontstyle']); } /** * @throws \Throwable */ public function testParseHTMLAttributesCoversFontTableAndHeadingBranches(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'font', 'parent' => 0, 'attribute' => ['face' => 'helvetica', 'size' => '+2'], 'style' => [], 'fontstyle' => '', 'fontsize' => 10.0, ]), 2 => $this->makeHtmlNode([ 'value' => 'table', 'parent' => 0, 'attribute' => [], 'style' => [], ]), 3 => $this->makeHtmlNode([ 'value' => 'tr', 'parent' => 2, 'attribute' => [], 'style' => [], ]), 4 => $this->makeHtmlNode([ 'value' => 'td', 'parent' => 3, 'attribute' => ['colspan' => '2'], 'style' => [], ]), 5 => $this->makeHtmlNode([ 'value' => 'h2', 'parent' => 0, 'attribute' => [], 'style' => [], 'fontstyle' => '', 'fontsize' => 10.0, ]), 6 => $this->makeHtmlNode([ 'value' => 'ul', 'parent' => 0, 'attribute' => [], 'style' => [], 'align' => '', ]), 7 => $this->makeHtmlNode([ 'value' => 'small', 'parent' => 0, 'attribute' => [], 'style' => [], 'fontsize' => 10.0, ]), 8 => $this->makeHtmlNode([ 'value' => 'th', 'parent' => 3, 'attribute' => [], 'style' => [], 'fontstyle' => '', 'align' => '', ]), ]; $obj->parseHTMLAttributes($dom, 1, false); $obj->parseHTMLAttributes($dom, 2, false); $obj->parseHTMLAttributes($dom, 3, false); $obj->parseHTMLAttributes($dom, 4, false); $obj->parseHTMLAttributes($dom, 5, false); $obj->parseHTMLAttributes($dom, 6, false); $obj->parseHTMLAttributes($dom, 7, false); $obj->parseHTMLAttributes($dom, 8, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(12.0, $dom[1]['fontsize']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame(1, $dom[2]['rows']); $this->assertSame([3], $dom[2]['trids']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame(3, $dom[3]['cols']); assert(isset($dom[4]), "\$dom[4] must be set"); $this->assertSame('2', $this->getHtmlNodeAttrString($dom[4], 'colspan')); assert(isset($dom[5]), "\$dom[5] must be set"); $this->assertSame(14.0, $dom[5]['fontsize']); $this->assertStringContainsString('B', $dom[5]['fontstyle']); assert(isset($dom[6]), "\$dom[6] must be set"); $this->assertSame('L', $dom[6]['align']); assert(isset($dom[7]), "\$dom[7] must be set"); $this->assertGreaterThan(0.0, $dom[7]['fontsize']); $this->assertLessThan(10.0, $dom[7]['fontsize']); assert(isset($dom[8]), "\$dom[8] must be set"); $this->assertSame('C', $dom[8]['align']); $this->assertStringContainsString('B', $dom[8]['fontstyle']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesCoversPageBreakAndInheritanceModes(): void { $obj = $this->getTestObject(); /** @var array $dom */ $dom = [ 0 => $this->makeHtmlNode([ 'line-height' => 1.25, 'listtype' => 'disc', 'text-indent' => 2.0, ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => [ 'style' => 'line-height:normal;page-break-before:always;page-break-after:right;' . 'list-style-type:inherit;text-indent:inherit;', ], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => [ 'style' => 'line-height:normal;page-break-before:left;page-break-after:always;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(1.25, $dom[1]['line-height']); $this->assertSame('right', $this->getHtmlNodeAttrString($dom[1], 'pagebreakafter')); $this->assertSame('disc', $dom[1]['listtype']); $this->assertSame(2.0, $dom[1]['text-indent']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame(1.25, $dom[2]['line-height']); $this->assertSame('true', $this->getHtmlNodeAttrString($dom[2], 'pagebreakafter')); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesMapsModernBreakAliases(): void { $obj = $this->getTestObject(); $root = $this->makeHtmlNode([ 'line-height' => 1.25, 'listtype' => 'disc', 'text-indent' => 2.0, ]); $breakNode = $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => [ 'style' => 'break-before:page;break-after:right;break-inside:avoid;', ], ]); $breakNode2 = $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => [ 'style' => 'break-before:left;break-after:page;', ], ]); /** @var array $dom */ $dom = [ 0 => $root, 1 => $breakNode, 2 => $breakNode2, ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('true', $this->getHtmlNodeAttrString($dom[1], 'pagebreak')); $this->assertSame('right', $this->getHtmlNodeAttrString($dom[1], 'pagebreakafter')); $this->assertSame('true', $this->getHtmlNodeAttrString($dom[1], 'nobr')); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('left', $this->getHtmlNodeAttrString($dom[2], 'pagebreak')); $this->assertSame('true', $this->getHtmlNodeAttrString($dom[2], 'pagebreakafter')); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesPageBreakValuesCaseInsensitive(): void { $obj = $this->getTestObject(); /** @var array $dom */ $dom = [ 0 => $this->makeHtmlNode([ 'line-height' => 1.0, 'listtype' => 'disc', 'text-indent' => 0.0, ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => [ 'style' => 'page-break-inside:AVOID;page-break-before:LEFT;page-break-after:RIGHT;', ], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => [ 'style' => 'break-inside:AVOID;break-before:PAGE;break-after:LEFT;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('true', $this->getHtmlNodeAttrString($dom[1], 'nobr')); $this->assertSame('left', $this->getHtmlNodeAttrString($dom[1], 'pagebreak')); $this->assertSame('right', $this->getHtmlNodeAttrString($dom[1], 'pagebreakafter')); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('true', $this->getHtmlNodeAttrString($dom[2], 'nobr')); $this->assertSame('true', $this->getHtmlNodeAttrString($dom[2], 'pagebreak')); $this->assertSame('left', $this->getHtmlNodeAttrString($dom[2], 'pagebreakafter')); } /** * @return array{count:int, markerPage:int} * * @throws \Throwable */ private function renderNestedBreakBeforeScenario(string $breakSide, string $marker): array { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '
INTRO
' . '

' . $marker . '

' . '
OUTRO
'; $obj->addHTMLCell($html, 0, 0, 60, 0); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $pages = $page->getPages(); $markerPage = 0; foreach ($pages as $idx => $pdata) { $content = \implode("\n", $pdata['content']); if (\strpos($content, $marker) !== false) { $markerPage = $idx + 1; break; } } return [ 'count' => \count($pages), 'markerPage' => $markerPage, ]; } /** @throws \Throwable */ private function measureWrappedLineHeightDelta(string $text, string $lineHeight): float { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $html = '

' . $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 array $dom */ $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'border-collapse:collapse;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'border-collapse:separate;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 1, 'attribute' => ['style' => 'border-collapse:InHeRiT;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 3, 1); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('collapse', $dom[1]['border-collapse']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('separate', $dom[2]['border-collapse']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('collapse', $dom[3]['border-collapse']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesBorderInheritApplied(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'border:1px solid #112233;border-left:2px dotted #445566;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 1, 'attribute' => ['style' => 'border:InHeRiT;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 1, 'attribute' => ['style' => 'border-left:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 1); $obj->parseHTMLStyleAttributes($dom, 3, 1); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotEmpty($this->getHtmlNodeBorders($dom[1])); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame($this->getHtmlNodeBorders($dom[1]), $this->getHtmlNodeBorders($dom[2])); $this->assertArrayHasKey('L', $this->getHtmlNodeBorders($dom[1])); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertArrayHasKey('L', $this->getHtmlNodeBorders($dom[3])); $this->assertSame($this->getHtmlNodeBorderSide($dom[1], 'L'), $this->getHtmlNodeBorderSide($dom[3], 'L')); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesBorderColorAndWidthInheritApplied(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => [ 'style' => 'border:1px solid #111111;border-color:#112233 #223344 #334455 #445566;' . 'border-width:1px 2px 3px 4px;', ], ]), 2 => $this->makeHtmlNode([ 'parent' => 1, 'attribute' => [ 'style' => 'border:1px solid black;border-color:InHeRiT;border-width:inherit;', ], ]), 3 => $this->makeHtmlNode([ 'parent' => 1, 'attribute' => [ 'style' => 'border:1px solid black;border-left-color:inherit;border-left-width:INHERIT;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 1); $obj->parseHTMLStyleAttributes($dom, 3, 1); foreach (['L', 'R', 'T', 'B'] as $side) { assert(isset($dom[1]), "\$dom[1] must be set"); assert(isset($dom[2]), "\$dom[2] must be set"); $baseSide = $this->getHtmlNodeBorderSide($dom[1], $side); $childSide = $this->getHtmlNodeBorderSide($dom[2], $side); $this->assertSame($baseSide['lineColor'] ?? null, $childSide['lineColor'] ?? null); $this->assertSame($baseSide['lineWidth'] ?? null, $childSide['lineWidth'] ?? null); } assert(isset($dom[3]), "\$dom[3] must be set"); $baseLeft = $this->getHtmlNodeBorderSide($dom[1], 'L'); $childLeft = $this->getHtmlNodeBorderSide($dom[3], 'L'); $this->assertSame($baseLeft['lineColor'] ?? null, $childLeft['lineColor'] ?? null); $this->assertSame($baseLeft['lineWidth'] ?? null, $childLeft['lineWidth'] ?? null); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesBorderStyleInheritApplied(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => [ 'style' => 'border-width:1px 2px 3px 4px;border-style:dashed dotted solid double;', ], ]), 2 => $this->makeHtmlNode([ 'parent' => 1, 'attribute' => [ 'style' => 'border-width:1px 2px 3px 4px;border-style:InHeRiT;', ], ]), 3 => $this->makeHtmlNode([ 'parent' => 1, 'attribute' => [ 'style' => 'border-width:1px 2px 3px 4px;border-style:solid;border-left-style:inherit;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 1); $obj->parseHTMLStyleAttributes($dom, 3, 1); assert(isset($dom[1]), "\$dom[1] must be set"); assert(isset($dom[2]), "\$dom[2] must be set"); foreach (['L', 'R', 'T', 'B'] as $side) { $baseSide = $this->getHtmlNodeBorderSide($dom[1], $side); $childSide = $this->getHtmlNodeBorderSide($dom[2], $side); $this->assertSame($baseSide['cssBorderStyle'] ?? null, $childSide['cssBorderStyle'] ?? null); } assert(isset($dom[3]), "\$dom[3] must be set"); $baseLeft = $this->getHtmlNodeBorderSide($dom[1], 'L'); $childLeft = $this->getHtmlNodeBorderSide($dom[3], 'L'); $this->assertSame($baseLeft['cssBorderStyle'] ?? null, $childLeft['cssBorderStyle'] ?? null); $this->assertSame('solid', $this->getHtmlNodeBorderSide($dom[3], 'T')['cssBorderStyle'] ?? null); } /** * @throws \Throwable */ public function testGetHTMLTableCellBorderStylesMergesSideOverridesOverLTRB(): void { $obj = $this->getInternalTestObject(); $all = [ 'lineWidth' => 1.0, 'lineCap' => 'square', 'lineJoin' => 'miter', 'miterLimit' => 10.0, 'dashArray' => [], 'dashPhase' => 0.0, 'lineColor' => 'rgba(119,119,119,1)', 'fillColor' => '', 'cssBorderStyle' => 'solid', ]; $top = [ 'lineWidth' => 2.0, 'lineCap' => 'square', 'lineJoin' => 'miter', /** @var array $dom */ /** @var array $dom */ 'miterLimit' => 10.0, 'dashArray' => [1], 'dashPhase' => 0.0, 'lineColor' => 'rgba(204,0,0,1)', 'fillColor' => '', 'cssBorderStyle' => 'dotted', ]; $right = [ 'lineWidth' => 1.0, 'lineCap' => 'square', 'lineJoin' => 'miter', 'miterLimit' => 10.0, 'dashArray' => [3], 'dashPhase' => 0.0, 'lineColor' => 'rgba(0,119,204,1)', 'fillColor' => '', 'cssBorderStyle' => 'dashed', ]; $dom = [ 0 => $this->makeHtmlNode(['value' => 'root']), 1 => $this->makeHtmlNode([ 'value' => 'div', 'parent' => 0, 'border' => [ 'LTRB' => $all, 'T' => $top, 'R' => $right, ], ]), ]; $styles = $obj->exposeGetHTMLTableCellBorderStylesWithDom($dom, 1); $this->assertArrayHasKey(0, $styles); $this->assertArrayHasKey(1, $styles); $this->assertArrayHasKey(2, $styles); $this->assertArrayHasKey(3, $styles); assert(isset($styles[0]), "\$styles[0] must be set"); $this->assertSame($top, $styles[0]); assert(isset($styles[1]), "\$styles[1] must be set"); $this->assertSame($right, $styles[1]); assert(isset($styles[2]), "\$styles[2] must be set"); $this->assertSame($all, $styles[2]); assert(isset($styles[3]), "\$styles[3] must be set"); $this->assertSame($all, $styles[3]); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesBorderInheritFallbacksToParentLTRB(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'border:2px dashed #123456;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 1, 'attribute' => [ 'style' => 'border-color:inherit;border-width:inherit;border-style:inherit;border-left:inherit;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 1); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertArrayHasKey('LTRB', $this->getHtmlNodeBorders($dom[1])); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertArrayHasKey('L', $this->getHtmlNodeBorders($dom[2])); $this->assertArrayHasKey('R', $this->getHtmlNodeBorders($dom[2])); $this->assertArrayHasKey('T', $this->getHtmlNodeBorders($dom[2])); $this->assertArrayHasKey('B', $this->getHtmlNodeBorders($dom[2])); $parentLTRB = $this->getHtmlNodeBorderSide($dom[1], 'LTRB'); foreach (['L', 'R', 'T', 'B'] as $side) { $childSide = $this->getHtmlNodeBorderSide($dom[2], $side); $this->assertSame($parentLTRB['lineColor'] ?? null, $childSide['lineColor'] ?? null); $this->assertSame($parentLTRB['lineWidth'] ?? null, $childSide['lineWidth'] ?? null); $this->assertSame($parentLTRB['cssBorderStyle'] ?? null, $childSide['cssBorderStyle'] ?? null); } } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesExtractsBackgroundColorFromShorthand(): void { $obj = $this->getTestObject(); /** @var array $dom */ $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => [ 'style' => 'background:url(example.png) no-repeat center center #ffeeaa;', ], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => [ 'style' => 'background:#112233;background-color:#abcdef;', ], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => [ 'style' => 'background:url(example.png) no-repeat center center;', ], ]), 4 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => [ 'style' => 'background-color:#abcdef;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 3, 0); $obj->parseHTMLStyleAttributes($dom, 4, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotSame('', $dom[1]['bgcolor']); $this->assertIsString($dom[1]['bgcolor']); $this->assertStringContainsString('rgb(', $dom[1]['bgcolor']); $this->assertSame($dom[4]['bgcolor'], $dom[2]['bgcolor']); $this->assertSame('', $dom[3]['bgcolor']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesInheritForFontFamilyColorAndBackgroundColor(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'fontname' => 'times', 'fgcolor' => 'red', 'bgcolor' => 'green', ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => [ 'style' => 'font-family:inherit;color:inherit;background-color:inherit;', ], 'style' => [], 'fontname' => '', 'fgcolor' => '', 'bgcolor' => '', ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('times', $dom[1]['fontname']); $this->assertSame('red', $dom[1]['fgcolor']); $this->assertSame('green', $dom[1]['bgcolor']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesBackgroundShorthandInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'bgcolor' => 'green', ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => [ 'style' => 'background:inherit;', ], 'bgcolor' => '', ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => [ 'style' => 'background:none;', ], 'bgcolor' => 'green', ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('green', $dom[1]['bgcolor']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('', $dom[2]['bgcolor']); } /** * @throws \Throwable */ public function testParseHTMLAttributesCoversDisplayColorAndGeometryAttributes(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'span', 'parent' => 0, 'fontsize' => 10.0, 'attribute' => [ 'display' => 'none', 'dir' => 'rtl', 'color' => 'red', 'bgcolor' => '#00ff00', 'strokecolor' => 'blue', 'width' => '20', 'height' => '10', 'align' => 'center', 'stroke' => '0.2', 'fill' => 'true', 'clip' => 'true', 'border' => '1', ], 'style' => [], ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertTrue($dom[1]['hide']); $this->assertSame('rtl', $dom[1]['dir']); $this->assertStringContainsString('rgb(', $dom[1]['fgcolor']); $this->assertNotSame('', $dom[1]['bgcolor']); $this->assertStringContainsString('rgb(', $dom[1]['strokecolor']); $this->assertGreaterThan(0.0, $dom[1]['width']); $this->assertGreaterThan(0.0, $dom[1]['height']); $this->assertSame('C', $dom[1]['align']); $this->assertGreaterThan(0.0, $dom[1]['stroke']); $this->assertTrue($dom[1]['fill']); $this->assertTrue($dom[1]['clip']); $this->assertArrayHasKey('LTRB', $dom[1]['border']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesAppliesCSSFontSize(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); // "12px" — NOT numeric as a string, but must be parsed by getFontValuePoints $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'attribute' => ['style' => 'font-size:12px;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'attribute' => ['style' => 'font-size:150%;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'attribute' => ['style' => 'font-size:1.5em;'], ]), 4 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'attribute' => ['style' => 'font-size:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 3, 0); $obj->parseHTMLStyleAttributes($dom, 4, 0); assert(isset($dom[1]), "\$dom[1] must be set"); // All three must result in a non-default (not 10pt) font size $this->assertNotSame(10.0, $dom[1]['fontsize'], 'font-size:12px should change fontsize'); $this->assertGreaterThan(0.0, $dom[1]['fontsize']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertNotSame(10.0, $dom[2]['fontsize'], 'font-size:150% should change fontsize'); $this->assertGreaterThan(0.0, $dom[2]['fontsize']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertNotSame(10.0, $dom[3]['fontsize'], 'font-size:1.5em should change fontsize'); $this->assertGreaterThan(0.0, $dom[3]['fontsize']); assert(isset($dom[4]), "\$dom[4] must be set"); $this->assertSame(10.0, $dom[4]['fontsize'], 'font-size:inherit should preserve parent fontsize'); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesWidthAndHeightInheritCaseInsensitive(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'width' => 12.5, 'height' => 34.75, ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'width' => 0.0, 'height' => 0.0, 'attribute' => ['style' => 'width:InHeRiT;height:INHERIT;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(12.5, $dom[1]['width']); $this->assertSame(34.75, $dom[1]['height']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesWidthAndHeightInheritFallBackToParentStyleValues(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'width' => '', 'height' => '', 'style' => [ 'width' => '20mm', 'height' => '15mm', ], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'width' => 0.0, 'height' => 0.0, 'attribute' => ['style' => 'width:inherit;height:inherit;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'width' => 0.0, 'height' => 0.0, 'attribute' => ['style' => 'width:20mm;height:15mm;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[2]), "\$dom[2] must be set"); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta($dom[2]['width'], $dom[1]['width'], 0.0001); $this->assertEqualsWithDelta($dom[2]['height'], $dom[1]['height'], 0.0001); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesAppliesMinMaxWidthHeightClamping(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'width' => 0.0, 'height' => 0.0, 'attribute' => ['style' => 'width:10mm;min-width:20mm;height:30mm;max-height:25mm;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'width' => 0.0, 'height' => 0.0, 'attribute' => ['style' => 'width:20mm;height:25mm;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[2]), "\$dom[2] must be set"); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta($dom[2]['width'], $dom[1]['width'], 0.0001); $this->assertEqualsWithDelta($dom[2]['height'], $dom[1]['height'], 0.0001); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesIgnoresMinMaxWithoutExplicitWidthHeight(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'width' => 0.0, 'height' => 0.0, 'attribute' => ['style' => 'min-width:20mm;max-width:30mm;min-height:10mm;max-height:15mm;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(0.0, $dom[1]['width']); $this->assertSame(0.0, $dom[1]['height']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesResolvesConflictingMinMaxByFavoringMin(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'width' => 0.0, 'height' => 0.0, 'attribute' => [ 'style' => 'width:10mm;min-width:30mm;max-width:20mm;height:10mm;min-height:30mm;max-height:20mm;', ], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'width' => 0.0, 'height' => 0.0, 'attribute' => ['style' => 'width:30mm;height:30mm;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[2]), "\$dom[2] must be set"); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta($dom[2]['width'], $dom[1]['width'], 0.0001); $this->assertEqualsWithDelta($dom[2]['height'], $dom[1]['height'], 0.0001); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesOverflowMapsToClipMode(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['clip' => true]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'clip' => false, 'attribute' => ['style' => 'overflow:hidden;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'clip' => true, 'attribute' => ['style' => 'overflow:visible;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'clip' => false, 'attribute' => ['style' => 'overflow:inherit;'], ]), ]; $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->assertTrue($dom[1]['clip']); $this->assertFalse($dom[2]['clip']); $this->assertTrue($dom[3]['clip']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesOverflowAxisOverridesShorthand(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['clip' => false]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'clip' => false, 'attribute' => ['style' => 'overflow:visible;overflow-x:hidden;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'clip' => true, 'attribute' => ['style' => 'overflow:hidden;overflow-y:visible;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); // Any axis hidden forces clipping true. $this->assertTrue($dom[1]['clip']); // Visible axis does not clear clipping when another declaration already requested hidden. $this->assertTrue($dom[2]['clip']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesInheritFallsBackToParentStyleScalars(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode([ 'fontsize' => '', 'font-stretch' => '', 'letter-spacing' => '', 'word-spacing' => '', 'fgcolor' => '', 'bgcolor' => '', 'border-collapse' => '', 'border-spacing' => '', 'fontstyle' => '', 'style' => [ 'font-size' => '13pt', 'font-stretch' => 'expanded', 'letter-spacing' => '1mm', 'word-spacing' => '2mm', 'color' => '#123456', 'background-color' => '#abcdef', 'border-collapse' => 'collapse', 'border-spacing' => '2mm 3mm', 'font-weight' => '700', 'font-style' => 'italic', ], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 0.0, 'font-stretch' => 0.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'fgcolor' => '', 'bgcolor' => '', 'fontstyle' => '', 'border-collapse' => '', 'border-spacing' => '', 'attribute' => [ 'style' => 'font-size:inherit;font-stretch:inherit;letter-spacing:inherit;word-spacing:inherit;' . 'color:inherit;background-color:inherit;border-collapse:inherit;border-spacing:inherit;' . 'font-weight:inherit;font-style:inherit;', ], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 0.0, 'font-stretch' => 0.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'fgcolor' => '', 'bgcolor' => '', 'fontstyle' => '', 'border-collapse' => '', 'border-spacing' => '', 'attribute' => [ 'style' => 'font-size:13pt;font-stretch:expanded;letter-spacing:1mm;word-spacing:2mm;' . 'color:#123456;background-color:#abcdef;border-collapse:collapse;border-spacing:2mm 3mm;' . 'font-weight:700;font-style:italic;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[2]), "\$dom[2] must be set"); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta($dom[2]['fontsize'], $dom[1]['fontsize'], 0.0001); $this->assertEqualsWithDelta($dom[2]['font-stretch'], $dom[1]['font-stretch'], 0.0001); $this->assertEqualsWithDelta($dom[2]['letter-spacing'], $dom[1]['letter-spacing'], 0.0001); $this->assertEqualsWithDelta($dom[2]['word-spacing'], $dom[1]['word-spacing'], 0.0001); $this->assertSame($dom[2]['fgcolor'], $dom[1]['fgcolor']); $this->assertSame($dom[2]['bgcolor'], $dom[1]['bgcolor']); $this->assertSame($dom[2]['border-collapse'], $dom[1]['border-collapse']); $this->assertSame($dom[2]['fontstyle'], $dom[1]['fontstyle']); $spacing1 = $this->getHtmlNodeBorderSpacing($dom[1]); $spacing2 = $this->getHtmlNodeBorderSpacing($dom[2]); $this->assertEqualsWithDelta($spacing2['H'], $spacing1['H'], 0.0001); $this->assertEqualsWithDelta($spacing2['V'], $spacing1['V'], 0.0001); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesLineHeightInheritDoesNotFallThrough(): void { $obj = $this->getInternalTestObject(); $parentLineHeight = 1.8; $dom = [ 0 => $this->makeHtmlNode(['line-height' => $parentLineHeight]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'line-height' => 1.0, 'attribute' => ['style' => 'line-height:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); // Must equal the parent value exactly, not be recalculated $this->assertSame( $parentLineHeight, $dom[1]['line-height'], 'line-height:inherit must not fall through to default recalculation', ); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesLineHeightAndTextIndentInheritCaseInsensitive(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'line-height' => 1.7, 'text-indent' => 2.5, ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'line-height' => 1.0, 'text-indent' => 0.0, 'attribute' => ['style' => 'line-height:INHERIT;text-indent:InHeRiT;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(1.7, $dom[1]['line-height']); $this->assertSame(2.5, $dom[1]['text-indent']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesTextIndentInheritFallsBackToParentStyleValue(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'text-indent' => '', 'style' => ['text-indent' => '3mm'], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'text-indent' => 0.0, 'attribute' => ['style' => 'text-indent:inherit;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'text-indent' => 0.0, 'attribute' => ['style' => 'text-indent:3mm;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[2]), "\$dom[2] must be set"); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta($dom[2]['text-indent'], $dom[1]['text-indent'], 0.0001); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesLineHeightDefaultCases(): void { $obj = $this->getInternalTestObject(); $fontsize = 12.0; // pts // Case 1: percentage → dimensionless ratio = value / 100 $dom = [ 0 => $this->makeHtmlNode(['line-height' => 1.0]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => $fontsize, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'line-height:150%;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta(1.5, $dom[1]['line-height'], 0.001, 'line-height:150% must store ratio 1.5'); // Case 2: unitless number → ratio stored directly $dom[1] = $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => $fontsize, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'line-height' => 1.0, 'attribute' => ['style' => 'line-height:2;'], ]); $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta(2.0, $dom[1]['line-height'], 0.001, 'line-height:2 must store ratio 2.0'); // Case 3: absolute unit (24pt on a 12pt font) → ratio 2.0 $dom[1] = $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => $fontsize, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'line-height' => 1.0, 'attribute' => ['style' => 'line-height:24pt;'], ]); $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta( 2.0, $dom[1]['line-height'], 0.01, 'line-height:24pt on 12pt font must store ratio 2.0', ); // Case 4: 100% → ratio 1.0 $dom[1] = $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => $fontsize, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'line-height' => 0.0, 'attribute' => ['style' => 'line-height:100%;'], ]); $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta(1.0, $dom[1]['line-height'], 0.001, 'line-height:100% must store ratio 1.0'); // Case 5: 200% → ratio 2.0 $dom[1] = $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => $fontsize, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'line-height' => 0.0, 'attribute' => ['style' => 'line-height:200%;'], ]); $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertEqualsWithDelta(2.0, $dom[1]['line-height'], 0.001, 'line-height:200% must store ratio 2.0'); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesWhiteSpaceModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['white-space' => 'pre-wrap']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'white-space:pre-line;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'white-space:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('pre-line', $dom[1]['white-space']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('pre-wrap', $dom[2]['white-space']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesTextTransformAndWhiteSpaceInheritFallBackToParentStyleValues(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'text-transform' => '', 'white-space' => '', 'style' => [ 'text-transform' => 'uppercase', 'white-space' => 'pre-wrap', ], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-transform:inherit;white-space:inherit;'], 'text-transform' => '', 'white-space' => '', ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('uppercase', $dom[1]['text-transform']); $this->assertSame('pre-wrap', $dom[1]['white-space']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesWordSpacingModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['word-spacing' => 1.25]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => ['style' => 'word-spacing:2mm;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => ['style' => 'word-spacing:inherit;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 3.0, 'attribute' => ['style' => 'word-spacing:normal;'], ]), ]; $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->assertGreaterThan(0.0, $dom[1]['word-spacing']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame(1.25, $dom[2]['word-spacing']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame(0.0, $dom[3]['word-spacing']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesSpacingKeywordsCaseInsensitive(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'font-stretch' => 112.0, 'letter-spacing' => 0.75, 'word-spacing' => 1.5, ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'font-stretch' => 0.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => ['style' => 'font-stretch:INHERIT;letter-spacing:InHeRiT;word-spacing:INHERIT;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'font-stretch' => 0.0, 'letter-spacing' => 9.0, 'word-spacing' => 9.0, 'attribute' => ['style' => 'font-stretch:NoRmAl;letter-spacing:NoRmAl;word-spacing:NoRmAl;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(112.0, $dom[1]['font-stretch']); $this->assertSame(0.75, $dom[1]['letter-spacing']); $this->assertSame(1.5, $dom[1]['word-spacing']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame(100.0, $dom[2]['font-stretch']); $this->assertSame(0.0, $dom[2]['letter-spacing']); $this->assertSame(0.0, $dom[2]['word-spacing']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesListStylePositionModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['list-style-position' => 'inside']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => ['style' => 'list-style-position:outside;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'word-spacing' => 0.0, 'attribute' => ['style' => 'list-style-position:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('outside', $dom[1]['list-style-position']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('inside', $dom[2]['list-style-position']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesListStyleShorthandInherit(): void { $obj = $this->getInternalTestObject(); $parentImage = 'url(data:image/svg+xml;base64,PHN2Zz4=)'; $dom = [ 0 => $this->makeHtmlNode([ 'listtype' => 'square', 'list-style-position' => 'inside', 'list-style-image' => $parentImage, ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'list-style:InHeRiT;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'list-style:inherit;list-style-position:outside;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('square', $dom[1]['listtype']); $this->assertSame('inside', $dom[1]['list-style-position']); $this->assertArrayHasKey('list-style-image', $dom[1]); $listImage = $dom[1]['list-style-image'] ?? null; $this->assertIsString($listImage); $this->assertSame($parentImage, $listImage); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('square', $dom[2]['listtype']); $this->assertSame('outside', $dom[2]['list-style-position']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesListStyleShorthandInheritUsesParentStyleImageFallback(): void { $obj = $this->getInternalTestObject(); $parentImage = 'url(data:image/svg+xml;base64,PHN2Zz4=)'; $dom = [ 0 => $this->makeHtmlNode([ 'listtype' => 'disc', 'list-style-position' => 'inside', 'list-style-image' => '', 'style' => ['list-style-image' => $parentImage], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'list-style:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('disc', $dom[1]['listtype']); $this->assertSame('inside', $dom[1]['list-style-position']); $this->assertArrayHasKey('list-style-image', $dom[1]); $listImage = $dom[1]['list-style-image'] ?? null; $this->assertIsString($listImage); $this->assertSame($parentImage, $listImage); $this->assertSame($parentImage, $this->getHtmlNodeStyleString($dom[1], 'list-style-image')); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesListStyleInheritFallsBackToParentStyleTypeAndPosition(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'listtype' => '', 'list-style-position' => '', 'style' => [ 'list-style-type' => 'square', 'list-style-position' => 'inside', ], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'list-style:inherit;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'list-style-type:inherit;list-style-position:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('square', $dom[1]['listtype']); $this->assertSame('inside', $dom[1]['list-style-position']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('square', $dom[2]['listtype']); $this->assertSame('inside', $dom[2]['list-style-position']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesTextTransformModesAndInherit(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['text-transform' => 'uppercase']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-transform:capitalize;'], 'text-transform' => '', ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-transform:inherit;'], 'text-transform' => '', ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-transform:none;'], 'text-transform' => 'lowercase', ]), 4 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'text-transform:invalid;'], 'text-transform' => 'lowercase', ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 3, 0); $obj->parseHTMLStyleAttributes($dom, 4, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('capitalize', $dom[1]['text-transform']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('uppercase', $dom[2]['text-transform']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('', $dom[3]['text-transform']); assert(isset($dom[4]), "\$dom[4] must be set"); $this->assertSame('lowercase', $dom[4]['text-transform']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesListStyleImageInheritResolvesParentImageMarker(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $parentImage = 'url(data:image/svg+xml;base64,PHN2Zz4=)'; $dom = [ 0 => $this->makeHtmlNode(['list-style-image' => '', 'style' => []]), 1 => $this->makeHtmlNode([ 'value' => 'ul', 'opening' => true, 'parent' => 0, 'attribute' => ['style' => 'list-style-image:' . $parentImage . ';'], 'style' => [], ]), 2 => $this->makeHtmlNode([ 'value' => 'ul', 'opening' => true, 'parent' => 1, 'attribute' => ['style' => 'list-style-image:inherit;'], 'style' => [], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 1); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame($parentImage, $this->getHtmlNodeStyleString($dom[2], 'list-style-image')); $marker = $obj->exposeGetHTMLListMarkerTypeWithDom($dom, 2, false); $this->assertStringStartsWith('img|svg|', $marker); } /** * @throws \Throwable */ public function testAnchorInsideBoldPreservesBoldFontstyle(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); // Build a DOM node for that already has bold from a parent $dom = [ 0 => $this->makeHtmlNode(['fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'a', 'parent' => 0, 'fontstyle' => 'B', 'style' => [], 'attribute' => [], ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); // Both bold and underline must be present $this->assertStringContainsString('B', $dom[1]['fontstyle'], 'Bold must be preserved on inside '); $this->assertStringContainsString('U', $dom[1]['fontstyle'], 'Underline must be added on '); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesNumericFontWeightApplied(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'fontstyle' => '', 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'font-weight:700;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'fontstyle' => 'B', 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'font-weight:400;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertStringContainsString('B', $dom[1]['fontstyle'], 'font-weight:700 must add bold'); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertStringNotContainsString('B', $dom[2]['fontstyle'], 'font-weight:400 must remove bold'); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesFontWeightInheritApplied(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => 'B']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'fontstyle' => '', 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'font-weight:inherit;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'fontstyle' => 'B', 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'font-weight:normal;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 2, 'fontsize' => 10.0, 'fontstyle' => '', 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'font-weight:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 3, 2); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('B', $dom[1]['fontstyle']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('', $dom[2]['fontstyle']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('', $dom[3]['fontstyle']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesFontStyleInheritAndNormalApplied(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => 'I']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'fontstyle' => '', 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'font-style:inherit;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'fontstyle' => 'BI', 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'font-style:normal;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'fontstyle' => 'B', 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'font-style:oblique;'], ]), ]; $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('I', $dom[1]['fontstyle']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('B', $dom[2]['fontstyle']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('BI', $dom[3]['fontstyle']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesTextDecorationNoneAndInheritApplied(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['fontstyle' => 'UD']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontstyle' => 'BI', 'attribute' => ['style' => 'text-decoration:none;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontstyle' => 'BI', 'attribute' => ['style' => 'text-decoration:inherit;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'fontstyle' => 'B', 'attribute' => ['style' => 'text-decoration:underline overline underline;'], ]), ]; $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('BI', $dom[1]['fontstyle']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame('BIUD', $dom[2]['fontstyle']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('BUO', $dom[3]['fontstyle']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesIndividualPaddingAndMarginApplied(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'attribute' => ['style' => 'padding-top:5px;padding-left:10px;margin-top:3px;margin-right:4px;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $paddingTop = $this->getHtmlNodeBoxSide($dom[1], 'padding', 'T'); $paddingLeft = $this->getHtmlNodeBoxSide($dom[1], 'padding', 'L'); $this->assertGreaterThan(0.0, $paddingTop, 'padding-top must be applied'); $this->assertGreaterThan(0.0, $paddingLeft, 'padding-left must be applied'); $this->assertGreaterThan( $paddingTop, $paddingLeft, 'padding-left (10px) must be greater than padding-top (5px)', ); $this->assertGreaterThan(0.0, $this->getHtmlNodeBoxSide($dom[1], 'margin', 'T'), 'margin-top must be applied'); $this->assertGreaterThan( 0.0, $this->getHtmlNodeBoxSide($dom[1], 'margin', 'R'), 'margin-right must be applied', ); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesPaddingAndMarginInheritApplied(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $parentPadding = ['T' => 1.1, 'R' => 2.2, 'B' => 3.3, 'L' => 4.4]; $parentMargin = ['T' => 5.5, 'R' => 6.6, 'B' => 7.7, 'L' => 8.8]; $dom = [ 0 => $this->makeHtmlNode([ 'padding' => $parentPadding, 'margin' => $parentMargin, ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'padding:inherit;margin:inherit;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'padding:1px;padding-left:INHERIT;margin:2px;margin-right:InHeRiT;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame($parentPadding, $dom[1]['padding']); $this->assertSame($parentMargin, $dom[1]['margin']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertSame($parentPadding['L'], $this->getHtmlNodeBoxSide($dom[2], 'padding', 'L')); $this->assertSame($parentMargin['R'], $this->getHtmlNodeBoxSide($dom[2], 'margin', 'R')); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesFontShorthandInheritApplied(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode([ 'fontname' => 'times', 'fontsize' => 11.5, 'fontstyle' => 'BI', 'line-height' => 1.4, 'font-stretch' => 125.0, ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'font:inherit;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('times', $dom[1]['fontname']); $this->assertSame(11.5, $dom[1]['fontsize']); $this->assertSame('BI', $dom[1]['fontstyle']); $this->assertSame(1.4, $dom[1]['line-height']); $this->assertSame(125.0, $dom[1]['font-stretch']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesFontShorthandParsesCoreLonghands(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode([ 'fontsize' => 10.0, 'fontname' => 'helvetica', 'fontstyle' => '', 'line-height' => 1.0, 'font-stretch' => 100.0, ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'font:italic 700 14pt/1.5 "Times New Roman", serif;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[0]), "\$dom[0] must be set"); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertGreaterThan($dom[0]['fontsize'], $dom[1]['fontsize']); $this->assertSame('"Times New Roman", serif', $dom[1]['fontname']); $this->assertStringContainsString('I', $dom[1]['fontstyle']); $this->assertStringContainsString('B', $dom[1]['fontstyle']); $this->assertEqualsWithDelta(1.5, $dom[1]['line-height'], 0.0001); } /** * @throws \Throwable */ public function testGetHTMLFormFieldJSPropertiesMapsElementStyleToWidgetProperties(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $this->makeHtmlNode([ 'bgcolor' => '#eef3fb', 'align' => 'C', 'border' => [ 'LTRB' => [ 'lineColor' => '#336699', 'lineWidth' => 0.5, 'cssBorderStyle' => 'dashed', ], ], ]); $jsp = $obj->exposeGetHTMLFormFieldJSProperties([], 'text', $elm); $this->assertSame('#eef3fb', $jsp['fillColor'] ?? null); $this->assertSame('#336699', $jsp['strokeColor'] ?? null); $this->assertSame('dashed', $jsp['borderStyle'] ?? null); $this->assertSame('center', $jsp['alignment'] ?? null); $this->assertIsInt($jsp['lineWidth'] ?? null); $this->assertGreaterThanOrEqual(1, $jsp['lineWidth']); } /** * @throws \Throwable */ public function testGetHTMLFormFieldJSPropertiesKeepsNumberAlignmentPriority(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $this->makeHtmlNode([ 'align' => 'L', ]); $jsp = $obj->exposeGetHTMLFormFieldJSProperties([], 'number', $elm); $this->assertSame('right', $jsp['alignment'] ?? null); } /** * @throws \Throwable */ public function testGetHTMLCellRendersParagraphText(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('

Hello

', 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('
Hello
', 10, 12, 40, 20); /** @var array> $dests */ $dests = $this->getObjectProperty($obj, 'dests'); /** @var \Com\Tecnick\Pdf\Encrypt\Encrypt $encrypt */ $encrypt = $this->getObjectProperty($obj, 'encrypt'); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $name = $encrypt->encodeNameObject('sec-1'); $this->assertArrayHasKey($name, $dests); $dest = $dests[$name] ?? null; $this->assertIsArray($dest); $destPage = $dest['p'] ?? null; $this->assertIsInt($destPage); $this->assertSame($page->getPageID(), $destPage); } /** * @throws \Throwable */ public function testGetHTMLCellUsesStylesToDrawOuterCell(): 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, 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 . '

'; $region = $page->getRegion(); $obj->addHTMLCell(html: $html, posx: $region['RX'], posy: $region['RY'], width: $region['RW']); $allContent = ''; foreach ($page->getPages() as $pdata) { $allContent .= \implode("\n", $pdata['content']) . "\n"; } // Collect the baseline Y of every rendered text line (PDF points, origin // bottom-left so Y decreases down the page) and the consecutive gaps. $tdMatches = []; \preg_match_all('/[\d.\-]+ ([\d.\-]+) Td\b/', $allContent, $tdMatches); assert(isset($tdMatches[1]), 'Td Y capture group must be present'); $lineY = []; foreach ($tdMatches[1] as $yVal) { $y = \floatval($yVal); $lineY[(string) \round($y, 1)] = $y; } \rsort($lineY); $correctPitch = 0; $leakedPitch = 0; $prevY = null; foreach ($lineY as $y) { if ($prevY === null) { $prevY = $y; continue; } $gap = $prevY - $y; $prevY = $y; if ($gap <= 0.1 || $gap > 40.0) { // Same visual line (sub-fragments) or a page/region break jump. continue; } if ($gap >= 14.4 && $gap <= 15.6) { ++$correctPitch; // 12pt x 1.25 = 15pt body line } elseif ($gap >= 15.7 && $gap <= 17.3) { ++$leakedPitch; // the leaked ~16.87pt (13.5pt glyph box x 1.25) } } // Before the fix this paragraph produced five leaked ~16.87pt gaps and only // five correct 15pt gaps; after the fix the body is uniform 15pt. $this->assertSame( 0, $leakedPitch, 'Body line spacing must not carry the leaked ~16.87pt pitch from the tall inline run.', ); $this->assertGreaterThanOrEqual(8, $correctPitch, 'Most body lines must be spaced at the correct 15pt pitch.'); } /** * Build a two-column page layout on the given object and return the * geometry shared by the column-break re-anchor tests. * * @return array{leftMargin: float, topMargin: float, columnWidth: float, contentHeight: float, col2x: float} * * @throws \Throwable */ private function addTwoColumnPage(\Com\Tecnick\Pdf\Tcpdf $obj): array { $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; $col2x = $leftMargin + $columnWidth + $columnGap; $obj->addPage([ 'margin' => [ 'PL' => $leftMargin, 'PR' => $rightMargin, 'CT' => $topMargin, 'CB' => $bottomMargin, ], 'region' => [ [ 'RX' => $leftMargin, 'RY' => $topMargin, 'RW' => $columnWidth, 'RH' => $contentHeight, ], [ 'RX' => $col2x, 'RY' => $topMargin, 'RW' => $columnWidth, 'RH' => $contentHeight, ], ], ]); return [ 'leftMargin' => $leftMargin, 'topMargin' => $topMargin, 'columnWidth' => $columnWidth, 'contentHeight' => $contentHeight, 'col2x' => $col2x, ]; } /** * @throws \Throwable */ public function testParseHTMLTextReanchorsLineCursorAfterRegionBreakToSecondColumn(): void { // Regression: when the fragment itself triggers a region break, the // line-local state captured before the break (line origin X, offset, // available width) must be re-read from the updated cell context, or // the fragment renders at the previous region's X origin. $obj = $this->getBBoxProbeTestObject(); $this->initFont($obj); $geo = $this->addTwoColumnPage($obj); $obj->exposeInitHTMLCellContext($geo['leftMargin'], $geo['topMargin'], $geo['columnWidth'], 0.0); $obj->exposeResetBBoxTrace(); // A single unbreakable word: no line-split opportunity, so the whole // fragment must move to the next region through the willBreak path. $elm = $this->makeHtmlNode([ 'value' => 'ColumnBreakProbe', 'align' => 'L', ]); // Cursor at the bottom of the first column: one text line no longer // fits vertically, forcing the break into the second column region. $tpx = $geo['leftMargin']; $tpy = $geo['topMargin'] + $geo['contentHeight'] - 1.0; $tpw = $geo['columnWidth']; $tph = 0.0; $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $trace = $obj->exposeGetBBoxTrace(); $this->assertCount(1, $trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertEqualsWithDelta( $geo['col2x'], $trace[0]['in_x'], 0.05, 'Fragment must re-anchor to the second column X origin after the region break.', ); $this->assertEqualsWithDelta( $geo['topMargin'], $trace[0]['in_y'], 0.5, 'Fragment must render at the top of the new region.', ); $this->assertGreaterThanOrEqual( $geo['col2x'], $tpx, 'Cursor must advance from the new line origin, not the stale one.', ); } /** * @throws \Throwable */ public function testParseHTMLTextReanchorsAfterRegionBreakWithZeroMaxWidth(): void { // Same re-anchor regression as above, exercising the fallback width // branch: with no cell max width the post-break width is recomputed // from the line-local available width instead of the cell context. $obj = $this->getBBoxProbeTestObject(); $this->initFont($obj); $geo = $this->addTwoColumnPage($obj); $obj->exposeInitHTMLCellContext($geo['leftMargin'], $geo['topMargin'], 0.0, 0.0); $obj->exposeResetBBoxTrace(); $elm = $this->makeHtmlNode([ 'value' => 'ColumnBreakProbe', 'align' => 'L', ]); $tpx = $geo['leftMargin']; $tpy = $geo['topMargin'] + $geo['contentHeight'] - 1.0; $tpw = $geo['columnWidth']; $tph = 0.0; $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $trace = $obj->exposeGetBBoxTrace(); $this->assertCount(1, $trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertEqualsWithDelta( $geo['col2x'], $trace[0]['in_x'], 0.05, 'Fragment must re-anchor to the second column X origin after the region break.', ); $this->assertEqualsWithDelta( $geo['topMargin'], $trace[0]['in_y'], 0.5, 'Fragment must render at the top of the new region.', ); } /** * @throws \Throwable */ public function testAddHTMLCellReanchorsBreakingFragmentIntoSecondColumn(): void { // Regression: in a multi-column layout, the unbreakable fragment that // overflows the first column must render at the second column's X // origin, not at the first column's X over already-rendered content. $obj = $this->getBBoxProbeTestObject(); $this->initFont($obj); $geo = $this->addTwoColumnPage($obj); $obj->exposeResetBBoxTrace(); // Single-word paragraphs have no line-split opportunity: the one that // hits the first column's bottom moves as a whole to the next region. $html = ''; for ($i = 0; $i < 45; ++$i) { $html .= '

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 list */ private function htmlReRectangleTops(string $content): array { $tops = []; $matches = []; $num = '(-?\d+(?:\.\d+)?)'; if (\preg_match_all( '/' . $num . '\s+' . $num . '\s+' . $num . '\s+' . $num . '\s+re(?![A-Za-z])/', $content, $matches, \PREG_SET_ORDER, )) { foreach ($matches as $match) { if (!isset($match[2], $match[4]) || !\is_numeric($match[2]) || !\is_numeric($match[4])) { continue; } $posy = (float) $match[2]; $rheight = (float) $match[4]; $tops[] = \max($posy, $posy + $rheight); } } return $tops; } /** * Regression: a bounded HTML cell placed in the bottom page margin must * render its table at its absolute position inside the margin, not reset it * to the top of the content region. * * @throws \Throwable */ public function testGetHTMLCellBoundedTableInBottomMarginStaysAtAbsolutePosition(): void { $obj = $this->getTestObject(); $this->addBottomMarginTestPage($obj, false); $contentBottom = 297.0 - 100.0; // region bottom edge (mm from top) $posy = $contentBottom + 50.0 - 4.0; // ~243mm, inside the bottom margin $html = '
' . '

BOTTOM BORDER
text inside the bottom margin.

' . '
Table Cell BOTTOM
' . '
'; $out = $obj->getHTMLCell($html, 20.0, $posy, 170.0, 20.0); $tops = $this->htmlReRectangleTops($out); $this->assertNotEmpty($tops, 'Expected the table border to be rendered.'); // In PDF space (origin bottom-left) the content-region bottom edge sits // at this Y; anything in the bottom margin is at or below it (smaller Y). $regionBottomPt = $obj->toYPoints($contentBottom); foreach ($tops as $top) { $this->assertLessThanOrEqual( $regionBottomPt + 1.0, $top, 'Table border must stay inside the bottom margin, not reset to the content-region top.', ); } } /** * Regression: an unbounded HTML cell placed in the bottom margin with * automatic page break disabled must not add a page nor yank the table back * to the content-region top (pageBreak() is a no-op when there is nowhere to * break to, so the cursor must be left untouched). * * @throws \Throwable */ public function testAddHTMLCellUnboundedTableInBottomMarginWithoutAutoBreakStaysOnPage(): void { $obj = $this->getTestObject(); $this->addBottomMarginTestPage($obj, false); /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */ $pageObj = $this->getObjectProperty($obj, 'page'); $beforePages = \count($pageObj->getPages()); $contentBottom = 297.0 - 100.0; $posy = $contentBottom + 50.0 - 4.0; $html = '

BOTTOM

Table Cell BOTTOM
'; $obj->addHTMLCell($html, 20.0, $posy, 170.0, 0.0); $this->assertSame($beforePages, \count($pageObj->getPages()), 'No page must be added when autobreak is off.'); $tops = $this->htmlReRectangleTops($this->collectPageContent($pageObj)); $this->assertNotEmpty($tops); $regionBottomPt = $obj->toYPoints($contentBottom); foreach ($tops as $top) { $this->assertLessThanOrEqual( $regionBottomPt + 1.0, $top, 'Table must stay in the bottom margin when autobreak is off.', ); } } /** * Counterpart to the no-autobreak case: an unbounded cell overflowing the * region with autobreak enabled must still legitimately break to a new page. * * @throws \Throwable */ public function testAddHTMLCellUnboundedTableInBottomMarginWithAutoBreakMovesToNewPage(): void { $obj = $this->getTestObject(); $this->addBottomMarginTestPage($obj, true); /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */ $pageObj = $this->getObjectProperty($obj, 'page'); $beforePages = \count($pageObj->getPages()); $contentBottom = 297.0 - 100.0; $posy = $contentBottom + 50.0 - 4.0; $html = '

BOTTOM

Table Cell BOTTOM
'; $obj->addHTMLCell($html, 20.0, $posy, 170.0, 0.0); $this->assertGreaterThan( $beforePages, \count($pageObj->getPages()), 'Unbounded overflow with autobreak on must add a page.', ); } /** * Regression: a bounded cell must not paginate even when autobreak is on - * the explicit height makes it an absolutely-positioned box that stays put. * * @throws \Throwable */ public function testAddHTMLCellBoundedTableInBottomMarginWithAutoBreakDoesNotBreak(): void { $obj = $this->getTestObject(); $this->addBottomMarginTestPage($obj, true); /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */ $pageObj = $this->getObjectProperty($obj, 'page'); $beforePages = \count($pageObj->getPages()); $contentBottom = 297.0 - 100.0; $posy = $contentBottom + 50.0 - 4.0; $html = '

BOTTOM

Table Cell BOTTOM
'; $obj->addHTMLCell($html, 20.0, $posy, 170.0, 20.0); $this->assertSame( $beforePages, \count($pageObj->getPages()), 'A bounded cell must not break even when autobreak is on.', ); $tops = $this->htmlReRectangleTops($this->collectPageContent($pageObj)); $this->assertNotEmpty($tops); $regionBottomPt = $obj->toYPoints($contentBottom); foreach ($tops as $top) { $this->assertLessThanOrEqual($regionBottomPt + 1.0, $top); } } /** * Regression: a bounded box whose height extends past the page edge renders * in place on a single page (overflow is the caller's responsibility for an * absolutely-positioned box) rather than paginating. * * @throws \Throwable */ public function testAddHTMLCellBoundedTableExceedingPageHeightRendersInPlaceOnSinglePage(): void { $obj = $this->getTestObject(); $this->addBottomMarginTestPage($obj, true); /** @var \Com\Tecnick\Pdf\Page\Page $pageObj */ $pageObj = $this->getObjectProperty($obj, 'page'); $beforePages = \count($pageObj->getPages()); $contentBottom = 297.0 - 100.0; $posy = $contentBottom + 50.0 - 4.0; // 243mm; with height 80 the box bottom (323mm) exceeds the 297mm page $html = '

BOTTOM

Table Cell BOTTOM
'; $obj->addHTMLCell($html, 20.0, $posy, 170.0, 80.0); $this->assertSame( $beforePages, \count($pageObj->getPages()), 'A box exceeding the page height must not paginate.', ); $tops = $this->htmlReRectangleTops($this->collectPageContent($pageObj)); $this->assertNotEmpty($tops); $regionBottomPt = $obj->toYPoints($contentBottom); foreach ($tops as $top) { $this->assertLessThanOrEqual($regionBottomPt + 1.0, $top); } } /** * @throws \Throwable */ public function testAddHTMLCellLongOrderedListSpansMultiplePages(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $beforePages = \count($page->getPages()); $items = ''; for ($i = 0; $i < 200; ++$i) { $items .= '
  • Item ' . $i . ' Lorem ipsum dolor sit amet consectetur
  • '; } $obj->addHTMLCell('
      ' . $items . '
    ', 20, 10, 150, 0); $afterPages = \count($page->getPages()); $this->assertGreaterThan($beforePages, $afterPages); } /** * @throws \Throwable */ public function testAddHTMLCellListPageBreakPreservesSectionOrderInsideStyledBlock(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $items = ''; for ($i = 0; $i < 220; ++$i) { $items .= '
  • Item ' . $i . ' with long text to force wrapping and page flow inside the list block.
  • '; } $html = '' . '
    ' . '

    SECTION4

    ' . '
      ' . $items . '
    ' . '
    ' . '
    ' . '

    SECTION6

    ' . '

    After list block

    ' . '
    '; $obj->addHTMLCell($html, 20, 10, 150, 0); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $pages = $page->getPages(); $content = ''; foreach ($pages as $pdata) { $pageContent = $pdata['content']; $content .= "\n" . \implode("\n", $pageContent); } $this->assertStringContainsString('SECTION4', $content); $this->assertStringContainsString('SECTION6', $content); $pos4 = \strpos($content, 'SECTION4'); $pos6 = \strpos($content, 'SECTION6'); $this->assertNotFalse($pos4); $this->assertNotFalse($pos6); $this->assertLessThan($pos6, $pos4, 'Expected SECTION4 to be emitted before SECTION6.'); } /** * @throws \Throwable */ public function testAddHTMLCellLongTableSpansMultiplePages(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $beforePages = \count($page->getPages()); $rows = ''; for ($i = 0; $i < 200; ++$i) { $rows .= 'Row ' . $i . 'Lorem ipsum dolor sit amet'; } $obj->addHTMLCell('' . $rows . '
    AB
    ', 20, 10, 150, 0); $afterPages = \count($page->getPages()); $this->assertGreaterThan($beforePages, $afterPages); } /** * @throws \Throwable */ public function testAddHTMLCellTwelvePointMixedInlineTableDoesNotBreakAfterFirstRow(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $fontfile = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/dejavu/dejavusans.json'); $font = $obj->font->insert($obj->pon, 'dejavusans', '', 12, null, null, $fontfile); $obj->page->addContent($font['out']); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $beforePages = \count($page->getPages()); $spanWords = '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'; $plainWords = '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'; $html = '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . '
    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 . '
    '; $obj->addHTMLCell($html, 20, 10, 180, 0); $afterPages = \count($page->getPages()); $this->assertSame($beforePages, $afterPages); } /** * @throws \Throwable */ public function testAddHTMLCellStyledBlockSpansMultiplePages(): 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.

    '; $html = '
    ' . \str_repeat($chunk, 150) . '
    '; $obj->addHTMLCell($html, 20, 10, 150, 0); $afterPages = \count($page->getPages()); $this->assertGreaterThan($beforePages, $afterPages); } /** * @throws \Throwable */ public function testAddHTMLCellSoftHyphenBreakUsesVisibleHyphenOnWrappedLine(): 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, ]; $obj->addHTMLCell('

    de­nounce

    ', 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 = '
    1. ' . 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, ' . 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ' . 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris ' . 'nisi ut aliquip ex ea commodo consequat.' . '
    '; $obj->exposeResetBBoxTrace(); $obj->getHTMLCell($html, 15, 20, 50, 0); $trace = $obj->exposeGetBBoxTrace(); // We should have text fragments (marker + text content) $this->assertGreaterThanOrEqual(1, \count($trace)); // Find the first text fragment (after the marker "1.") // Look for traces with actual text content (marker is usually "1" or "1.") $textFragments = []; foreach ($trace as $entry) { $txt = $entry['txt']; // Skip empty strings and just the marker if ($txt !== '' && !\preg_match('/^1[.)]?$/', $txt)) { $textFragments[] = $entry; } } // If we have multiple text fragments, verify first line is indented if (\count($textFragments) >= 2) { $first = $textFragments[0] ?? null; $second = $textFragments[1] ?? null; $this->assertIsArray($first); $this->assertIsArray($second); $firstX = $first['bbox_x']; $secondX = $second['bbox_x']; // First line X should be greater than second line X (indented) $this->assertGreaterThan($secondX, $firstX, 'First line should be indented relative to continuation lines'); } else { // Even if not wrapped, we should have detected the text-indent value $this->assertGreaterThanOrEqual(1, \count($textFragments), 'Should have at least one text fragment'); } } /** * @throws \Throwable */ public function testGetHTMLCellUsesCellPaddingForContentPosition(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $nopad = [ '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, ]; $pad = [ 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 12.0], 'borderpos' => \Com\Tecnick\Pdf\Base::BORDERPOS_DEFAULT, ]; $plainOut = $obj->getHTMLCell('

    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 = '' . '
    ' . 'BEFIM' . 'Ssmspstsg' . 'ttud
    frm
    ' . '
    ' . '
    q
    ' . '
    dv
    ' . '
    dt
    dd
    ' . '

    1

    2

    3

    4

    5
    6
    ' . '


    ' . 'img' . '' . '
    1. o1
    ' . '
    • u1
    ' . '' . '' . '

    psubsup

    ' . '
    pre
    ' . '
    H
    T
    ' . 'TH' . '' . '' . ''; $out = $obj->getHTMLCell($html, 0, 0, 80, 60); $this->assertNotSame('', $out); $this->assertStringContainsString('BT', $out); } /** * @throws \Throwable */ public function testGetHTMLCellTracksBBoxForRepeatedSmallTagText(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $html = 'normal small text normal small text'; $out = $obj->getHTMLCell($html, 0, 0, 200, 20); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertCount(4, $trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertSame('normal ', $trace[0]['txt']); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertSame('small text', $trace[1]['txt']); assert(isset($trace[2]), "\$trace[2] must be set"); $this->assertSame(' normal ', $trace[2]['txt']); assert(isset($trace[3]), "\$trace[3] must be set"); $this->assertSame('small text', $trace[3]['txt']); $this->assertEqualsWithDelta(10.0, $trace[0]['font_size'], 1e-9); $this->assertEqualsWithDelta(6.666666666666666, $trace[1]['font_size'], 1e-9); $this->assertEqualsWithDelta(10.0, $trace[2]['font_size'], 1e-9); $this->assertEqualsWithDelta(6.666666666666666, $trace[3]['font_size'], 1e-9); $this->assertGreaterThan($trace[0]['bbox_end_x'], $trace[1]['bbox_end_x']); $this->assertGreaterThan($trace[1]['bbox_end_x'], $trace[2]['bbox_end_x']); $this->assertGreaterThan($trace[2]['bbox_end_x'], $trace[3]['bbox_end_x']); $this->assertEqualsWithDelta($trace[0]['bbox_end_x'], $trace[1]['bbox_x'], 1e-9); $this->assertEqualsWithDelta($trace[1]['bbox_end_x'], $trace[2]['bbox_x'], 1e-9); $this->assertEqualsWithDelta($trace[2]['bbox_end_x'], $trace[3]['bbox_x'], 1e-9); $this->assertEqualsWithDelta($trace[1]['bbox_w'], $trace[3]['bbox_w'], 1e-9); } /** * @throws \Throwable */ public function testGetHTMLCellRendersTopLevelTableOuterBorder(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '
    X
    '; $out = $obj->getHTMLCell($html, 0, 0, 80, 30); $this->assertNotSame('', $out); $this->assertMatchesRegularExpression('/\sre\s+s\b/s', $out); } /** * @throws \Throwable */ public function testGetHTMLCellTableBorderZeroAttributeDrawsNoBorder(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '
    AB
    '; $out = $obj->getHTMLCell($html, 0, 0, 80, 30); $this->assertNotSame('', $out); $this->assertDoesNotMatchRegularExpression('/\sre\s+s\b/s', $out); $this->assertDoesNotMatchRegularExpression('/\sl\s+S\b/s', $out); } /** * @throws \Throwable */ public function testGetHTMLCellTableCSSBorderZeroDrawsNoBorder(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '
    AB
    '; $out = $obj->getHTMLCell($html, 0, 0, 80, 30); $this->assertNotSame('', $out); $this->assertDoesNotMatchRegularExpression('/\sre\s+s\b/s', $out); $this->assertDoesNotMatchRegularExpression('/\sl\s+S\b/s', $out); } /** * @throws \Throwable */ public function testGetHTMLCellBorderWidthZeroLonghandOverridesShorthand(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '
    A
    '; $out = $obj->getHTMLCell($html, 0, 0, 80, 30); $this->assertNotSame('', $out); $this->assertDoesNotMatchRegularExpression('/\sre\s+s\b/s', $out); $this->assertDoesNotMatchRegularExpression('/\sl\s+S\b/s', $out); } /** * @throws \Throwable */ public function testGetHTMLCellBorderStyleNoneLonghandOverridesShorthand(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '
    A
    '; $out = $obj->getHTMLCell($html, 0, 0, 80, 30); $this->assertNotSame('', $out); $this->assertDoesNotMatchRegularExpression('/\sre\s+s\b/s', $out); $this->assertDoesNotMatchRegularExpression('/\sl\s+S\b/s', $out); } /** * @throws \Throwable */ public function testGetHTMLCellBorderShorthandAfterLonghandWins(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '
    A
    '; $out = $obj->getHTMLCell($html, 0, 0, 80, 30); $this->assertNotSame('', $out); $this->assertMatchesRegularExpression('/\sre\s+s\b/s', $out); } /** * @throws \Throwable */ public function testGetHTMLCellBorderColorLonghandAppliesOverShorthand(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '
    A
    '; $out = $obj->getHTMLCell($html, 0, 0, 80, 30); $this->assertNotSame('', $out); $this->assertMatchesRegularExpression('/\sl\s+S\b/s', $out); $this->assertStringContainsString('1.000000 0.000000 0.000000 RG', $out); } /** * @throws \Throwable */ public function testGetHTMLCellCellBorderWidthZeroCancelsTableBorderAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '
    A
    '; $out = $obj->getHTMLCell($html, 0, 0, 80, 30); $this->assertNotSame('', $out); // Only the table outer frame must be stroked; the cell border is cancelled. $matches = []; $this->assertSame(1, \preg_match_all('/\sre\s+s\b/s', $out, $matches)); $this->assertDoesNotMatchRegularExpression('/\sl\s+S\b/s', $out); } /** * @throws \Throwable */ public function testGetHTMLCellCentersMixedDirectionInlineRunAsOneLine(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $cellWidth = 150.0; $html = '
    ' . 'The words “מזל [mazel] טוב [tov]' . '” mean “Congratulations!”
    '; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, 0, 0, $cellWidth, 40); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertCount(3, $trace); assert(isset($trace[0]), "\$trace[0] must be set"); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertEqualsWithDelta($trace[0]['bbox_end_x'], $trace[1]['bbox_x'], 1e-9); assert(isset($trace[2]), "\$trace[2] must be set"); $this->assertEqualsWithDelta($trace[1]['bbox_end_x'], $trace[2]['bbox_x'], 1e-9); $lineLeft = $trace[0]['bbox_x']; $lineRight = $trace[2]['bbox_end_x']; $this->assertEqualsWithDelta($cellWidth / 2, ($lineLeft + $lineRight) / 2, 1e-9); } /** * @throws \Throwable */ public function testGetHTMLCellCentersWrappedInlineSpansPerLine(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $cellWidth = 60.0; $html = '
    ' . 'Alfa Bravo Charlie Delta ' . 'Echo Foxtrot Golf Hotel' . '
    '; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, 0, 0, $cellWidth, 0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); /** @var array $lines */ $lines = []; foreach ($trace as $frag) { $key = \sprintf('%.6f', $frag['bbox_y']); if (!isset($lines[$key])) { $lines[$key] = [ 'left' => $frag['bbox_x'], 'right' => $frag['bbox_end_x'], ]; continue; } $line = $lines[$key] ?? ['left' => 0.0, 'right' => 0.0]; $lines[$key] = [ 'left' => \min($line['left'], $frag['bbox_x']), 'right' => \max($line['right'], $frag['bbox_end_x']), ]; } $lineboxes = \array_values($lines); $this->assertGreaterThanOrEqual(2, \count($lineboxes)); $cellCenter = $cellWidth / 2; $checklines = \min(3, \count($lineboxes)); for ($idx = 0; $idx < $checklines; ++$idx) { $line = $this->getLineBox($lineboxes, $idx); $this->assertEqualsWithDelta($cellCenter, ($line['left'] + $line['right']) / 2, 1.0); } $firstLine = $this->getLineBox($lineboxes, 0); // The first wrapped line must not be left-flush when centered. $this->assertGreaterThan(0.5, $firstLine['left']); } /** * @throws \Throwable */ public function testGetHTMLCellRightAlignedWrappedInlineSpansUseMultipleLines(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $cellWidth = 150.0; $html = '
    ' . '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' . '
    '; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, 0, 0, $cellWidth, 0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); /** @var array $linekeys */ $linekeys = []; foreach ($trace as $frag) { $linekeys[\sprintf('%.6f', $frag['bbox_y'])] = true; } $this->assertGreaterThanOrEqual(2, \count($linekeys)); } /** * @throws \Throwable */ public function testGetHTMLCellTablePercentWidthsKeepFirstColumnTextInsideCell(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $html = '' . '' . '' . '' . '' . '
    Gesch\u{00E4}ftsf\u{00FC}hrer Egon Schrempp Amtsgericht Stuttgart HRB 1234RIGHTCOL
    '; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, 0, 0, 120, 0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); $rightIdx = null; for ($idx = 0; $idx < \count($trace); ++$idx) { $row = $this->getTraceRow($trace, $idx); if ($row['txt'] !== 'RIGHTCOL') { continue; } $rightIdx = $idx; break; } $this->assertNotNull($rightIdx, 'Unable to locate the second column text fragment in the trace.'); $rightRow = $this->getTraceRow($trace, (int) $rightIdx); $rightStartX = $rightRow['bbox_x']; $maxFirstColumnEndX = 0.0; for ($idx = 0; $idx < (int) $rightIdx; ++$idx) { $row = $this->getTraceRow($trace, $idx); $txt = $row['txt']; if ($txt === '') { continue; } $maxFirstColumnEndX = \max($maxFirstColumnEndX, $row['bbox_end_x']); } $this->assertGreaterThan(0.0, $maxFirstColumnEndX); $this->assertLessThanOrEqual( $rightStartX + 0.01, $maxFirstColumnEndX, 'First-column text overflowed into the second column.', ); } /** * @throws \Throwable */ #[DataProvider('tableLineRegressionProvider')] public function testGetHTMLCellTableLineRegression( string $lineid, string $cellHtml, int $expectedLines, string $expectedFirstTxt, ?string $expectedSecondTxt, ): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $html = '' . $cellHtml . '
    '; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, 0, 0, 150, 0); $this->assertNotSame('', $out, 'Rendered output should not be empty for row ' . $lineid); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace, 'BBox trace should not be empty for row ' . $lineid); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertSame($expectedFirstTxt, $trace[0]['txt']); if ($expectedSecondTxt !== null) { $this->assertGreaterThanOrEqual(2, \count($trace)); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertStringContainsString($expectedSecondTxt, $trace[1]['txt']); } /** @var array $linekeys */ $linekeys = []; /** @var array $lineOrder */ $lineOrder = []; foreach ($trace as $frag) { $liney = $frag['bbox_y']; $key = \sprintf('%.6f', $liney); if (!isset($linekeys[$key])) { $linekeys[$key] = true; $lineOrder[] = $liney; } } $this->assertCount($expectedLines, $linekeys, 'Unexpected wrapped line count for row ' . $lineid); // Ensure line progression is monotonic (no backwards jumps/overlap in render order). for ($idx = 1; $idx < \count($lineOrder); ++$idx) { $prevLineY = $lineOrder[$idx - 1] ?? null; $currLineY = $lineOrder[$idx] ?? null; if (!\is_float($prevLineY) || !\is_float($currLineY)) { $this->fail('Invalid line y progression data.'); } $this->assertGreaterThanOrEqual( $prevLineY, $currLineY, 'Non-monotonic line y progression detected for row ' . $lineid, ); } } /** * @return array */ public static function tableLineRegressionProvider(): array { $line1Spans = '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'; $line2Text = ' A1 example link column span. ' . 'One two tree four five six seven eight nine ten.'; return [ [ '1L', '1L ' . $line1Spans . '', 2, '1L', null, ], [ '1C', '1C ' . $line1Spans . '', 2, '1C', null, ], [ '1R', '1R ' . $line1Spans . '', 2, '1R', null, ], [ '2L', '2L' . $line2Text . '', 1, '2L', 'A1 ex', ], [ '2C', '2C' . $line2Text . '', 1, '2C', 'A1 ex', ], [ '2R', '2R' . $line2Text . '', 1, '2R', 'A1 ex', ], [ '3L', '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', 2, '3L small text', 'Alfa', ], [ '3C', '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', 2, '3C small text', 'Alfa', ], [ '3R', '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', 2, '3R small text', 'Alfa', ], ]; } /** * @throws \Throwable */ #[DataProvider('smallPrefixAlignmentProvider')] public function testGetHTMLCellMixedSmallPrefixKeepsFollowingTextOnFirstLine(string $align): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $cellWidth = 150.0; $html = '
    ' . '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' . '
    '; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, 0, 0, $cellWidth, 0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); $this->assertGreaterThanOrEqual(2, \count($trace)); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertSame('3X small text', \trim($trace[0]['txt'])); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertStringContainsString('Alfa', $trace[1]['txt']); // The text after should start on the same line (or higher baseline-adjusted) // and not after a forced line advance. $this->assertLessThanOrEqual($trace[0]['in_y'] + 0.001, $trace[1]['in_y']); } /** * @throws \Throwable */ public function testGetHTMLCellExampleTableSmallPrefixCenterRightKeepsFollowingTextOnSameLine(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $html = '' . '' . '' . '' . '
    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
    '; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, 0, 0, 150, 0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); foreach (['3C small text', '3R small text'] as $label) { $smallIdx = null; foreach ($trace as $idx => $item) { if (\trim($item['txt']) !== $label) { continue; } $smallIdx = $idx; break; } $this->assertNotNull($smallIdx, 'Missing trace fragment: ' . $label); $nextIdx = null; for ($idx = (int) $smallIdx + 1; $idx < \count($trace); ++$idx) { $row = $this->getTraceRow($trace, $idx); if (\trim($row['txt']) === '') { continue; } $nextIdx = $idx; break; } $this->assertNotNull($nextIdx, 'Missing follow-up fragment for: ' . $label); $nextRow = $this->getTraceRow($trace, (int) $nextIdx); $this->assertStringContainsString('Alfa', $nextRow['txt']); $smallRow = $this->getTraceRow($trace, (int) $smallIdx); $this->assertLessThanOrEqual( $smallRow['in_y'] + 0.001, $nextRow['in_y'], 'Text after ' . $label . ' moved to a new line.', ); } } /** * @return array */ public static function smallPrefixAlignmentProvider(): array { return [ ['center'], ['right'], ]; } /** * @throws \Throwable */ public function testGetHTMLCellContinuesInlineEmAfterMultiLineWrappedTextOnSameLine(): void { // Regression: a long plain-text fragment that internally wraps to a new // visual line must not push the immediately following inline content // (here "(Sierra-Tango)") onto a third line. The "(" already // landed on the second line and "Sierra-Tango" must continue right // after it, keeping the whole paragraph on two lines. $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $html = '

    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 array $linekeys */ $linekeys = []; foreach ($trace as $entry) { $linekeys[\sprintf('%.3f', $entry['bbox_y'])] = true; } $this->assertCount( 1, $linekeys, 'Paragraph must render on exactly two lines: the em-wrapped continuation must not start a third line.', ); // Both em fragments and their surrounding parentheses share the second line. $this->assertEqualsWithDelta( $sierraEntry['bbox_y'], $xrayEntry['bbox_y'], 0.01, 'Both em fragments must share the second line.', ); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesLineHeightToWrappedContinuationLines(): void { $text = 'Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett Kilo Lima Mike November Oscar Papa ' . 'Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett Kilo Lima Mike November Oscar Papa'; $height100 = $this->measureWrappedLineHeightDelta($text, '100%'); $height200 = $this->measureWrappedLineHeightDelta($text, '200%'); $this->assertGreaterThan( $height100 + 1.0, $height200, 'line-height must affect continuation lines created by automatic wraps.', ); } /** * Probe derived from examples/E069_html_line_height.php selected block. * * @throws \Throwable */ public function testGetHTMLCellLineHeightExampleBlockRendersExpectedLineYPositions(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $html = '
    ' . '

    D1_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

    ' . '
    '; $out = $obj->getHTMLCell($html, 20, 20, 150, 0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); $d1 = $this->getTraceTokenBBoxY($trace, 'D1_DEFAULT'); $d2 = $this->getTraceTokenBBoxY($trace, 'D2_DEFAULT'); $d3 = $this->getTraceTokenBBoxY($trace, 'D3_DEFAULT'); $n1 = $this->getTraceTokenBBoxY($trace, 'N1_NORMAL'); $n2 = $this->getTraceTokenBBoxY($trace, 'N2_NORMAL'); $n3 = $this->getTraceTokenBBoxY($trace, 'N3_NORMAL'); $h100_1 = $this->getTraceTokenBBoxY($trace, 'H1_100'); $h100_2 = $this->getTraceTokenBBoxY($trace, 'H2_100'); $h100_3 = $this->getTraceTokenBBoxY($trace, 'H3_100'); $h050_1 = $this->getTraceTokenBBoxY($trace, 'H1_050'); $h050_2 = $this->getTraceTokenBBoxY($trace, 'H2_050'); $h050_3 = $this->getTraceTokenBBoxY($trace, 'H3_050'); $h150_1 = $this->getTraceTokenBBoxY($trace, 'H1_150'); $h150_2 = $this->getTraceTokenBBoxY($trace, 'H2_150'); $h150_3 = $this->getTraceTokenBBoxY($trace, 'H3_150'); $defaultStep = $d2 - $d1; $defaultStep2 = $d3 - $d2; $normalDouble = $n2 - $n1; $normalSingle = $n3 - $n2; $h100Double = $h100_2 - $h100_1; $h100Single = $h100_3 - $h100_2; $h050Double = $h050_2 - $h050_1; $h050Single = $h050_3 - $h050_2; $h150Double = $h150_2 - $h150_1; $h150Single = $h150_3 - $h150_2; $this->assertGreaterThan(0.1, $defaultStep); $this->assertGreaterThan(0.1, $normalSingle); $this->assertGreaterThan(0.1, $h100Single); $this->assertGreaterThan(0.1, $h050Single); $this->assertGreaterThan(0.1, $h150Single); $this->assertEqualsWithDelta($defaultStep, $defaultStep2, 0.25, 'Default paragraph line steps must be stable.'); $this->assertGreaterThan($normalSingle, $normalDouble, 'normal +

    must add extra vertical distance.'); $this->assertGreaterThan($h100Single, $h100Double, '100% +

    must add extra vertical distance.'); $this->assertGreaterThan($h050Single, $h050Double, '50% +

    must add extra vertical distance.'); $this->assertGreaterThan($h150Single, $h150Double, '150% +

    must add extra vertical distance.'); $this->assertGreaterThan($h050Single + 0.5, $h100Single, '100% line step must be larger than 50%.'); $this->assertGreaterThan($h100Single + 0.5, $h150Single, '150% line step must be larger than 100%.'); $this->assertEqualsWithDelta( 5.0, $h050Single, 0.75, 'line-height:50% with 10mm font-size should step about 5mm.', ); $this->assertEqualsWithDelta( 10.0, $h100Single, 0.75, 'line-height:100% with 10mm font-size should step about 10mm.', ); $this->assertEqualsWithDelta( 15.0, $h150Single, 0.9, 'line-height:150% with 10mm font-size should step about 15mm.', ); $this->assertGreaterThan( $h100Single + 0.2, $defaultStep, 'Default step should be larger than explicit 100% step.', ); $this->assertEqualsWithDelta( $defaultStep, $normalSingle, 0.6, 'default and normal should stay visually close.', ); } /** * Probe derived from examples/E069_html_line_height.php BR-vs-DIV block. * * @throws \Throwable */ public function testGetHTMLCellLineHeightKeepsDivContinuationAlignedWithBrSteps(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $html = '
    ' . '
    B1
    B2
    B3
    B4
    ' . '
    D1
    D2
    D3
    D4
    ' . '
    '; $out = $obj->getHTMLCell($html, 20, 20, 150, 0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); $b1 = $this->getTraceTokenBBoxY($trace, 'B1'); $b2 = $this->getTraceTokenBBoxY($trace, 'B2'); $b3 = $this->getTraceTokenBBoxY($trace, 'B3'); $b4 = $this->getTraceTokenBBoxY($trace, 'B4'); $d1 = $this->getTraceTokenBBoxY($trace, 'D1'); $d2 = $this->getTraceTokenBBoxY($trace, 'D2'); $d3 = $this->getTraceTokenBBoxY($trace, 'D3'); $d4 = $this->getTraceTokenBBoxY($trace, 'D4'); $brStep1 = $b2 - $b1; $brStep2 = $b3 - $b2; $brStep3 = $b4 - $b3; $divStep1 = $d2 - $d1; $divStep2 = $d3 - $d2; $divStep3 = $d4 - $d3; $this->assertEqualsWithDelta(20.0, $brStep1, 0.9, 'BR baseline step should match 20mm line-height.'); $this->assertEqualsWithDelta(20.0, $brStep2, 0.9, 'BR baseline step should match 20mm line-height.'); $this->assertEqualsWithDelta(20.0, $brStep3, 0.9, 'BR baseline step should match 20mm line-height.'); $this->assertEqualsWithDelta(20.0, $divStep1, 0.9, 'DIV probe baseline step should match 20mm line-height.'); $this->assertEqualsWithDelta(20.0, $divStep2, 0.9, 'DIV probe baseline step should match 20mm line-height.'); $this->assertEqualsWithDelta( $divStep2, $divStep3, 0.9, 'Final line rendered by nested DIV must keep the same vertical step as previous BR-separated lines.', ); $this->assertEqualsWithDelta( $brStep3, $divStep3, 0.9, 'Nested DIV final line step must match BR final-line stepping.', ); } /** * @throws \Throwable */ public function testGetHTMLCellContinuesPlainTextAfterEmFollowedByLongMultiLineRun(): void { // Regression: when an inline ends mid-line and the next plain-text // fragment is long enough to internally wrap to multiple lines, its // leading non-space chunk (here ")") must continue right after the // on the SAME line. The previous logic considered the line // "deep" because the italic bumped linebottom by a sub-millimeter // font-metric drift, and force-wrapped the whole continuation // fragment to a fresh line — pushing ")" to a new line by itself. $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $html = '

    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('AB', 0, 0, 30, 10); $this->assertNotSame('', $out); $annotation = $this->getAnnotationList($obj); $this->assertCount(2, $annotation); } /** * @throws \Throwable */ public function testGetHTMLCellRendersOrderedListMarkers(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('
    1. One
    2. Two
    ', 0, 0, 30, 12); $this->assertNotSame('', $out); $this->assertStringContainsString('(1.)', $out); $this->assertStringContainsString('(2.)', $out); $this->assertStringContainsString('One', $out); $this->assertStringContainsString('Two', $out); } /** * @throws \Throwable */ public function testGetHTMLCellRendersUnorderedListMarkers(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('
    • First
    • Second
    ', 0, 0, 30, 12); $this->assertNotSame('', $out); $this->assertStringContainsString('First', $out); $this->assertStringContainsString('Second', $out); $this->assertStringContainsString('BT', $out); } /** * @throws \Throwable */ public function testNestedListItemIndentsIncrementally(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $originx = 20.0; $obj->exposeInitHTMLCellContext($originx, 100.0, 150.0, 0.0); $elm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, '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], ]); $tpx = $originx; $tpy = 100.0; $tpw = 150.0; $tph = 0.0; // Push depth-1 list. $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENol', $elm, $tpx, $tpy, $tpw, $tph); // Open depth-1 li: tpx must advance by exactly one indentWidth. $tpx = $originx; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENli', $elm, $tpx, $tpy, $tpw, $tph); $tpxDepth1 = $tpx; $indentWidth = $tpxDepth1 - $originx; $this->assertGreaterThan(0.0, $indentWidth, 'depth-1 li should add a positive indent'); // Push depth-2 list inside the depth-1 li, then open a depth-2 li. // openHTMLBlock will reset tpx to the updated originx (= tpxDepth1). $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENol', $elm, $tpx, $tpy, $tpw, $tph); $tpxBeforeDepth2 = $tpx; // equals tpxDepth1 after openHTMLBlock $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENli', $elm, $tpx, $tpy, $tpw, $tph); $indent2 = $tpx - $tpxBeforeDepth2; // Each successive level must add exactly one indentWidth, not depth * indentWidth. $this->assertEqualsWithDelta( $indentWidth, $indent2, 0.001, 'depth-2 li should increment tpx by the same indentWidth as depth-1 li', ); } /** * @throws \Throwable */ public function testListItemUsesListCssIndentOverrideWhenPresent(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $obj->addHTMLCell('
    1. Probe item baseline
    ', 20, 20, 120, 0); $defaultX = null; foreach ($obj->exposeGetBBoxTrace() as $entry) { if (!\str_contains($entry['txt'], 'Probe item baseline')) { continue; } $defaultX = $entry['in_x']; break; } $this->assertNotNull($defaultX); $obj->exposeResetBBoxTrace(); $obj->addHTMLCell('
    1. Probe item baseline
    ', 20, 20, 120, 0); $cssX = null; foreach ($obj->exposeGetBBoxTrace() as $entry) { if (!\str_contains($entry['txt'], 'Probe item baseline')) { continue; } $cssX = $entry['in_x']; break; } $this->assertNotNull($cssX); $this->assertLessThan($defaultX, $cssX); } /** * @throws \Throwable */ public function testListItemCssIndentOverrideTakesPrecedenceOverListLevel(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $obj->addHTMLCell('
    1. Probe precedence
    ', 20, 20, 120, 0); $listX = null; foreach ($obj->exposeGetBBoxTrace() as $entry) { if (!\str_contains($entry['txt'], 'Probe precedence')) { continue; } $listX = $entry['in_x']; break; } $this->assertNotNull($listX); $obj->exposeResetBBoxTrace(); $obj->addHTMLCell( '
    1. Probe precedence
    ', 20, 20, 120, 0, ); $liX = null; foreach ($obj->exposeGetBBoxTrace() as $entry) { if (!\str_contains($entry['txt'], 'Probe precedence')) { continue; } $liX = $entry['in_x']; break; } $this->assertNotNull($liX); $this->assertGreaterThan($listX, $liX); } /** * @throws \Throwable */ public function testNestedListDefaultIndentKeepsSameLevelStableAndInnerDeeper(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $obj->addHTMLCell('
    1. OUTER_A
      • INNER_B
    2. OUTER_C
    ', 20, 20, 120, 0); $outerAX = null; $innerBX = null; $outerCX = null; foreach ($obj->exposeGetBBoxTrace() as $entry) { $txt = $entry['txt']; if ($outerAX === null && \str_contains($txt, 'OUTER_A')) { $outerAX = $entry['in_x']; } if ($innerBX === null && \str_contains($txt, 'INNER_B')) { $innerBX = $entry['in_x']; } if ($outerCX === null && \str_contains($txt, 'OUTER_C')) { $outerCX = $entry['in_x']; } } $this->assertNotNull($outerAX); $this->assertNotNull($innerBX); $this->assertNotNull($outerCX); $this->assertGreaterThan($outerAX, $innerBX); $this->assertEqualsWithDelta($outerAX, $outerCX, 0.001); } /** * @throws \Throwable */ public function testNestedListDepthCssOverrideChangesOnlyTargetDepthIndent(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $obj->addHTMLCell( '
    1. OUTER_D
      • INNER_E
    ', 20, 20, 120, 0, ); $outerBaseX = null; $innerBaseX = null; foreach ($obj->exposeGetBBoxTrace() as $entry) { $txt = $entry['txt']; if ($outerBaseX === null && \str_contains($txt, 'OUTER_D')) { $outerBaseX = $entry['in_x']; } if ($innerBaseX === null && \str_contains($txt, 'INNER_E')) { $innerBaseX = $entry['in_x']; } } $this->assertNotNull($outerBaseX); $this->assertNotNull($innerBaseX); $obj->exposeResetBBoxTrace(); $obj->addHTMLCell( '
    1. OUTER_D
      • INNER_E
    ', 20, 20, 120, 0, ); $outerOverrideX = null; $innerOverrideX = null; foreach ($obj->exposeGetBBoxTrace() as $entry) { $txt = $entry['txt']; if ($outerOverrideX === null && \str_contains($txt, 'OUTER_D')) { $outerOverrideX = $entry['in_x']; } if ($innerOverrideX === null && \str_contains($txt, 'INNER_E')) { $innerOverrideX = $entry['in_x']; } } $this->assertNotNull($outerOverrideX); $this->assertNotNull($innerOverrideX); $this->assertEqualsWithDelta($outerBaseX, $outerOverrideX, 0.001); $this->assertLessThan($innerBaseX, $innerOverrideX); } /** * @throws \Throwable */ public function testListItemInsideMarkerDoesNotShrinkContentBox(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $originx = 20.0; $maxwidth = 150.0; $obj->exposeInitHTMLCellContext($originx, 100.0, $maxwidth, 0.0); $elm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'inside', '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], ]); $tpx = $originx; $tpy = 100.0; $tpw = $maxwidth; $tph = 0.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENol', $elm, $tpx, $tpy, $tpw, $tph); $before = $obj->exposeGetHTMLRenderContext(); $out = $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENli', $elm, $tpx, $tpy, $tpw, $tph); $after = $obj->exposeGetHTMLRenderContext(); $this->assertNotSame('', $out); $this->assertGreaterThan($originx, $tpx); $this->assertSame($before['cellctx']['originx'], $after['cellctx']['originx']); $this->assertSame($before['cellctx']['maxwidth'], $after['cellctx']['maxwidth']); $this->assertSame($maxwidth, $tpw); } /** * @throws \Throwable */ public function testListItemOutsideMarkerShrinksContentBox(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $originx = 20.0; $maxwidth = 150.0; $obj->exposeInitHTMLCellContext($originx, 100.0, $maxwidth, 0.0); $elm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'outside', '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], ]); $tpx = $originx; $tpy = 100.0; $tpw = $maxwidth; $tph = 0.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENol', $elm, $tpx, $tpy, $tpw, $tph); $before = $obj->exposeGetHTMLRenderContext(); $out = $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENli', $elm, $tpx, $tpy, $tpw, $tph); $after = $obj->exposeGetHTMLRenderContext(); $this->assertNotSame('', $out); $this->assertGreaterThan($originx, $tpx); $this->assertGreaterThan($before['cellctx']['originx'], $after['cellctx']['originx']); $this->assertLessThan($before['cellctx']['maxwidth'], $after['cellctx']['maxwidth']); $this->assertLessThan($maxwidth, $tpw); } /** * @throws \Throwable */ public function testMarkerColorIsAppliedToTextBasedMarkers(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); // Render ordered list with marker color style // Just verify no errors occur during rendering with marker styles $obj->addHTMLCell( '
    1. ITEM_1
    2. ITEM_2
    ', 20, 20, 120, 0, ); $this->assertNotSame('', $obj->getOutPDFString()); } /** * @throws \Throwable */ public function testListItemInsideMarkerUsesSameBulletAnchorAsOutside(): void { $insideObj = $this->getInternalTestObject(); $this->initFontAndPage($insideObj); $insideElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'inside', '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], ]); $insideObj->exposeInitHTMLCellContext(20.0, 100.0, 150.0, 0.0); $insideTpx = 20.0; $insideTpy = 100.0; $insideTpw = 150.0; $insideTph = 0.0; $insideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENol', $insideElm, $insideTpx, $insideTpy, $insideTpw, $insideTph, ); $insideOut = $insideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENli', $insideElm, $insideTpx, $insideTpy, $insideTpw, $insideTph, ); $outsideObj = $this->getInternalTestObject(); $this->initFontAndPage($outsideObj); $outsideElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'outside', '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], ]); $outsideObj->exposeInitHTMLCellContext(20.0, 100.0, 150.0, 0.0); $outsideTpx = 20.0; $outsideTpy = 100.0; $outsideTpw = 150.0; $outsideTph = 0.0; $outsideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENol', $outsideElm, $outsideTpx, $outsideTpy, $outsideTpw, $outsideTph, ); $outsideOut = $outsideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENli', $outsideElm, $outsideTpx, $outsideTpy, $outsideTpw, $outsideTph, ); $this->assertNotSame('', $insideOut); $this->assertNotSame('', $outsideOut); $this->assertNotSame($outsideOut, $insideOut); } /** * @throws \Throwable */ public function testInsideListIndentOverrideIsNotTrimmedByOutsideMarkerSpacingRule(): void { $insideObj = $this->getInternalTestObject(); $this->initFontAndPage($insideObj); $insideElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'inside', 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 9.0], 'style' => ['padding-left' => '9mm'], ]); $insideObj->exposeInitHTMLCellContext(20.0, 100.0, 150.0, 0.0); $insideTpx = 20.0; $insideTpy = 100.0; $insideTpw = 150.0; $insideTph = 0.0; $insideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENol', $insideElm, $insideTpx, $insideTpy, $insideTpw, $insideTph, ); $insideBefore = $insideTpx; $insideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENli', $insideElm, $insideTpx, $insideTpy, $insideTpw, $insideTph, ); $insideAdvance = $insideTpx - $insideBefore; $outsideObj = $this->getInternalTestObject(); $this->initFontAndPage($outsideObj); $outsideElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'outside', 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 9.0], 'style' => ['padding-left' => '9mm'], ]); $outsideObj->exposeInitHTMLCellContext(20.0, 100.0, 150.0, 0.0); $outsideTpx = 20.0; $outsideTpy = 100.0; $outsideTpw = 150.0; $outsideTph = 0.0; $outsideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENol', $outsideElm, $outsideTpx, $outsideTpy, $outsideTpw, $outsideTph, ); $outsideBefore = $outsideTpx; $outsideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENli', $outsideElm, $outsideTpx, $outsideTpy, $outsideTpw, $outsideTph, ); $outsideAdvance = $outsideTpx - $outsideBefore; $this->assertGreaterThan(0.0, $outsideAdvance); $this->assertGreaterThan($outsideAdvance, $insideAdvance); } /** * @throws \Throwable */ public function testOutsideSmallListIndentOverrideIsNotCanceledByTrimWorkaround(): void { $baseObj = $this->getInternalTestObject(); $this->initFontAndPage($baseObj); $baseElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'outside', '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], ]); $baseObj->exposeInitHTMLCellContext(20.0, 100.0, 150.0, 0.0); $baseTpx = 20.0; $baseTpy = 100.0; $baseTpw = 150.0; $baseTph = 0.0; $baseObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENul', $baseElm, $baseTpx, $baseTpy, $baseTpw, $baseTph, ); $baseBefore = $baseTpx; $baseObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENli', $baseElm, $baseTpx, $baseTpy, $baseTpw, $baseTph, ); $baseAdvance = $baseTpx - $baseBefore; $smallObj = $this->getInternalTestObject(); $this->initFontAndPage($smallObj); $smallElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'outside', 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 1.5], 'style' => ['padding-left' => '1.5mm'], ]); $smallObj->exposeInitHTMLCellContext(20.0, 100.0, 150.0, 0.0); $smallTpx = 20.0; $smallTpy = 100.0; $smallTpw = 150.0; $smallTph = 0.0; $smallObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENul', $smallElm, $smallTpx, $smallTpy, $smallTpw, $smallTph, ); $smallBefore = $smallTpx; $smallObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENli', $smallElm, $smallTpx, $smallTpy, $smallTpw, $smallTph, ); $smallAdvance = $smallTpx - $smallBefore; $this->assertGreaterThan(0.0, $smallAdvance); $this->assertLessThan($baseAdvance, $smallAdvance); } /** * @throws \Throwable */ public function testListItemInsidePositionAddsExtraTextOffsetComparedToOutside(): void { $insideObj = $this->getInternalTestObject(); $this->initFontAndPage($insideObj); $insideElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'inside', '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], ]); $insideObj->exposeInitHTMLCellContext(20.0, 100.0, 150.0, 0.0); $insideTpx = 20.0; $insideTpy = 100.0; $insideTpw = 150.0; $insideTph = 0.0; $insideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENul', $insideElm, $insideTpx, $insideTpy, $insideTpw, $insideTph, ); $insideBefore = $insideTpx; $insideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENli', $insideElm, $insideTpx, $insideTpy, $insideTpw, $insideTph, ); $insideAdvance = $insideTpx - $insideBefore; $outsideObj = $this->getInternalTestObject(); $this->initFontAndPage($outsideObj); $outsideElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'list-style-position' => 'outside', '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], ]); $outsideObj->exposeInitHTMLCellContext(20.0, 100.0, 150.0, 0.0); $outsideTpx = 20.0; $outsideTpy = 100.0; $outsideTpw = 150.0; $outsideTph = 0.0; $outsideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENul', $outsideElm, $outsideTpx, $outsideTpy, $outsideTpw, $outsideTph, ); $outsideBefore = $outsideTpx; $outsideObj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENli', $outsideElm, $outsideTpx, $outsideTpy, $outsideTpw, $outsideTph, ); $outsideAdvance = $outsideTpx - $outsideBefore; $this->assertGreaterThan($outsideAdvance, $insideAdvance); } /** * @throws \Throwable */ public function testMarkerColorIsAppliedToDiscMarker(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); // Create a simple ul with disc marker and marker color style $obj->addHTMLCell('
    • ITEM_1
    ', 20, 20, 120, 0); // Just verify no errors occur during rendering with marker styles // (Full PDF color rendering would require inspecting PDF ops, which is complex) $this->assertNotSame('', $obj->getOutPDFString()); } /** * @throws \Throwable */ public function testUnsupportedMarkerPropertiesAreIgnored(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); // Marker styles with unsupported properties (text-decoration, margin, etc.) // should be silently ignored without errors $obj->addHTMLCell( '' . '
    1. ITEM_1
    ', 20, 20, 120, 0, ); // If we get here without errors, unsupported properties were properly handled $this->assertNotSame('', $obj->getOutPDFString()); } /** * @throws \Throwable */ public function testClassBasedMarkerSelectorAttachesFilteredMarkerStyleToLi(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = $obj->exposeGetHTMLDOM( '' . '
    1. Item
    ', ); $liNode = null; foreach ($dom as $node) { if (!($node['value'] === 'li' && !empty($node['opening']))) { continue; } $liNode = $node; break; } $this->assertNotNull($liNode); $this->assertArrayHasKey('attribute', $liNode); $this->assertIsArray($liNode['attribute']); $this->assertArrayHasKey('pseudo-marker-style', $liNode['attribute']); $markerStyle = $this->getHtmlNodeAttrMap($liNode, 'pseudo-marker-style'); $this->assertNotSame([], $markerStyle); $this->assertSame('red', $markerStyle['color'] ?? null); $this->assertSame('bold', $markerStyle['font-weight'] ?? null); $this->assertArrayNotHasKey('text-decoration', $markerStyle); } /** * @throws \Throwable */ public function testListStyleImageCSSPropertyIsParsedWithoutErrors(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); // list-style-image CSS should be accepted and parsed without errors // (Full image loading/rendering is deferred to future phases) $listImageDataUri = 'data:image/svg+xml;base64,' . 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4IiBoZWlnaHQ9Ijgi' . 'PjxjaXJjbGUgY3g9IjQiIGN5PSI0IiByPSI0IiBmaWxsPSJyZWQiLz48L3N2Zz4='; $obj->addHTMLCell( '
    • Custom bullet image
    ', 20, 20, 120, 0, ); $this->assertNotSame('', $obj->getOutPDFString()); } /** * @throws \Throwable */ public function testListStyleImageResolvesToImageMarkerType(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $svg = 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmci' . 'IHdpZHRoPSI4IiBoZWlnaHQ9IjgiPgogIDxjaXJjbGUgY3g9IjQiIGN5PSI0IiByPSIzIiBmaWxsPSJyZWQi' . 'Lz4KPC9zdmc+'; $dom = [ 0 => $this->makeHtmlNode([ 'value' => 'ul', 'style' => ['list-style-image' => 'url(data:image/svg+xml;base64,' . $svg . ')'], 'attribute' => [], 'listtype' => '', ]), ]; $marker = $obj->exposeGetHTMLListMarkerTypeWithDom($dom, 0, false); $this->assertStringStartsWith('img|svg|', $marker); $this->assertStringContainsString('|@getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['value' => 'root', 'opening' => true, 'parent' => 0]), 1 => $this->makeHtmlNode([ 'value' => 'ul', 'opening' => true, 'parent' => 0, 'attribute' => [ 'style' => 'list-style-image:url(data:image/svg+xml;base64,PHN2Zz4=);color:red', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('url(data:image/svg+xml;base64,PHN2Zz4=)', $this->getHtmlNodeStyleString( $dom[1], 'list-style-image', )); $this->assertSame('red', $this->getHtmlNodeStyleString($dom[1], 'color')); } /** * @throws \Throwable */ public function testListStyleImageCssClassResolvesImageMarkerFromDomPipeline(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $html = '' . '
    • Item
    '; $dom = $obj->exposeGetHTMLDOM($html); $ulKey = -1; foreach ($dom as $key => $node) { if (empty($node['opening']) || $node['value'] !== 'ul') { continue; } $ulKey = (int) $key; break; } $this->assertGreaterThanOrEqual(0, $ulKey); $marker = $obj->exposeGetHTMLListMarkerTypeWithDom($dom, $ulKey, false); $this->assertStringStartsWith('img|svg|', $marker); } /** * @throws \Throwable */ public function testNestedListStyleImageClassResolvesForNestedUl(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $html = '' . '
    • A
      • B
    '; $dom = $obj->exposeGetHTMLDOM($html); $markers = []; foreach ($dom as $key => $node) { if (empty($node['opening']) || $node['value'] !== 'ul') { continue; } $markers[] = $obj->exposeGetHTMLListMarkerTypeWithDom($dom, (int) $key, false); } $this->assertCount(2, $markers); assert(isset($markers[0]), "\$markers[0] must be set"); $this->assertStringStartsWith('img|svg|', $markers[0]); assert(isset($markers[1]), "\$markers[1] must be set"); $this->assertStringStartsWith('img|svg|', $markers[1]); } /** @throws \Throwable */ public function testIsLastHtmlStyleDeclarationPropertyRespectsDeclarationOrder(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'attribute' => [ 'style' => 'background-color: #f00; background: #00f;', ], ]), ]; $this->assertFalse($obj->exposeIsLastHTMLStyleDeclarationProperty( $dom, 0, 'background-color', ['background-color', 'background'], )); $this->assertTrue($obj->exposeIsLastHTMLStyleDeclarationProperty( $dom, 0, 'background', ['background-color', 'background'], )); $domNoStyle = [0 => $this->makeHtmlNode(['attribute' => []])]; $this->assertTrue($obj->exposeIsLastHTMLStyleDeclarationProperty( $domNoStyle, 0, 'background', ['background-color', 'background'], )); } /** @throws \Throwable */ public function testCurrentHtmlListIndentWidthPrefersActiveListIndent(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $baseCtx = $obj->exposeGetHTMLRenderContext(); $baseCtx['liststack'] = []; $baseCtx['dom'] = []; $defaultIndent = $obj->exposeGetCurrentHTMLListIndentWidthWithContext($baseCtx); $this->assertGreaterThan(0.0, $defaultIndent); $ctxWithIndent = $baseCtx; $ctxWithIndent['liststack'] = [['count' => 0, 'indent' => 9.75, 'ordered' => false, 'type' => 'disc']]; $this->assertSame(9.75, $obj->exposeGetCurrentHTMLListIndentWidthWithContext($ctxWithIndent)); $ctxWithZeroIndent = $baseCtx; $ctxWithZeroIndent['liststack'] = [['count' => 0, 'indent' => 0.0, 'ordered' => false, 'type' => 'disc']]; $this->assertEqualsWithDelta( $defaultIndent, $obj->exposeGetCurrentHTMLListIndentWidthWithContext($ctxWithZeroIndent), 0.0001, ); } /** @throws \Throwable */ public function testPdfUaListNumberingMapsKnownValuesAndTagFallbacks(): void { $obj = $this->getInternalTestObject(); $this->assertSame('UpperRoman', $obj->exposeGetPdfUaListNumbering([ 'value' => 'ol', 'listtype' => 'upper-roman', ])); $this->assertSame('LowerAlpha', $obj->exposeGetPdfUaListNumbering(['value' => 'ol', 'listtype' => 'a'])); $this->assertSame('Circle', $obj->exposeGetPdfUaListNumbering(['value' => 'ul', 'listtype' => 'circle'])); $this->assertSame('Disc', $obj->exposeGetPdfUaListNumbering(['value' => 'ul', 'listtype' => ''])); $this->assertSame('Decimal', $obj->exposeGetPdfUaListNumbering(['value' => 'ol', 'listtype' => ''])); $this->assertSame('', $obj->exposeGetPdfUaListNumbering(['value' => 'div', 'listtype' => 'none'])); } /** @throws \Throwable */ public function testExpandHtmlBorderQuadValuesSupportsTokenGroups(): void { $obj = $this->getInternalTestObject(); $this->assertSame( ['T' => '1px', 'R' => '1px', 'B' => '1px', 'L' => '1px'], $obj->exposeExpandHTMLBorderQuadValues('1px'), ); $this->assertSame( ['T' => '1px', 'R' => '2px', 'B' => '1px', 'L' => '2px'], $obj->exposeExpandHTMLBorderQuadValues('1px 2px'), ); $this->assertSame( ['T' => '1px', 'R' => '2px', 'B' => '3px', 'L' => '2px'], $obj->exposeExpandHTMLBorderQuadValues('1px 2px 3px'), ); $this->assertSame( ['T' => '1px', 'R' => '2px', 'B' => '3px', 'L' => '4px'], $obj->exposeExpandHTMLBorderQuadValues('1px 2px 3px 4px'), ); $this->assertSame(['T' => '', 'R' => '', 'B' => '', 'L' => ''], $obj->exposeExpandHTMLBorderQuadValues(' ')); } /** @throws \Throwable */ public function testShouldHideEmptyTableCellDependsOnCollapseModeAndBuffer(): void { $obj = $this->getInternalTestObject(); $elm = $this->makeHtmlNode(['empty-cells' => 'hide']); $cell = $this->makeHtmlCellContext(); $this->assertTrue($obj->exposeShouldHideHTMLEmptyTableCell( $this->makeHtmlTableState(['collapse' => false]), $elm, $cell, )); $this->assertFalse($obj->exposeShouldHideHTMLEmptyTableCell( $this->makeHtmlTableState(['collapse' => true]), $elm, $cell, )); $this->assertFalse($obj->exposeShouldHideHTMLEmptyTableCell( $this->makeHtmlTableState(['collapse' => false]), $elm, $this->makeHtmlCellContext('drawn'), )); $this->assertFalse($obj->exposeShouldHideHTMLEmptyTableCell( $this->makeHtmlTableState(['collapse' => false]), $this->makeHtmlNode(['empty-cells' => 'show']), $cell, )); } /** @throws \Throwable */ public function testParseHtmlStyleBorderSpacingHandlesZeroInheritAndExplicitValues(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode([ 'style' => ['border-spacing' => '2 4'], 'border-spacing' => ['H' => 2.0, 'V' => 4.0], ]), 1 => $this->makeHtmlNode([ 'style' => ['border-spacing' => 'inherit'], 'parent' => 0, ]), 2 => $this->makeHtmlNode([ 'style' => ['border-spacing' => '0'], 'parent' => 0, ]), 3 => $this->makeHtmlNode([ 'style' => ['border-spacing' => '3 5'], 'parent' => 0, ]), ]; $obj->exposeParseHTMLStyleBorderSpacingProperty($dom, 1, 0); $obj->exposeParseHTMLStyleBorderSpacingProperty($dom, 2, 0); $obj->exposeParseHTMLStyleBorderSpacingProperty($dom, 3, 0); if (!isset($dom[1], $dom[2], $dom[3])) { $this->fail('Expected parsed DOM nodes at keys 1, 2 and 3.'); } $this->assertSame(['H' => 2.0, 'V' => 4.0], $this->getHtmlNodeBorderSpacing($dom[1])); $this->assertSame(['H' => 0.0, 'V' => 0.0], $this->getHtmlNodeBorderSpacing($dom[2])); $this->assertNotSame(['H' => 0.0, 'V' => 0.0], $this->getHtmlNodeBorderSpacing($dom[3])); } /** @throws \Throwable */ public function testBreakInsidePropertiesSetNobrFlags(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'style' => ['page-break-inside' => 'avoid'], 'attribute' => [], ]), 1 => $this->makeHtmlNode([ 'style' => ['break-inside' => 'avoid'], 'attribute' => [], ]), ]; $obj->exposeParseHTMLStylePageBreakInsideProperty($dom, 0); $obj->exposeParseHTMLStyleBreakInsideAliasProperty($dom, 1); if (!isset($dom[0], $dom[1])) { $this->fail('Expected parsed DOM nodes at keys 0 and 1.'); } $this->assertSame('true', $this->getHtmlNodeAttrString($dom[0], 'nobr')); $this->assertSame('true', $this->getHtmlNodeAttrString($dom[1], 'nobr')); } /** @throws \Throwable */ public function testParseHtmlStyleBackgroundPrefersLastDeclaredProperty(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'bgcolor' => '#00ff00', 'style' => ['background-color' => '#00ff00'], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'background-color: #ff0000; background: #0000ff;'], 'style' => ['background-color' => '#ff0000', 'background' => '#0000ff'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'background: #0000ff; background-color: #ff0000;'], 'style' => ['background' => '#0000ff', 'background-color' => '#ff0000'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'attribute' => ['style' => 'background: inherit;'], 'style' => ['background' => 'inherit'], ]), ]; $obj->exposeParseHTMLStyleBackgroundProperty($dom, 1, 0); $obj->exposeParseHTMLStyleBackgroundProperty($dom, 2, 0); $obj->exposeParseHTMLStyleBackgroundProperty($dom, 3, 0); if (!isset($dom[1], $dom[2], $dom[3])) { $this->fail('Expected parsed DOM nodes at keys 1, 2 and 3.'); } $this->assertSame('rgb(0%,0%,100%)', $dom[1]['bgcolor']); $this->assertSame('rgb(100%,0%,0%)', $dom[2]['bgcolor']); $this->assertSame('#00ff00', $dom[3]['bgcolor']); } /** @throws \Throwable */ public function testParseHtmlStyleTableLayoutCaptionSideAndEmptyCellsInheritance(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'table-layout' => 'fixed', 'caption-side' => 'bottom', 'empty-cells' => 'hide', 'style' => ['table-layout' => 'fixed', 'caption-side' => 'bottom', 'empty-cells' => 'hide'], ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'style' => ['table-layout' => 'inherit', 'caption-side' => 'inherit', 'empty-cells' => 'inherit'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'style' => ['table-layout' => 'auto', 'caption-side' => 'top', 'empty-cells' => 'show'], ]), ]; $obj->exposeParseHTMLStyleTableLayoutProperty($dom, 1, 0); $obj->exposeParseHTMLStyleCaptionSideProperty($dom, 1, 0); $obj->exposeParseHTMLStyleEmptyCellsProperty($dom, 1, 0); $obj->exposeParseHTMLStyleTableLayoutProperty($dom, 2, 0); $obj->exposeParseHTMLStyleCaptionSideProperty($dom, 2, 0); $obj->exposeParseHTMLStyleEmptyCellsProperty($dom, 2, 0); if (!isset($dom[1], $dom[2])) { $this->fail('Expected parsed DOM nodes at keys 1 and 2.'); } $this->assertSame('fixed', $dom[1]['table-layout']); $this->assertSame('bottom', $dom[1]['caption-side']); $this->assertSame('hide', $dom[1]['empty-cells']); $this->assertSame('auto', $dom[2]['table-layout']); $this->assertSame('top', $dom[2]['caption-side']); $this->assertSame('show', $dom[2]['empty-cells']); } /** @throws \Throwable */ public function testParseHtmlStyleInheritFallbackReadsParentStyleWhenFieldUnset(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'style' => ['table-layout' => 'fixed', 'caption-side' => 'bottom', 'empty-cells' => 'hide'], 'table-layout' => '', 'caption-side' => '', 'empty-cells' => '', ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'style' => ['table-layout' => 'inherit', 'caption-side' => 'inherit', 'empty-cells' => 'inherit'], ]), ]; $obj->exposeParseHTMLStyleTableLayoutProperty($dom, 1, 0); $obj->exposeParseHTMLStyleCaptionSideProperty($dom, 1, 0); $obj->exposeParseHTMLStyleEmptyCellsProperty($dom, 1, 0); if (!isset($dom[1])) { $this->fail('Expected parsed DOM node at key 1.'); } $this->assertSame('fixed', $dom[1]['table-layout']); $this->assertSame('bottom', $dom[1]['caption-side']); $this->assertSame('hide', $dom[1]['empty-cells']); } /** @throws \Throwable */ public function testParseHtmlStyleFontShorthandExtractsStyleWeightStretchSizeAndFamily(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'style' => [ 'font' => 'italic 700 condensed 12pt/1.4 "Helvetica Neue", sans-serif', ], ]), ]; $obj->exposeParseHTMLStyleFontShorthandProperty($dom, 0); $style = $dom[0]['style'] ?? []; $this->assertSame('italic', $style['font-style'] ?? ''); $this->assertSame('700', $style['font-weight'] ?? ''); $this->assertSame('condensed', $style['font-stretch'] ?? ''); $this->assertSame('12pt', $style['font-size'] ?? ''); $this->assertSame('1.4', $style['line-height'] ?? ''); $this->assertSame('"Helvetica Neue", sans-serif', $style['font-family'] ?? ''); } /** @throws \Throwable */ public function testParseHtmlStyleFontFamilyResolvesInheritAndExplicitFamily(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['fontname' => 'times']), 1 => $this->makeHtmlNode([ 'parent' => 0, 'style' => ['font-family' => 'inherit'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'style' => ['font-family' => 'Courier New, monospace'], ]), ]; $obj->exposeParseHTMLStyleFontFamilyProperty($dom, 1, 0); $obj->exposeParseHTMLStyleFontFamilyProperty($dom, 2, 0); if (!isset($dom[1], $dom[2])) { $this->fail('Expected parsed DOM nodes at keys 1 and 2.'); } $this->assertSame('times', $dom[1]['fontname']); $this->assertSame('Courier New, monospace', $dom[2]['fontname']); } /** @throws \Throwable */ public function testParseHtmlStyleListPropertiesHandleInheritAndExplicitValues(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode([ 'listtype' => 'square', 'list-style-position' => 'inside', 'style' => [ 'list-style-type' => 'square', 'list-style-position' => 'inside', 'list-style-image' => 'url(parent.svg)', ], 'list-style-image' => 'url(parent.svg)', ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'style' => [ 'list-style' => 'inherit', 'list-style-type' => 'inherit', 'list-style-position' => 'inherit', 'list-style-image' => 'inherit', ], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'style' => [ 'list-style-type' => 'upper-roman', 'list-style-position' => 'outside', 'list-style-image' => 'url(child.svg)', ], ]), ]; $obj->exposeParseHTMLStyleListStyleShorthandProperty($dom, 1, 0); $obj->exposeParseHTMLStyleListStyleTypeProperty($dom, 1, 0); $obj->exposeParseHTMLStyleListStylePositionProperty($dom, 1, 0); $obj->exposeParseHTMLStyleListStyleImageProperty($dom, 1, 0); $obj->exposeParseHTMLStyleListStyleTypeProperty($dom, 2, 0); $obj->exposeParseHTMLStyleListStylePositionProperty($dom, 2, 0); $obj->exposeParseHTMLStyleListStyleImageProperty($dom, 2, 0); if (!isset($dom[1], $dom[2])) { $this->fail('Expected parsed DOM nodes at keys 1 and 2.'); } $this->assertSame('square', $dom[1]['listtype']); $this->assertSame('inside', $dom[1]['list-style-position']); $this->assertSame('url(parent.svg)', $dom[1]['list-style-image'] ?? null); $this->assertSame('upper-roman', $dom[2]['listtype']); $this->assertSame('outside', $dom[2]['list-style-position']); $this->assertSame('url(child.svg)', $dom[2]['list-style-image'] ?? null); } /** * @throws \Throwable */ public function testGetHTMLCellRendersBasicTableCells(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '
    H1H2
    AB
    ', 0, 0, 40, 20, ); $this->assertNotSame('', $out); $this->assertStringContainsString('H1', $out); $this->assertStringContainsString('H2', $out); $this->assertStringContainsString('(A)', $out); $this->assertStringContainsString('(B)', $out); } /** * @throws \Throwable */ public function testGetHTMLCellRendersTableWithColspan(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '
    Top
    LeftRight
    ', 0, 0, 40, 20, ); $this->assertNotSame('', $out); $this->assertStringContainsString('(Top)', $out); $this->assertStringContainsString('(Left)', $out); $this->assertStringContainsString('(Right)', $out); } /** * @throws \Throwable */ public function testGetHTMLCellSuppressesNestedNobrAttribute(): void { $obj = $this->getNobrProbeTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('
    A
    B
    ', 0, 0, 40, 20); $states = $obj->exposeNobrOpenStates(); $this->assertCount(2, $states); assert(isset($states[0]), "\$states[0] must be set"); $this->assertSame('true', $states[0]); assert(isset($states[1]), "\$states[1] must be set"); $this->assertSame('', $states[1]); } /** * @throws \Throwable */ public function testGetHTMLCellBreaksBeforeNobrBlockOnOverflow(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $before = $page->getPageId(); $region = $page->getRegion(); $starty = \max(0.0, $region['RH'] - 5.0); $out = $obj->getHTMLCell('

    A

    B

    ', 0, $starty, 30, 0); $after = $page->getPageId(); $this->assertNotSame('', $out); $this->assertGreaterThan($before, $after); $this->assertStringContainsString('(A)', $out); $this->assertStringContainsString('(B)', $out); } /** * @throws \Throwable */ public function testGetHTMLCellRendersInputAndTextareaValues(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); // text input + textarea each produce a form-field annotation; hidden produces none $annotation = $this->getFormAnnotations($obj); $this->assertCount(2, $annotation); $fieldTypes = $this->getFormFieldTypes($annotation); $this->assertContains('Tx', $fieldTypes); } /** * @throws \Throwable */ public function testGetHTMLCellRendersCheckboxAndPasswordInputs(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '' . '' . '', 0, 0, 40, 20, ); // checkbox, radio, and password text each produce a form-field annotation $annotation = $this->getFormAnnotations($obj); $this->assertGreaterThanOrEqual(3, \count($annotation)); $fieldTypes = $this->getFormFieldTypes($annotation); $this->assertContains('Btn', $fieldTypes); $this->assertContains('Tx', $fieldTypes); } /** * @throws \Throwable */ public function testGetHTMLCellRendersFileInputWithFileSelectFlag(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 40, 20); $annotation = $this->getFormAnnotations($obj); $this->assertCount(1, $annotation); $opt = $this->getLastFormAnnotationOpt($annotation); $this->assertSame('Tx', $opt['ft'] ?? null); $this->assertArrayHasKey('ff', $opt); $this->assertIsInt($opt['ff'] ?? null); $this->assertNotSame(0, (int) $opt['ff'] & (1 << 20)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersMultilineTextareaValue(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 40, 20); // textarea produces a multiline text form-field annotation $annotation = $this->getFormAnnotations($obj); $this->assertCount(1, $annotation); $opt = $this->getLastFormAnnotationOpt($annotation); $this->assertSame('Tx', $opt['ft'] ?? null); } /** * @throws \Throwable */ public function testGetHTMLCellAssociatesLabelForIdWithInputField(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 50, 20, ); $annotation = $this->getFormAnnotations($obj); $this->assertCount(1, $annotation); $opt = $this->getLastFormAnnotationOpt($annotation); $this->assertSame('Tx', $opt['ft'] ?? ''); $this->assertSame('User Name', $opt['tu'] ?? ''); } /** * @throws \Throwable */ public function testGetHTMLCellFallsBackToEnclosingLabelWhenForIsMissing(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 50, 20); $annotation = $this->getFormAnnotations($obj); $this->assertCount(1, $annotation); $opt = $this->getLastFormAnnotationOpt($annotation); $this->assertSame('Tx', $opt['ft'] ?? ''); $this->assertSame('Notes', $opt['tu'] ?? ''); } /** * @throws \Throwable */ public function testGetHTMLCellDoesNotAssociateUnmatchedLabelForAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 50, 20, ); $annotation = $this->getFormAnnotations($obj); $this->assertCount(1, $annotation); $opt = $this->getLastFormAnnotationOpt($annotation); $this->assertSame('Tx', $opt['ft'] ?? ''); $this->assertArrayNotHasKey('tu', $opt); } /** * @throws \Throwable */ public function testGetHTMLCellMapsInputReadonlyRequiredDisabledAndMaxlength(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 50, 20, ); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Tx', $this->getMapString($opt, 'ft')); $this->assertSame(5, $this->getMapInt($opt, 'maxlen')); $fieldFlags = $this->getMapInt($opt, 'ff'); $this->assertNotSame(0, $fieldFlags & 1); $this->assertSame(0, $fieldFlags & (1 << 1)); $annotFlags = $this->getMapInt($opt, 'f'); $this->assertNotSame(0, $annotFlags & (1 << 6)); } /** * @throws \Throwable */ public function testGetHTMLCellDisabledFieldClearsRequiredFlagForSelect(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 60, 20); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Ch', $this->getMapString($opt, 'ft')); $fieldFlags = $this->getMapInt($opt, 'ff'); $this->assertNotSame(0, $fieldFlags & 1); $this->assertSame(0, $fieldFlags & (1 << 1)); } /** * @throws \Throwable */ public function testGetHTMLCellMapsReadonlyAndRequiredOnSelectAndTextarea(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '' . '', 0, 0, 70, 20, ); $annotation = $this->getObjectArrayProperty($obj, 'annotation'); $this->assertIsArray($annotation); $this->assertCount(2, $annotation); $items = \array_values($annotation); assert(isset($items[0]), "\$items[0] must be set"); /** @var array{opt: array} $select */ $select = $items[0]; $this->assertSame('Ch', $select['opt']['ft'] ?? ''); $this->assertIsInt($select['opt']['ff'] ?? null); $this->assertNotSame(0, $select['opt']['ff'] & 1); $this->assertNotSame(0, $select['opt']['ff'] & (1 << 1)); assert(isset($items[1]), "\$items[1] must be set"); /** @var array{opt: array} $textarea */ $textarea = $items[1]; $this->assertSame('Tx', $textarea['opt']['ft'] ?? ''); $this->assertSame(3, $textarea['opt']['maxlen'] ?? null); $this->assertIsInt($textarea['opt']['ff'] ?? null); $this->assertNotSame(0, $textarea['opt']['ff'] & 1); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesTypeDefaultsForEmailAndNumberInputs(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 70, 20, ); $annotation = $this->getObjectArrayProperty($obj, 'annotation'); $this->assertIsArray($annotation); $this->assertCount(2, $annotation); $items = \array_values($annotation); assert(isset($items[0]), "\$items[0] must be set"); /** @var array{opt: array} $email */ $email = $items[0]; $this->assertSame('Tx', $email['opt']['ft'] ?? ''); $this->assertIsInt($email['opt']['ff'] ?? null); $this->assertNotSame(0, $email['opt']['ff'] & (1 << 22)); assert(isset($items[1]), "\$items[1] must be set"); /** @var array{opt: array} $number */ $number = $items[1]; $this->assertSame('Tx', $number['opt']['ft'] ?? ''); $this->assertSame(2, $number['opt']['q'] ?? null); $this->assertIsInt($number['opt']['ff'] ?? null); $this->assertNotSame(0, $number['opt']['ff'] & (1 << 22)); } /** * @throws \Throwable */ public function testGetHTMLCellDisabledReadonlyKeepsTypeDefaultsOnEmailAndNumberInputs(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '' . '', 0, 0, 70, 20, ); $annotation = $this->getObjectArrayProperty($obj, 'annotation'); $this->assertIsArray($annotation); $this->assertCount(2, $annotation); $items = \array_values($annotation); assert(isset($items[0]), "\$items[0] must be set"); /** @var array{opt: array} $email */ $email = $items[0]; $this->assertSame('Tx', $email['opt']['ft'] ?? ''); $this->assertIsInt($email['opt']['ff'] ?? null); // readonly field flag $this->assertNotSame(0, $email['opt']['ff'] & 1); // required must be cleared when disabled $this->assertSame(0, $email['opt']['ff'] & (1 << 1)); // doNotSpellCheck type default remains applied $this->assertNotSame(0, $email['opt']['ff'] & (1 << 22)); $this->assertIsInt($email['opt']['f'] ?? null); $this->assertNotSame(0, $email['opt']['f'] & (1 << 6)); assert(isset($items[1]), "\$items[1] must be set"); /** @var array{opt: array} $number */ $number = $items[1]; $this->assertSame('Tx', $number['opt']['ft'] ?? ''); // number alignment default remains applied while readonly/disabled $this->assertSame(2, $number['opt']['q'] ?? null); $this->assertIsInt($number['opt']['ff'] ?? null); $this->assertNotSame(0, $number['opt']['ff'] & 1); $this->assertSame(0, $number['opt']['ff'] & (1 << 1)); $this->assertNotSame(0, $number['opt']['ff'] & (1 << 22)); $this->assertIsInt($number['opt']['f'] ?? null); $this->assertNotSame(0, $number['opt']['f'] & (1 << 6)); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesTypeDefaultsForDateAndTelWithDisabledRequiredHandling(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '' . '', 0, 0, 70, 20, ); $annotation = $this->getObjectArrayProperty($obj, 'annotation'); $this->assertIsArray($annotation); $this->assertCount(2, $annotation); $items = \array_values($annotation); assert(isset($items[0]), "\$items[0] must be set"); /** @var array{opt: array} $date */ $date = $items[0]; $this->assertSame('Tx', $date['opt']['ft'] ?? ''); $this->assertIsInt($date['opt']['ff'] ?? null); $this->assertNotSame(0, $date['opt']['ff'] & 1); $this->assertSame(0, $date['opt']['ff'] & (1 << 1)); $this->assertNotSame(0, $date['opt']['ff'] & (1 << 22)); assert(isset($items[1]), "\$items[1] must be set"); /** @var array{opt: array} $tel */ $tel = $items[1]; $this->assertSame('Tx', $tel['opt']['ft'] ?? ''); $this->assertIsInt($tel['opt']['ff'] ?? null); $this->assertNotSame(0, $tel['opt']['ff'] & 1); $this->assertNotSame(0, $tel['opt']['ff'] & (1 << 1)); $this->assertNotSame(0, $tel['opt']['ff'] & (1 << 22)); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesTypeDefaultsForUrlWithDisabledAndReadonlyHandling(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '' . '', 0, 0, 70, 20, ); $annotation = $this->getObjectArrayProperty($obj, 'annotation'); $this->assertIsArray($annotation); $this->assertCount(2, $annotation); $items = \array_values($annotation); assert(isset($items[0]), "\$items[0] must be set"); /** @var array{opt: array} $disabledUrl */ $disabledUrl = $items[0]; $this->assertSame('Tx', $disabledUrl['opt']['ft'] ?? ''); $this->assertIsInt($disabledUrl['opt']['ff'] ?? null); $this->assertNotSame(0, $disabledUrl['opt']['ff'] & 1); $this->assertSame(0, $disabledUrl['opt']['ff'] & (1 << 1)); $this->assertNotSame(0, $disabledUrl['opt']['ff'] & (1 << 22)); assert(isset($items[1]), "\$items[1] must be set"); /** @var array{opt: array} $readonlyUrl */ $readonlyUrl = $items[1]; $this->assertSame('Tx', $readonlyUrl['opt']['ft'] ?? ''); $this->assertIsInt($readonlyUrl['opt']['ff'] ?? null); $this->assertNotSame(0, $readonlyUrl['opt']['ff'] & 1); $this->assertNotSame(0, $readonlyUrl['opt']['ff'] & (1 << 1)); $this->assertNotSame(0, $readonlyUrl['opt']['ff'] & (1 << 22)); } /** * Helper: extract option labels from the last ComboBox annotation. * * @param object $obj PDF object * @return array */ private function getLastComboBoxLabels(object $obj): array { $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Ch', $this->getMapString($opt, 'ft')); return $this->extractComboBoxLabels($opt['opt'] ?? null); } /** * Helper: get the initial selected value from the last ComboBox annotation. */ private function getLastComboBoxValue(object $obj): string { $opt = $this->getLastAnnotationOptFromObject($obj); return $this->getMapString($opt, 'v'); } /** * @throws \Throwable */ public function testGetHTMLCellRendersSelectFirstOptionLabel(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Alpha', $labels); $this->assertContains('Beta', $labels); } /** * @throws \Throwable */ public function testGetHTMLCellRendersSelectedOptionLabelByValue(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Alpha', $labels); $this->assertContains('Beta', $labels); $this->assertSame('v2', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersSelectedOptionLabelBySingleQuotedValue(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( "", 0, 0, 40, 20, ); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Beta', $labels); $this->assertSame('v2', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersSelectedOptionLabelByUnquotedValue(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Beta', $labels); $this->assertSame('v2', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersMultipleSelectedOptionLabelsByValue(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); // ComboBox is created with all option labels accessible $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Alpha', $labels); $this->assertContains('Beta', $labels); } /** * @throws \Throwable */ public function testGetHTMLCellRendersSelectedOptionLabelBySelectedAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Beta', $labels); $this->assertSame('v2', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersMultipleSelectedOptionLabelsBySelectedAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); // First selected option becomes the initial value $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Alpha', $labels); $this->assertContains('Beta', $labels); $this->assertSame('Alpha', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersSelectedOptionLabelWithBooleanSelectedAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 40, 20); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Beta', $labels); $this->assertSame('Beta', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersSelectedOptionLabelWithSelectedTrueAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 40, 20); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Beta', $labels); $this->assertSame('Beta', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersSelectedOptionLabelWithSingleQuotedSelectedAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( "", 0, 0, 40, 20, ); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Beta', $labels); $this->assertSame('Beta', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersSelectedOptionLabelWithUppercaseSelectedAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 40, 20); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Beta', $labels); $this->assertSame('Beta', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersOptgroupLabelsInSelectOptions(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); $labels = $this->getLastComboBoxLabels($obj); $this->assertContains('Fruits - Apple', $labels); $this->assertContains('Fruits - Banana', $labels); $this->assertContains('Veg - Carrot', $labels); } /** * @throws \Throwable */ public function testGetHTMLCellPrefersSelectedOptionOverSelectValueForInitialValue(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); $this->assertSame('v2', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellSelectValueFallbackSkipsUnknownValues(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 20, ); $this->assertSame('v2', $this->getLastComboBoxValue($obj)); } /** * @throws \Throwable */ public function testGetHTMLCellRendersImgFallbackWhenImageCannotLoad(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('', 0, 0, 20, 10); $this->assertNotSame('', $out); $this->assertStringContainsString('[img]', $out); } /** * @throws \Throwable */ public function testGetHTMLCellImgWithoutSrcUsesAltFallbackText(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('x', 0, 0, 20, 10); $this->assertNotSame('', $out); $this->assertStringContainsString('Tj', $out); $this->assertStringContainsString('x', $out); } /** * @throws \Throwable */ public function testGetHTMLCellImgInvalidSrcUsesAltFallbackText(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( 'fallback-alt', 0, 0, 20, 10, ); $this->assertNotSame('', $out); $this->assertStringContainsString('Tj', $out); $this->assertStringContainsString('fallback-alt', $out); } /** * @throws \Throwable */ public function testGetHTMLCellDrawsTableCellBorderWhenSpecified(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('
    A
    ', 0, 0, 30, 12); $this->assertNotSame('', $out); $this->assertStringContainsString(' re', $out); } /** * @throws \Throwable */ public function testGetHTMLCellDrawsUniformBorderHeightAcrossRow(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '' . '
    AThis is a longer wrapped cell value
    ', 0, 0, 25, 30, ); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\\s+s/', $out, $matches, PREG_SET_ORDER); $this->assertGreaterThanOrEqual(2, \count($matches)); $m0h = \is_numeric($matches[0][4] ?? null) ? (float) $matches[0][4] : 0.0; $m1h = \is_numeric($matches[1][4] ?? null) ? (float) $matches[1][4] : 0.0; $this->assertEqualsWithDelta(\abs($m0h), \abs($m1h), 0.0001); } /** * @throws \Throwable */ public function testGetHTMLCellRendersTableWithRowspanContent(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '
    ATop
    Bottom
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $this->assertStringContainsString('(A)', $out); $this->assertStringContainsString('(Top)', $out); $this->assertStringContainsString('(Bottom)', $out); } /** * @throws \Throwable */ public function testGetHTMLCellDrawsRowspanBorderAcrossTwoRows(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '' . '' . '' . '' . '
    ATop
    Bottom
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\\s+s/', $out, $matches, PREG_SET_ORDER); $this->assertGreaterThanOrEqual(3, \count($matches)); $heights = []; foreach ($matches as $match) { if (!\is_numeric($match[4] ?? null)) { continue; } $heights[] = \abs((float) $match[4]); } \rsort($heights); assert(isset($heights[1]), "\$heights[1] must be set"); assert(isset($heights[0]), "\$heights[0] must be set"); $this->assertGreaterThan($heights[1], $heights[0]); assert(isset($heights[2]), "\$heights[2] must be set"); $this->assertEqualsWithDelta($heights[0], $heights[1] + $heights[2], 0.0001); } /** * @throws \Throwable */ public function testGetHTMLCellRowspanHeightIncludesCellspacingBetweenRows(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '' . '' . '' . '' . '
    ATop
    Bottom
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\\s+s/', $out, $matches, PREG_SET_ORDER); $this->assertGreaterThanOrEqual(3, \count($matches)); $heights = []; foreach ($matches as $match) { if (!\is_numeric($match[4] ?? null)) { continue; } $heights[] = \abs((float) $match[4]); } \rsort($heights); assert(isset($heights[1]), "\$heights[1] must be set"); assert(isset($heights[2]), "\$heights[2] must be set"); assert(isset($heights[0]), "\$heights[0] must be set"); $this->assertGreaterThan( $heights[1] + $heights[2], $heights[0], 'Rowspan height should include inter-row cellspacing between spanned rows.', ); } /** * @throws \Throwable */ public function testGetHTMLCellRowspanHeightIncludesCssBorderSpacingBetweenRows(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '' . '' . '' . '' . '
    ATop
    Bottom
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+s/', $out, $matches, PREG_SET_ORDER); $this->assertGreaterThanOrEqual(3, \count($matches)); $heights = []; foreach ($matches as $match) { if (!\is_numeric($match[4] ?? null)) { continue; } $heights[] = \abs((float) $match[4]); } \rsort($heights); assert(isset($heights[1]), "\$heights[1] must be set"); assert(isset($heights[2]), "\$heights[2] must be set"); assert(isset($heights[0]), "\$heights[0] must be set"); $this->assertGreaterThan( $heights[1] + $heights[2], $heights[0], 'Rowspan height should include inter-row CSS border-spacing between spanned rows.', ); } /** * @throws \Throwable */ public function testGetHTMLCellDrawsTableCellBackgroundFillWhenSpecified(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('
    A
    ', 0, 0, 30, 12); $this->assertNotSame('', $out); $this->assertStringContainsString(' re', $out); $this->assertStringContainsString("f\n", $out); } /** * @throws \Throwable */ public function testGetHTMLCellDrawsInlineBackgroundFillWhenSpecified(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('A', 0, 0, 30, 12); $this->assertNotSame('', $out); $this->assertStringContainsString(' re', $out); $this->assertStringContainsString("f\n", $out); } /** * @throws \Throwable */ public function testGetHTMLCellRenderingRegressionFloatAndClearFixture(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = (string) \file_get_contents(__DIR__ . '/fixtures/html/rendering/float_clear.html'); $out = $obj->getHTMLCell($html, 0, 0, 90, 30); $this->assertNotSame('', $out); $this->assertStringContainsString('FLOAT-L', $out); $this->assertStringContainsString('FLOAT-R', $out); $this->assertStringContainsString('CLEAR-BLOCK', $out); $this->assertMatchesRegularExpression('/FLOAT-L.*FLOAT-R.*CLEAR-BLOCK/s', $out); } /** * @throws \Throwable */ public function testGetHTMLCellRenderingRegressionPositionModesFixture(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = (string) \file_get_contents(__DIR__ . '/fixtures/html/rendering/position_modes.html'); $out = $obj->getHTMLCell($html, 0, 0, 90, 35); $this->assertNotSame('', $out); $this->assertStringContainsString('REL-BOX', $out); $this->assertStringContainsString('ABS-BOX', $out); $this->assertStringContainsString('FIXED-BOX', $out); } /** * @throws \Throwable */ public function testGetHTMLCellRenderingRegressionTableLayoutFixture(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = (string) \file_get_contents(__DIR__ . '/fixtures/html/rendering/table_layout_fixed_auto.html'); $out = $obj->getHTMLCell($html, 0, 0, 90, 60); $this->assertNotSame('', $out); $this->assertStringContainsString('FIXED-A', $out); $this->assertStringContainsString('FIXED-B-LONG-CONTENT', $out); $this->assertStringContainsString('AUTO-A', $out); $this->assertStringContainsString('AUTO-B-LONG-CONTENT', $out); $this->assertStringContainsString(' re', $out); } /** * @throws \Throwable */ public function testGetHTMLCellCaptionSideBottomRendersCaptionAfterTableRows(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '' . '' . '' . '
    CAPTION-BOTTOM
    ROW-CELL
    '; $out = $obj->getHTMLCell($html, 0, 0, 60, 30); $this->assertNotSame('', $out); $this->assertStringContainsString('CAPTION-BOTTOM', $out); $this->assertStringContainsString('ROW-CELL', $out); $captionPos = \strpos($out, 'Td (CAPTION-BOTTOM) Tj ET'); $rowPos = \strpos($out, 'Td (ROW-CELL) Tj ET'); $this->assertNotFalse($captionPos); $this->assertNotFalse($rowPos); $this->assertGreaterThan($rowPos, $captionPos); } /** * @throws \Throwable */ public function testGetHTMLCellCaptionSideTopRendersCaptionBeforeTableRows(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '' . '' . '' . '
    CAPTION-TOP
    ROW-CELL
    '; $out = $obj->getHTMLCell($html, 0, 0, 60, 30); $this->assertNotSame('', $out); $this->assertStringContainsString('CAPTION-TOP', $out); $this->assertStringContainsString('ROW-CELL', $out); $captionPos = \strpos($out, 'Td (CAPTION-TOP) Tj ET'); $rowPos = \strpos($out, 'Td (ROW-CELL) Tj ET'); $this->assertNotFalse($captionPos); $this->assertNotFalse($rowPos); $this->assertLessThan($rowPos, $captionPos); } /** * @throws \Throwable */ public function testGetHTMLCellExtendsInlineSmallBackgroundAcrossWrappedLines(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = 'small small small small small small small' . ' small small small small small small small small small small small small small'; $extractFillSpan = static function (string $out): float { $matches = []; $re4Pattern = '/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\\s+f/'; \preg_match_all($re4Pattern, $out, $matches, PREG_SET_ORDER); if ($matches === []) { return 0.0; } $miny = PHP_FLOAT_MAX; $maxy = -PHP_FLOAT_MAX; foreach ($matches as $match) { assert(isset($match[2]), "\$match[2] must be set"); $posy = \floatval($match[2]); assert(isset($match[4]), "\$match[4] must be set"); $height = \abs(\floatval($match[4])); $miny = \min($miny, $posy); $maxy = \max($maxy, $posy + $height); } if ($maxy <= $miny) { return 0.0; } return $maxy - $miny; }; $outNoWrap = $obj->getHTMLCell($html, 0, 0, 160, 20); $outWrap = $obj->getHTMLCell($html, 0, 0, 40, 20); $this->assertNotSame('', $outNoWrap); $this->assertNotSame('', $outWrap); $nowrapHeight = $extractFillSpan($outNoWrap); $wrapHeight = $extractFillSpan($outWrap); $this->assertGreaterThan(0.0, $nowrapHeight); $this->assertGreaterThan(0.0, $wrapHeight); $this->assertGreaterThan( $nowrapHeight + 0.001, $wrapHeight, 'Wrapped inline background must cover more than one rendered line.', ); } /** * @throws \Throwable */ public function testGetHTMLCellDrawsMultipleFillRectsForWrappedInlineSmallBackground(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $html = '' . 'small small small small small small small small small small small small' . ''; $out = $obj->getHTMLCell($html, 0, 0, 40, 20); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\\s+f/', $out, $matches, PREG_SET_ORDER); $this->assertNotEmpty($matches); $linekeys = []; foreach ($matches as $match) { assert(isset($match[2]), "\$match[2] must be set"); $linekeys[\sprintf('%.3f', \floatval($match[2]))] = true; } $this->assertGreaterThanOrEqual( 2, \count($linekeys), 'Wrapped inline background should be drawn on more than one line.', ); } /** * @throws \Throwable */ public function testGetHTMLCellWhiteSpaceNowrapKeepsInlineBackgroundOnSingleLine(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $text = 'alpha beta gamma delta epsilon zeta eta theta iota kappa'; $normal = '' . $text . ''; $nowrap = '' . $text . ''; $outNormal = $obj->getHTMLCell($normal, 0, 0, 40, 20); $outNowrap = $obj->getHTMLCell($nowrap, 0, 0, 40, 20); $this->assertNotSame('', $outNormal); $this->assertNotSame('', $outNowrap); $normalMatches = []; $nowrapMatches = []; \preg_match_all( '/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+f/', $outNormal, $normalMatches, PREG_SET_ORDER, ); \preg_match_all( '/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+f/', $outNowrap, $nowrapMatches, PREG_SET_ORDER, ); $this->assertNotEmpty($normalMatches); $this->assertNotEmpty($nowrapMatches); $normalLines = []; foreach ($normalMatches as $match) { assert(isset($match[2]), "\$match[2] must be set"); $normalLines[\sprintf('%.3f', \floatval($match[2]))] = true; } $nowrapLines = []; foreach ($nowrapMatches as $match) { assert(isset($match[2]), "\$match[2] must be set"); $nowrapLines[\sprintf('%.3f', \floatval($match[2]))] = true; } $this->assertGreaterThanOrEqual(2, \count($normalLines)); $this->assertSame(1, \count($nowrapLines)); } /** * @throws \Throwable */ public function testGetHTMLCellExpandsBlockBackgroundFillAcrossLineWidth(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '
    Hello World!
    Hello
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\\s+f/', $out, $matches, PREG_SET_ORDER); $this->assertNotEmpty($matches); $maxwidth = 0.0; foreach ($matches as $match) { assert(isset($match[3]), "\$match[3] must be set"); $maxwidth = \max($maxwidth, \abs(\floatval($match[3]))); } $this->assertGreaterThan(20.0, $maxwidth); } /** * @throws \Throwable */ public function testGetHTMLCellRendersTableHeadAndBodyRows(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('
    H
    T
    ', 0, 0, 30, 20); $this->assertNotSame('', $out); $this->assertStringContainsString('H', $out); $this->assertStringContainsString('T', $out); } /** * @throws \Throwable */ public function testGetHTMLCellReplaysTableHeadOnExplicitBodyRowPageBreak(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '' . '
    H
    T
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $this->assertGreaterThanOrEqual(2, \substr_count($out, '(H)')); $this->assertStringContainsString('(T)', $out); } /** * @throws \Throwable */ public function testResetHTMLTableStackOnPageBreakRebasesOpenTablesAndDropsActiveRowspans(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $setTableStack = new \ReflectionMethod($obj, 'exposeSetHTMLTableStack'); $setTableStack->invokeArgs($obj, [[ [ 'originx' => 5.0, 'originy' => 12.0, 'width' => 40.0, 'cols' => 2, 'colwidth' => 18.0, 'colwidths' => [18.0, 18.0], 'cellspacingh' => 1.0, 'cellspacingv' => 3.0, 'cellpadding' => 0.0, 'collapse' => false, 'hascellborders' => true, 'prevrowbottom' => [], 'rowtop' => 21.0, 'rowheight' => 7.0, 'colindex' => 1, 'cells' => [['cellx' => 5.0]], 'occupied' => [0, 1], 'rowspans' => [[ 'cellx' => 5.0, 'cellw' => 18.0, 'colindex' => 0, 'colspan' => 1, 'rowtop' => 21.0, 'rowsremaining' => 2, 'usedheight' => 7.0, 'contenth' => 14.0, 'valign' => 'middle', 'bstyles' => [], 'fillstyle' => null, 'buffer' => 'A', ]], ], ]]); $obj->exposeResetHTMLTableStackOnPageBreak(42.0); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; $this->assertSame(42.0, $table['originy']); $this->assertSame(45.0, $table['rowtop']); $this->assertSame(0.0, $table['rowheight']); $this->assertSame([], $table['cells']); $this->assertSame([], $table['rowspans']); } /** * @throws \Throwable */ public function testGetHTMLCellReplaysTableHeadOnAutomaticRowOverflow(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $region = $page->getRegion(); $starty = \max(0.0, $region['RH'] - 10.0); $out = $obj->getHTMLCell( '
    H
    Tall
    Next
    ', 0, $starty, 30, 0, ); $this->assertNotSame('', $out); $this->assertGreaterThanOrEqual(2, \substr_count($out, '(H)')); $this->assertStringContainsString('(Tall)', $out); $this->assertStringContainsString('(Next)', $out); } /** * @throws \Throwable */ public function testGetHTMLCellDrawsTableOuterFrameOnEveryPageItSpans(): void { // Regression: when a bordered table spanned several pages the outer // frame was stroked only around the final page's section, because the // closing handler frames a single section and // resetHTMLTableStackOnPageBreak() rebases the table origin to the new // region top on every break. Each continuation page therefore lost its // border, leaving only the last page framed. The frame must now be // stroked once per page the table covers. $obj = $this->getTestObject(); $this->initFontAndPage($obj); // Cells cancel their own borders so the only stroked rectangle on each // page is the table's outer frame, emitted as a closed "re s" path. $rows = ''; for ($i = 1; $i <= 80; ++$i) { $rows .= 'Row ' . $i . ''; } $out = $obj->getHTMLCell( '' . $rows . '
    ', 0, 0, 30, 0, ); $this->assertNotSame('', $out); $this->assertStringContainsString('(Row 1)', $out); $this->assertStringContainsString('(Row 80)', $out); $matches = []; $frames = \preg_match_all('/\sre\s+s\b/s', $out, $matches); $this->assertGreaterThanOrEqual( 2, $frames, 'The table outer frame must be stroked on every page the table spans, not only the last.', ); } /** * @throws \Throwable */ public function testGetHTMLCellTableHeadReplayDoesNotOverlapBodyRowWithCellpadding(): void { // Regression: estimateHTMLTableHeadHeight previously ignored the // attribute when measuring the replayed // header on a new page (parseHTMLTagOPENtable applies that padding // as a default to TD/TH cells with zero CSS padding at render time, // but the estimate parses the standalone thead DOM and never ran // those handlers). The under-estimated header height caused the // first body row on the next page to overlap the replayed header // (see example 018 row 14 vs page-6 header). $obj = $this->getTestObject(); $this->initFontAndPage($obj); /** @var \Com\Tecnick\Pdf\Page\Page $page */ $page = $this->getObjectProperty($obj, 'page'); $region = $page->getRegion(); $starty = \max(0.0, $region['RH'] - 5.0); $out = $obj->getHTMLCell( '
    HDR
    BODY
    ', 0, $starty, 30, 0, ); $this->assertNotSame('', $out); $hdrMatches = []; $this->assertGreaterThanOrEqual( 2, \preg_match_all('/BT .*? [-0-9.]+ ([-0-9.]+) Td \(HDR\) Tj ET/s', $out, $hdrMatches), 'Header text must be rendered both on the original page and replayed on the new page.', ); $bodyMatches = []; $this->assertSame( 1, \preg_match('/BT .*? [-0-9.]+ ([-0-9.]+) Td \(BODY\) Tj ET/s', $out, $bodyMatches), 'Body text must be rendered exactly once.', ); $hdrValues = $hdrMatches[1] ?? []; $this->assertIsArray($hdrValues); $this->assertNotSame([], $hdrValues); $hdrLastIdx = \count($hdrValues) - 1; $hdrSecondY = \is_numeric($hdrValues[$hdrLastIdx] ?? null) ? (float) $hdrValues[$hdrLastIdx] : 0.0; assert(isset($bodyMatches[1]), "\$bodyMatches[1] must be set"); $bodyY = \floatval($bodyMatches[1]); // The y values are PDF user-space points. Without the fix the // estimated header height excluded the table cellpadding (~9pt // vertical), so the body row text on the new page sat about 11.5pt // below the replayed header (visibly overlapping it). With the fix // the gap is roughly 20pt. Threshold 14 pt cleanly separates the // two regimes. $this->assertGreaterThan( 14.0, \abs($hdrSecondY - $bodyY), 'Replayed header and the first body row on the new page must not overlap; ' . 'the vertical gap must include the table cellpadding.', ); $this->assertLessThan( 24.0, \abs($hdrSecondY - $bodyY), 'Replayed header and the first body row on the new page must not drift apart with an extra continuation gap.', ); } /** * @throws \Throwable */ public function testReplayHTMLTableHeadAdvancesCursorByActualRenderedHeight(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 30.0, 0.0); $thead = '
    HDR
    '; $expectedHeight = $obj->exposeMeasureHTMLCellRenderedHeight($thead, 0.0, 0.0, 30.0, 0.0); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 0.0; $out = $obj->exposeReplayHTMLTableHead($thead, $tpx, $tpy, $tpw, $tph); $this->assertNotSame('', $out); $this->assertGreaterThan(0.0, $expectedHeight); $this->assertEqualsWithDelta( $expectedHeight, $tpy, 0.01, 'Replayed table headers must advance the cursor by their actual rendered height.', ); } /** * @throws \Throwable */ public function testGetHTMLCellReplaysTableHeadWithSameColumnWidthsOnPxUnitDocument(): void { // Regression for https://github.com/tecnickcom/tc-lib-pdf/issues/224: // injectHTMLTableHeadColWidths serialized the computed column widths // using the document unit name. CSS pixel lengths are parsed with the // 96dpi ratio (1px = 0.75pt) while the 'px' document unit maps one // user unit to one point, so replayed headers on continuation pages // were rendered at 75% of the table width. $obj = new \Com\Tecnick\Pdf\Tcpdf('px'); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '' . '' . '
    HHH
    AB
    ', 0, 0, 100, 0, ); $this->assertGreaterThanOrEqual(2, \substr_count($out, '(H)')); // The only rectangles in the output are the header background fills: // two columns on the original page and two on the continuation page. // The replayed header must keep the exact column geometry computed // for the original table (100 user units = 100pt split in half). $matches = []; \preg_match_all('/[0-9.+-]+ [0-9.+-]+ ([0-9.+-]+) [0-9.+-]+ re/', $out, $matches); $rectwidths = $matches[1] ?? []; $this->assertIsArray($rectwidths); $widths = []; foreach ($rectwidths as $rectwidth) { $widths[] = \is_numeric($rectwidth) ? \round((float) $rectwidth, 4) : -1.0; } $this->assertSame([50.0, 50.0, 50.0, 50.0], $widths); } /** * @throws \Throwable */ public function testGetHTMLCellReplaysTableHeadWhenEmbeddedStyleBlockIsPresent(): void { // Regression: an embedded (or external) ' . '' . '' . '
    HHH
    AB
    ', 0, 0, 100, 0, ); // The header must appear twice: once on the original page and once // replayed on the continuation page forced by page-break-before. $this->assertGreaterThanOrEqual( 2, \substr_count($out, '(H)'), 'Table header must be replayed on continuation pages even when an embedded ' . $body); $this->assertSame( $plain, $styled, 'Auto table column widths must not change when an unrelated embedded ' . '

    plain one

    plain two

    ', ); $heights = []; foreach ($dom as $elm) { if (!$elm['tag'] || !$elm['opening'] || $elm['value'] !== 'p') { continue; } $heights[] = $elm['height']; } $this->assertCount(3, $heights); $this->assertEqualsWithDelta(25.0, $heights[0] ?? 0.0, 0.01, 'The empty

    must keep its :empty height.'); $this->assertSame(0.0, $heights[1] ?? -1.0, 'Non-empty siblings must not inherit the :empty height.'); $this->assertSame(0.0, $heights[2] ?? -1.0, 'Non-empty siblings must not inherit the :empty height.'); } /** * @throws \Throwable */ public function testGetHTMLCellRespectsExplicitTdColumnWidth(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '' . '' . '' . '
    WideNarrow
    ', 0, 0, 60, 20, ); $domObj = $this->getInternalTestObject(); $this->initFontAndPage($domObj); $dom = $domObj->exposeGetHTMLDOM('
    WideNarrow
    '); $tdWidths = []; foreach ($dom as $elm) { if (!(!empty($elm['opening']) && $elm['value'] === 'td')) { continue; } $tdWidths[] = $elm['width']; } $this->assertNotSame('', $out); $this->assertStringContainsString('Wide', $out); $this->assertStringContainsString('Narrow', $out); $this->assertCount(2, $tdWidths); assert(isset($tdWidths[1]), "\$tdWidths[1] must be set"); assert(isset($tdWidths[0]), "\$tdWidths[0] must be set"); $this->assertGreaterThan($tdWidths[1], $tdWidths[0], 'First column should resolve wider width than second'); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesCellspacingBetweenRows(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $noSpacing = $obj->getHTMLCell( '' . '
    A
    B
    ', 0, 0, 30, 30, ); $withSpacing = $obj->getHTMLCell( '' . '
    A
    B
    ', 0, 0, 30, 30, ); $this->assertNotSame('', $noSpacing); $this->assertNotSame('', $withSpacing); $this->assertStringContainsString('A', $withSpacing); $this->assertStringContainsString('B', $withSpacing); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesCellpaddingToAllCells(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); // With cellpadding=5, the text inside the cell should be indented relative to the border. $out = $obj->getHTMLCell( '
    X
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $this->assertStringContainsString('(X)', $out); $this->assertStringContainsString(' re', $out, 'Expected a cell rectangle to be drawn'); } /** * @throws \Throwable */ public function testGetHTMLCellRendersTableOuterBorderFromTableAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $withoutBorder = $obj->getHTMLCell( '
    A
    ', 0, 0, 30, 20, ); $withBorder = $obj->getHTMLCell( '
    A
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $withoutBorder); $this->assertNotSame('', $withBorder); $this->assertStringContainsString('(A)', $withBorder); $this->assertStringNotContainsString(' re', $withoutBorder); $this->assertStringContainsString(' re', $withBorder, 'Expected outer table border rectangle to be drawn'); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesCellspacingBetweenOuterAndInnerBorders(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '
    A
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\\s+s/', $out, $matches, PREG_SET_ORDER); $this->assertGreaterThanOrEqual(2, \count($matches)); $xvalues = []; foreach ($matches as $match) { assert(isset($match[1]), "\$match[1] must be set"); $xvalues[] = \floatval($match[1]); } if ($xvalues === []) { $this->fail('Expected at least one x position extracted from border rectangles'); } $this->assertGreaterThan( 0.1, \max($xvalues) - \min($xvalues), 'Expected distinct x positions for inner cell border and outer table border when cellspacing is set', ); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesCssBorderSpacingBetweenOuterAndInnerBorders(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '
    A
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+s/', $out, $matches, PREG_SET_ORDER); $this->assertGreaterThanOrEqual(2, \count($matches)); $xvalues = []; foreach ($matches as $match) { assert(isset($match[1]), "\$match[1] must be set"); $xvalues[] = \floatval($match[1]); } if ($xvalues === []) { $this->fail('Expected at least one x position extracted from border rectangles'); } $this->assertGreaterThan( 0.1, \max($xvalues) - \min($xvalues), 'Expected distinct x positions for inner cell border and outer table border when CSS border-spacing is set', ); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesCssBorderSpacingBetweenRows(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $noSpacing = $obj->getHTMLCell( '' . '
    A
    B
    ', 0, 0, 30, 30, ); $withSpacing = $obj->getHTMLCell( '' . '
    A
    B
    ', 0, 0, 30, 30, ); $this->assertNotSame('', $noSpacing); $this->assertNotSame('', $withSpacing); $noSpacingMatches = []; $withSpacingMatches = []; \preg_match_all( '/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+s/', $noSpacing, $noSpacingMatches, PREG_SET_ORDER, ); \preg_match_all( '/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+s/', $withSpacing, $withSpacingMatches, PREG_SET_ORDER, ); $this->assertGreaterThanOrEqual(2, \count($noSpacingMatches)); $this->assertGreaterThanOrEqual(2, \count($withSpacingMatches)); $noSpacingY = []; foreach ($noSpacingMatches as $match) { assert(isset($match[2]), "\$match[2] must be set"); $noSpacingY[] = \floatval($match[2]); } $withSpacingY = []; foreach ($withSpacingMatches as $match) { assert(isset($match[2]), "\$match[2] must be set"); $withSpacingY[] = \floatval($match[2]); } \sort($noSpacingY); \sort($withSpacingY); assert(isset($noSpacingY[1]), "\$noSpacingY[1] must be set"); assert(isset($noSpacingY[0]), "\$noSpacingY[0] must be set"); assert(isset($withSpacingY[1]), "\$withSpacingY[1] must be set"); assert(isset($withSpacingY[0]), "\$withSpacingY[0] must be set"); $this->assertGreaterThan( $noSpacingY[1] - $noSpacingY[0], $withSpacingY[1] - $withSpacingY[0], 'Expected a larger vertical gap between row border rectangles when CSS border-spacing is set', ); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesVerticalAlignWithinTallerRow(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $topOut = $obj->getHTMLCell( '' . '
    AB
    C
    D
    ', 0, 0, 40, 30, ); $bottomOut = $obj->getHTMLCell( '' . '
    AB
    C
    D
    ', 0, 0, 40, 30, ); $this->assertNotSame('', $topOut); $this->assertNotSame('', $bottomOut); $this->assertSame(0, \preg_match('/1 0 0 1 0 -?[0-9.]+ cm\n/', $topOut)); $this->assertSame( 1, \preg_match('/1 0 0 1 0 -[0-9.]+ cm\n/', $bottomOut), 'Bottom vertical alignment should emit a downward translation transform inside a taller row.', ); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesCssVerticalAlignWithinTallerRow(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $topOut = $obj->getHTMLCell( '' . '
    AB
    C
    D
    ', 0, 0, 40, 30, ); $bottomOut = $obj->getHTMLCell( '' . '
    AB
    C
    D
    ', 0, 0, 40, 30, ); $this->assertNotSame('', $topOut); $this->assertNotSame('', $bottomOut); $this->assertSame(0, \preg_match('/1 0 0 1 0 -?[0-9.]+ cm\n/', $topOut)); $this->assertSame( 1, \preg_match('/1 0 0 1 0 -[0-9.]+ cm\n/', $bottomOut), 'CSS bottom vertical alignment should emit a downward translation transform inside a taller row.', ); } /** * @throws \Throwable */ public function testGetHTMLCellPrefersCssVerticalAlignOverValignAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $cssTopOut = $obj->getHTMLCell( '' . '
    AB
    C
    D
    ', 0, 0, 40, 30, ); $cssBottomOut = $obj->getHTMLCell( '' . '
    AB
    C
    D
    ', 0, 0, 40, 30, ); $this->assertNotSame('', $cssTopOut); $this->assertNotSame('', $cssBottomOut); $this->assertSame(0, \preg_match('/1 0 0 1 0 -?[0-9.]+ cm\n/', $cssTopOut)); $this->assertSame(1, \preg_match('/1 0 0 1 0 -[0-9.]+ cm\n/', $cssBottomOut)); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesVerticalAlignBottomOnCompletedRowspanCell(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $topOut = $obj->getHTMLCell( '' . '' . '' . '' . '
    AB
    C
    D
    E
    ', 0, 0, 40, 40, ); $bottomOut = $obj->getHTMLCell( '' . '' . '' . '' . '
    AB
    C
    D
    E
    ', 0, 0, 40, 40, ); $this->assertNotSame('', $topOut); $this->assertNotSame('', $bottomOut); $this->assertSame(0, \preg_match('/1 0 0 1 0 -[0-9.]+ cm\n.*?\(A\) Tj/s', $topOut)); $this->assertSame( 1, \preg_match('/1 0 0 1 0 -[0-9.]+ cm\n.*?\(A\) Tj/s', $bottomOut), 'Bottom vertical alignment should translate rowspan cell content ' . 'when the spanned height exceeds content height.', ); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesVerticalAlignMiddleWithinTallerRow(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $topOut = $obj->getHTMLCell( '' . '
    AB
    C
    D
    ', 0, 0, 40, 30, ); $middleOut = $obj->getHTMLCell( '' . '
    AB
    C
    D
    ', 0, 0, 40, 30, ); $bottomOut = $obj->getHTMLCell( '' . '
    AB
    C
    D
    ', 0, 0, 40, 30, ); $this->assertNotSame('', $topOut); $this->assertNotSame('', $middleOut); $this->assertNotSame('', $bottomOut); $this->assertSame(0, \preg_match('/1 0 0 1 0 -?[0-9.]+ cm\n/', $topOut)); $middleMatch = []; $bottomMatch = []; $this->assertSame(1, \preg_match('/1 0 0 1 0 (-[0-9.]+) cm\n/', $middleOut, $middleMatch)); $this->assertSame(1, \preg_match('/1 0 0 1 0 (-[0-9.]+) cm\n/', $bottomOut, $bottomMatch)); assert(isset($middleMatch[1]), "\$middleMatch[1] must be set"); $middleOffset = \abs(\floatval($middleMatch[1])); assert(isset($bottomMatch[1]), "\$bottomMatch[1] must be set"); $bottomOffset = \abs(\floatval($bottomMatch[1])); $this->assertGreaterThan(0.0, $middleOffset); $this->assertGreaterThan($middleOffset, $bottomOffset); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesVerticalAlignMiddleOnCompletedRowspanCell(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $topOut = $obj->getHTMLCell( '' . '' . '' . '' . '
    AB
    C
    D
    E
    ', 0, 0, 40, 40, ); $middleOut = $obj->getHTMLCell( '' . '' . '' . '' . '
    AB
    C
    D
    E
    ', 0, 0, 40, 40, ); $bottomOut = $obj->getHTMLCell( '' . '' . '' . '' . '
    AB
    C
    D
    E
    ', 0, 0, 40, 40, ); $this->assertNotSame('', $topOut); $this->assertNotSame('', $middleOut); $this->assertNotSame('', $bottomOut); $this->assertSame(0, \preg_match('/1 0 0 1 0 -[0-9.]+ cm\n.*?\(A\) Tj/s', $topOut)); $middleMatch = []; $bottomMatch = []; $this->assertSame(1, \preg_match('/1 0 0 1 0 (-[0-9.]+) cm\n.*?\(A\) Tj/s', $middleOut, $middleMatch)); $this->assertSame(1, \preg_match('/1 0 0 1 0 (-[0-9.]+) cm\n.*?\(A\) Tj/s', $bottomOut, $bottomMatch)); assert(isset($middleMatch[1]), "\$middleMatch[1] must be set"); $middleOffset = \abs(\floatval($middleMatch[1])); assert(isset($bottomMatch[1]), "\$bottomMatch[1] must be set"); $bottomOffset = \abs(\floatval($bottomMatch[1])); $this->assertGreaterThan(0.0, $middleOffset); $this->assertGreaterThan($middleOffset, $bottomOffset); } /** * @throws \Throwable */ public function testGetHTMLCellVerticallyCentersContentInFixedHeightTableScenarios(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $cases = [ [ 'label' => 'div wrapper with 100% table height', 'posx' => 20.0, 'posy' => 20.0, 'width' => 150.0, 'height' => 0.0, 'containerTop' => 20.0, 'containerHeight' => 90.0, 'html' => '' . '

    ' . '' . '
    ' . '

    Vertically Centered Block

    ' . '

    This content is centered in the middle of the box.

    ' . '

    HTML: headings, paragraphs, and inline formatting.

    ' . '
    ', ], [ 'label' => 'explicit table and cell height', 'posx' => 20.0, 'posy' => 120.0, 'width' => 150.0, 'height' => 100.0, 'containerTop' => 120.0, 'containerHeight' => 100.0, 'html' => '' . '
    ' . '

    Vertically Centered Block

    ' . '

    This content is centered in the middle of the box.

    ' . '

    HTML: headings, paragraphs, and inline formatting.

    ' . '
    ', ], ]; foreach ($cases as $case) { $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($case['html'], $case['posx'], $case['posy'], $case['width'], $case['height']); $this->assertNotSame('', $out, $case['label']); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace, $case['label'] . ': expected captured text bboxes'); $firstTop = null; $lastBottom = null; foreach ($trace as $entry) { $bboxY = $entry['bbox_y']; $bboxH = $entry['bbox_h']; $bboxBottom = $bboxY + $bboxH; $firstTop = $firstTop === null ? $bboxY : \min($firstTop, $bboxY); $lastBottom = $lastBottom === null ? $bboxBottom : \max($lastBottom, $bboxBottom); } $this->assertNotNull($firstTop, $case['label'] . ': expected valid bbox top'); $this->assertNotNull($lastBottom, $case['label'] . ': expected valid bbox bottom'); $cmMatches = []; \preg_match_all('/1 0 0 1 0 (-?[0-9.]+) cm\\n/', $out, $cmMatches); $translatePt = 0.0; foreach ($cmMatches[1] ?? [] as $match) { if (!\is_numeric($match)) { continue; } $ypt = (float) $match; if (\abs($ypt) > \abs($translatePt)) { $translatePt = $ypt; } } $translateMm = -$obj->toUnit($translatePt); $this->assertGreaterThan( 20.0, $translateMm, $case['label'] . ': expected a substantial middle-valign translate offset in mm mode', ); $effectiveTop = $firstTop + $translateMm; $effectiveBottom = $lastBottom + $translateMm; $topSpace = $effectiveTop - $case['containerTop']; $bottomSpace = $case['containerTop'] + $case['containerHeight'] - $effectiveBottom; $this->assertGreaterThan(0.0, $topSpace, $case['label'] . ': top free space must be positive'); $this->assertGreaterThan(0.0, $bottomSpace, $case['label'] . ': bottom free space must be positive'); $this->assertLessThan( 6.0, \abs($topSpace - $bottomSpace), $case['label'] . ': expected near-centered top/bottom free space', ); } } /** * @throws \Throwable */ public function testGetHTMLCellAppliesVerticalAlignOnCompletedThreeRowRowspanCell(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $topOut = $obj->getHTMLCell( '' . '' . '' . '' . '' . '
    AB
    C
    D
    E
    F
    G
    H
    ', 0, 0, 40, 50, ); $middleOut = $obj->getHTMLCell( '' . '' . '' . '' . '' . '
    AB
    C
    D
    E
    F
    G
    H
    ', 0, 0, 40, 50, ); $bottomOut = $obj->getHTMLCell( '' . '' . '' . '' . '' . '
    AB
    C
    D
    E
    F
    G
    H
    ', 0, 0, 40, 50, ); $this->assertNotSame('', $topOut); $this->assertNotSame('', $middleOut); $this->assertNotSame('', $bottomOut); $this->assertSame(0, \preg_match('/1 0 0 1 0 -[0-9.]+ cm\n.*?\(A\) Tj/s', $topOut)); $middleMatch = []; $bottomMatch = []; $this->assertSame(1, \preg_match('/1 0 0 1 0 (-[0-9.]+) cm\n.*?\(A\) Tj/s', $middleOut, $middleMatch)); $this->assertSame(1, \preg_match('/1 0 0 1 0 (-[0-9.]+) cm\n.*?\(A\) Tj/s', $bottomOut, $bottomMatch)); assert(isset($middleMatch[1]), "\$middleMatch[1] must be set"); $middleOffset = \abs(\floatval($middleMatch[1])); assert(isset($bottomMatch[1]), "\$bottomMatch[1] must be set"); $bottomOffset = \abs(\floatval($bottomMatch[1])); $this->assertGreaterThan(0.0, $middleOffset); $this->assertGreaterThan($middleOffset, $bottomOffset); } /** * @throws \Throwable */ public function testGetHTMLCellPrefersCssVerticalAlignOverValignOnCompletedRowspanCell(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $cssMiddleOut = $obj->getHTMLCell( '' . '' . '' . '' . '
    AB
    C
    D
    E
    ', 0, 0, 40, 40, ); $attrBottomOut = $obj->getHTMLCell( '' . '' . '' . '' . '
    AB
    C
    D
    E
    ', 0, 0, 40, 40, ); $this->assertNotSame('', $cssMiddleOut); $this->assertNotSame('', $attrBottomOut); $middleMatch = []; $bottomMatch = []; $this->assertSame(1, \preg_match('/1 0 0 1 0 (-[0-9.]+) cm\n.*?\(A\) Tj/s', $cssMiddleOut, $middleMatch)); $this->assertSame(1, \preg_match('/1 0 0 1 0 (-[0-9.]+) cm\n.*?\(A\) Tj/s', $attrBottomOut, $bottomMatch)); assert(isset($middleMatch[1]), "\$middleMatch[1] must be set"); $middleOffset = \abs(\floatval($middleMatch[1])); assert(isset($bottomMatch[1]), "\$bottomMatch[1] must be set"); $bottomOffset = \abs(\floatval($bottomMatch[1])); $this->assertGreaterThan(0.0, $middleOffset); $this->assertGreaterThan($middleOffset, $bottomOffset); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesVerticalAlignBottomOnCompletedRowspanHeaderCell(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $topOut = $obj->getHTMLCell( '' . '' . '' . '' . '
    AB
    C
    D
    E
    ', 0, 0, 40, 40, ); $bottomOut = $obj->getHTMLCell( '' . '' . '' . '' . '
    AB
    C
    D
    E
    ', 0, 0, 40, 40, ); $this->assertNotSame('', $topOut); $this->assertNotSame('', $bottomOut); $this->assertSame(0, \preg_match('/1 0 0 1 0 -[0-9.]+ cm\\n.*?\\(A\\) Tj/s', $topOut)); $this->assertSame( 1, \preg_match('/1 0 0 1 0 -[0-9.]+ cm\\n.*?\\(A\\) Tj/s', $bottomOut), 'Bottom vertical alignment should translate completed rowspan header-cell content.', ); } /** * @throws \Throwable */ public function testGetHTMLCellCollapseAvoidsDuplicateOuterBorderWithCellBorder(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '' . '
    A
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+s/', $out, $matches, PREG_SET_ORDER); $this->assertCount( 1, $matches, 'Collapsed single-cell table should avoid drawing duplicate outer-border rectangles', ); } /** * @throws \Throwable */ public function testGetHTMLCellCollapseIgnoresCssBorderSpacing(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell( '' . '
    A
    ', 0, 0, 30, 20, ); $this->assertNotSame('', $out); $matches = []; \preg_match_all('/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+s/', $out, $matches, PREG_SET_ORDER); $this->assertCount( 1, $matches, 'Collapsed single-cell table should ignore CSS border-spacing and avoid separated outer-border rectangles', ); } /** * @throws \Throwable */ public function testGetHTMLCellAppliesMixedCssBorderSpacingAcrossRowsAndColumns(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $noSpacing = $obj->getHTMLCell( '' . '
    AB
    CD
    ', 0, 0, 40, 30, ); $withSpacing = $obj->getHTMLCell( '' . '' . '
    AB
    CD
    ', 0, 0, 40, 30, ); $this->assertNotSame('', $noSpacing); $this->assertNotSame('', $withSpacing); $noSpacingMatches = []; $withSpacingMatches = []; \preg_match_all( '/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+s/', $noSpacing, $noSpacingMatches, PREG_SET_ORDER, ); \preg_match_all( '/(-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) (-?[0-9.]+) re\s+s/', $withSpacing, $withSpacingMatches, PREG_SET_ORDER, ); $this->assertGreaterThanOrEqual(4, \count($noSpacingMatches)); $this->assertGreaterThanOrEqual(4, \count($withSpacingMatches)); $noSpacingX = []; $noSpacingY = []; $noSpacingWidths = []; $noSpacingHeights = []; foreach ($noSpacingMatches as $match) { assert(isset($match[1]), "\$match[1] must be set"); $noSpacingX[] = \round(\floatval($match[1]), 4); assert(isset($match[2]), "\$match[2] must be set"); $noSpacingY[] = \round(\floatval($match[2]), 4); assert(isset($match[3]), "\$match[3] must be set"); $noSpacingWidths[] = \round(\floatval($match[3]), 4); assert(isset($match[4]), "\$match[4] must be set"); $noSpacingHeights[] = \round(\floatval($match[4]), 4); } $withSpacingX = []; $withSpacingY = []; $withSpacingWidths = []; $withSpacingHeights = []; foreach ($withSpacingMatches as $match) { assert(isset($match[1]), "\$match[1] must be set"); $withSpacingX[] = \round(\floatval($match[1]), 4); assert(isset($match[2]), "\$match[2] must be set"); $withSpacingY[] = \round(\floatval($match[2]), 4); assert(isset($match[3]), "\$match[3] must be set"); $withSpacingWidths[] = \round(\floatval($match[3]), 4); assert(isset($match[4]), "\$match[4] must be set"); $withSpacingHeights[] = \round(\floatval($match[4]), 4); } $noSpacingX = \array_values(\array_unique($noSpacingX)); $noSpacingY = \array_values(\array_unique($noSpacingY)); $noSpacingWidths = \array_values(\array_unique($noSpacingWidths)); $noSpacingHeights = \array_values(\array_unique($noSpacingHeights)); $withSpacingX = \array_values(\array_unique($withSpacingX)); $withSpacingY = \array_values(\array_unique($withSpacingY)); $withSpacingWidths = \array_values(\array_unique($withSpacingWidths)); $withSpacingHeights = \array_values(\array_unique($withSpacingHeights)); \sort($noSpacingX); \sort($noSpacingY); \sort($noSpacingWidths); \sort($noSpacingHeights); \sort($withSpacingX); \sort($withSpacingY); \sort($withSpacingWidths); \sort($withSpacingHeights); $this->assertCount(2, $noSpacingX); $this->assertCount(2, $noSpacingY); $this->assertCount(1, $noSpacingWidths); $this->assertCount(1, $noSpacingHeights); $this->assertCount(2, $withSpacingX); $this->assertCount(2, $withSpacingY); $this->assertCount(1, $withSpacingWidths); $this->assertCount(1, $withSpacingHeights); assert(isset($noSpacingX[1]), "\$noSpacingX[1] must be set"); assert(isset($noSpacingX[0]), "\$noSpacingX[0] must be set"); assert(isset($noSpacingWidths[0]), "\$noSpacingWidths[0] must be set"); $noSpacingHGap = $noSpacingX[1] - ($noSpacingX[0] + $noSpacingWidths[0]); assert(isset($withSpacingX[1]), "\$withSpacingX[1] must be set"); assert(isset($withSpacingX[0]), "\$withSpacingX[0] must be set"); assert(isset($withSpacingWidths[0]), "\$withSpacingWidths[0] must be set"); $withSpacingHGap = $withSpacingX[1] - ($withSpacingX[0] + $withSpacingWidths[0]); assert(isset($noSpacingY[1]), "\$noSpacingY[1] must be set"); assert(isset($noSpacingY[0]), "\$noSpacingY[0] must be set"); assert(isset($noSpacingHeights[0]), "\$noSpacingHeights[0] must be set"); $noSpacingVGap = $noSpacingY[1] - ($noSpacingY[0] + $noSpacingHeights[0]); assert(isset($withSpacingY[1]), "\$withSpacingY[1] must be set"); assert(isset($withSpacingY[0]), "\$withSpacingY[0] must be set"); assert(isset($withSpacingHeights[0]), "\$withSpacingHeights[0] must be set"); $withSpacingVGap = $withSpacingY[1] - ($withSpacingY[0] + $withSpacingHeights[0]); $this->assertGreaterThan( $noSpacingHGap, $withSpacingHGap, 'Expected mixed CSS border-spacing to increase the horizontal gap between rendered cell columns', ); $this->assertGreaterThan( $noSpacingVGap, $withSpacingVGap, 'Expected mixed CSS border-spacing to increase the vertical gap between rendered cell rows', ); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtableTreatsCollapseAsZeroCellspacing(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $separateElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'cols' => 1, 'pendingcellspacingh' => 3.0, 'pendingcellspacingv' => 3.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [20.0], ]); $collapseElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 3.0, 'pendingcellspacingv' => 3.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [20.0], ]); $separateX = 0.0; $separateY = 0.0; $separateW = 30.0; $separateH = 20.0; $obj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENtable', $separateElm, $separateX, $separateY, $separateW, $separateH, ); $collapseX = 0.0; $collapseY = 0.0; $collapseW = 30.0; $collapseH = 20.0; $obj->exposeInvokeParseHTMLTagMethod( 'parseHTMLTagOPENtable', $collapseElm, $collapseX, $collapseY, $collapseW, $collapseH, ); $this->assertSame(3.0, $separateY); $this->assertSame(0.0, $collapseY); $this->assertGreaterThan($collapseY, $separateY); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtableUsesHorizontalAndVerticalBorderSpacingIndependently(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 30.0, 20.0); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'table-layout' => 'fixed', 'cols' => 1, 'pendingcellspacingh' => 3.0, 'pendingcellspacingv' => 6.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [20.0], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; $this->assertSame(6.0, $tpy); $this->assertSame(26.0, $table['width']); $this->assertSame(3.0, $table['cellspacingh']); $this->assertSame(6.0, $table['cellspacingv']); $this->assertSame(6.0, $table['rowtop']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtableUsesPendingColWidthsForFixedAndAutoLayouts(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 60.0, 20.0); $fixedElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'table-layout' => 'fixed', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [10.0, 30.0], ]); $autoElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'table-layout' => 'auto', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [10.0, 30.0], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $fixedElm, $tpx, $tpy, $tpw, $tph); $tpx = 0.0; $tpy = 0.0; $tpw = 40.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $autoElm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertCount(2, $hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $fixedTable = $hrc['tablestack'][0]; assert(isset($hrc['tablestack'][1]), "\$hrc['tablestack'][1] must be set"); $autoTable = $hrc['tablestack'][1]; $this->assertSame([10.0, 30.0], $fixedTable['colwidths']); $this->assertSame([10.0, 30.0], $autoTable['colwidths']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCarriesResolvedValignIntoCellContext(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $tdElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'valign' => 'bottom', ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $tdElm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $this->assertSame('bottom', $hrc['bcellctx'][0]['valign']); } /** * @throws \Throwable */ public function testParseHTMLTagCLOSEtdEmptyCellsHideSuppressesBorderAndFillForEmptySeparateCells(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'separate', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $tdElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'empty-cells' => 'hide', 'bgcolor' => '#eeeeee', 'border' => [ 'T' => ['lineWidth' => 1.0, 'lineColor' => '#111'], 'R' => ['lineWidth' => 1.0, 'lineColor' => '#111'], 'B' => ['lineWidth' => 1.0, 'lineColor' => '#111'], 'L' => ['lineWidth' => 1.0, 'lineColor' => '#111'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $tdElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $tdElm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; $this->assertNotEmpty($table['cells']); assert(isset($table['cells'][0]), "\$table['cells'][0] must be set"); $this->assertSame([], $table['cells'][0]['bstyles']); $this->assertNull($table['cells'][0]['fillstyle']); } /** * @throws \Throwable */ public function testParseHTMLTagCLOSEtdEmptyCellsHideDoesNotSuppressInCollapseMode(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $tdElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'empty-cells' => 'hide', 'bgcolor' => '#eeeeee', 'border' => [ 'T' => ['lineWidth' => 1.0, 'lineColor' => '#111'], 'R' => ['lineWidth' => 1.0, 'lineColor' => '#111'], 'B' => ['lineWidth' => 1.0, 'lineColor' => '#111'], 'L' => ['lineWidth' => 1.0, 'lineColor' => '#111'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $tdElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $tdElm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; $this->assertNotEmpty($table['cells']); assert(isset($table['cells'][0]), "\$table['cells'][0] must be set"); $this->assertNotSame([], $table['cells'][0]['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENthCarriesResolvedValignIntoCellContext(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $thElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'th', 'valign' => 'middle', ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENth', $thElm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $this->assertSame('middle', $hrc['bcellctx'][0]['valign']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersWiderSharedVerticalBorder(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $leftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'R' => ['lineWidth' => 0.5, 'lineColor' => '#111'], ], ]); $rightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => ['lineWidth' => 2.0, 'lineColor' => '#222'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $leftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $leftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $rightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; assert(isset($table['cells'][0]), "\$table['cells'][0] must be set"); $this->assertArrayHasKey(1, $table['cells'][0]['bstyles']); assert(isset($table['cells'][0]['bstyles'][1]), "\$table['cells'][0]['bstyles'][1] must be set"); $this->assertSame(2.0, $table['cells'][0]['bstyles'][1]['lineWidth']); $this->assertSame('#222', $table['cells'][0]['bstyles'][1]['lineColor']); $this->assertArrayNotHasKey(3, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersSolidWhenWidthsMatchOnSharedVerticalBorder(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $leftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'R' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [3, 3], 'dashPhase' => 3, ], ], ]); $rightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $leftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $leftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $rightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($table['cells'][0]), "\$table['cells'][0] must be set"); $this->assertArrayHasKey(1, $table['cells'][0]['bstyles']); assert(isset($table['cells'][0]['bstyles'][1]), "\$table['cells'][0]['bstyles'][1] must be set"); $this->assertSame('#222', $table['cells'][0]['bstyles'][1]['lineColor']); $this->assertSame([], $table['cells'][0]['bstyles'][1]['dashArray']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersRightCellOnEqualSharedVerticalTieInRtlTable(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'dir' => 'rtl', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $leftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'R' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $rightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $leftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $leftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $rightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($table['cells'][0]), "\$table['cells'][0] must be set"); $this->assertArrayHasKey(1, $table['cells'][0]['bstyles']); assert(isset($table['cells'][0]['bstyles'][1]), "\$table['cells'][0]['bstyles'][1] must be set"); $this->assertSame('#222', $table['cells'][0]['bstyles'][1]['lineColor']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersDoubleWhenWidthsMatchOnSharedVerticalBorder(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $leftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'R' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'lineCap' => 'square', 'lineJoin' => 'miter', 'miterLimit' => 10.0, 'dashArray' => [], 'dashPhase' => 0.0, 'fillColor' => '', 'cssBorderStyle' => 'solid', ], ], ]); $rightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'lineCap' => 'square', 'lineJoin' => 'miter', 'miterLimit' => 10.0, 'dashArray' => [], 'dashPhase' => 0.0, 'fillColor' => '', 'cssBorderStyle' => 'double', ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $leftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $leftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $rightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($table['cells'][0]), "\$table['cells'][0] must be set"); $this->assertArrayHasKey(1, $table['cells'][0]['bstyles']); assert(isset($table['cells'][0]['bstyles'][1]), "\$table['cells'][0]['bstyles'][1] must be set"); $this->assertSame('#222', $table['cells'][0]['bstyles'][1]['lineColor']); $this->assertSame('double', $obj->exposeGetHTMLCollapsedBorderStyleName($table['cells'][0]['bstyles'][1])); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersSolidSharedVerticalBorderAfterColspanCell(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 3, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [10.0, 10.0, 10.0], ]); $leftSpanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['colspan' => '2'], 'border' => [ 'R' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [3, 3], 'dashPhase' => 3, ], ], ]); $rightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 36.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $leftSpanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $leftSpanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $rightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertNotEmpty($table['cells']); assert(isset($table['cells'][0]), "\$table['cells'][0] must be set"); $this->assertArrayHasKey(1, $table['cells'][0]['bstyles']); assert(isset($table['cells'][0]['bstyles'][1]), "\$table['cells'][0]['bstyles'][1] must be set"); $this->assertSame('#222', $table['cells'][0]['bstyles'][1]['lineColor']); $this->assertSame([], $table['cells'][0]['bstyles'][1]['dashArray']); $this->assertArrayNotHasKey(3, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersWiderSharedVerticalBorderAfterColspanCell(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 3, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [10.0, 10.0, 10.0], ]); $leftSpanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['colspan' => '2'], 'border' => [ 'R' => [ 'lineWidth' => 2.0, 'lineColor' => '#111', 'dashArray' => [3, 3], 'dashPhase' => 3, ], ], ]); $rightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 36.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $leftSpanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $leftSpanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $rightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; $this->assertNotEmpty($table['cells']); assert(isset($table['cells'][0]), "\$table['cells'][0] must be set"); $this->assertArrayHasKey(1, $table['cells'][0]['bstyles']); assert(isset($table['cells'][0]['bstyles'][1]), "\$table['cells'][0]['bstyles'][1] must be set"); $this->assertSame(2.0, $table['cells'][0]['bstyles'][1]['lineWidth']); $this->assertSame('#111', $table['cells'][0]['bstyles'][1]['lineColor']); $this->assertSame([3, 3], $table['cells'][0]['bstyles'][1]['dashArray']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseKeepsTopWhenPreferredOverPreviousRowBottom(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => ['lineWidth' => 0.5, 'lineColor' => '#111'], ], ]); $row2Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'T' => ['lineWidth' => 2.0, 'lineColor' => '#222'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2Td, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertArrayHasKey(0, $currentCell['bstyles']); assert(isset($currentCell['bstyles'][0]), "\$currentCell['bstyles'][0] must be set"); $this->assertSame(2.0, $currentCell['bstyles'][0]['lineWidth']); $this->assertSame('#222', $currentCell['bstyles'][0]['lineColor']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersSolidTopWhenWidthsMatchPreviousRowBottom(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [3, 3], 'dashPhase' => 3, ], ], ]); $row2Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'T' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2Td, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertArrayHasKey(0, $currentCell['bstyles']); assert(isset($currentCell['bstyles'][0]), "\$currentCell['bstyles'][0] must be set"); $this->assertSame('#222', $currentCell['bstyles'][0]['lineColor']); $this->assertSame([], $currentCell['bstyles'][0]['dashArray']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersSharedVerticalBorderAgainstActiveRowspan(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'R' => ['lineWidth' => 0.5, 'lineColor' => '#111'], ], ]); $row1RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', ]); $row2RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => ['lineWidth' => 2.0, 'lineColor' => '#222'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2RightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertNotEmpty($table['rowspans']); assert(isset($table['rowspans'][0]), "\$table['rowspans'][0] must be set"); $this->assertArrayHasKey(1, $table['rowspans'][0]['bstyles']); assert(isset($table['rowspans'][0]['bstyles'][1]), "\$table['rowspans'][0]['bstyles'][1] must be set"); $this->assertSame(2.0, $table['rowspans'][0]['bstyles'][1]['lineWidth']); $this->assertSame('#222', $table['rowspans'][0]['bstyles'][1]['lineColor']); $this->assertArrayNotHasKey(3, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersCurrentCellOnEqualTieRtl(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'dir' => 'rtl', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'R' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $row1RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', ]); $row2RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2RightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertNotEmpty($table['rowspans']); assert(isset($table['rowspans'][0]), "\$table['rowspans'][0] must be set"); $this->assertArrayHasKey(1, $table['rowspans'][0]['bstyles']); assert(isset($table['rowspans'][0]['bstyles'][1]), "\$table['rowspans'][0]['bstyles'][1] must be set"); $this->assertSame('#222', $table['rowspans'][0]['bstyles'][1]['lineColor']); $this->assertArrayNotHasKey(3, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersRowspanCellOnEqualVerticalTieInLtrTable(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'R' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $row1RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', ]); $row2RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2RightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertNotEmpty($table['rowspans']); assert(isset($table['rowspans'][0]), "\$table['rowspans'][0] must be set"); $this->assertArrayHasKey(1, $table['rowspans'][0]['bstyles']); assert(isset($table['rowspans'][0]['bstyles'][1]), "\$table['rowspans'][0]['bstyles'][1] must be set"); $this->assertSame('#111', $table['rowspans'][0]['bstyles'][1]['lineColor']); $this->assertArrayNotHasKey(3, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersRowspanColspanCellOnEqualVerticalTieInLtrTable(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 3, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [8.0, 8.0, 8.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftSpanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2', 'colspan' => '2'], 'border' => [ 'R' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $row1RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', ]); $row2RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftSpanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftSpanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2RightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertNotEmpty($table['rowspans']); assert(isset($table['rowspans'][0]), "\$table['rowspans'][0] must be set"); $this->assertSame(2, (int) $table['rowspans'][0]['colspan']); $this->assertArrayHasKey(1, $table['rowspans'][0]['bstyles']); assert(isset($table['rowspans'][0]['bstyles'][1]), "\$table['rowspans'][0]['bstyles'][1] must be set"); $this->assertSame('#111', $table['rowspans'][0]['bstyles'][1]['lineColor']); $this->assertArrayNotHasKey(3, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersCurrentCellOnRowspanColspanEqualVerticalTieInRtlTable(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'dir' => 'rtl', 'border-collapse' => 'collapse', 'cols' => 3, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [8.0, 8.0, 8.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftSpanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2', 'colspan' => '2'], 'border' => [ 'R' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $row1RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', ]); $row2RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftSpanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftSpanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2RightTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertNotEmpty($table['rowspans']); assert(isset($table['rowspans'][0]), "\$table['rowspans'][0] must be set"); $this->assertSame(2, (int) $table['rowspans'][0]['colspan']); $this->assertArrayHasKey(1, $table['rowspans'][0]['bstyles']); assert(isset($table['rowspans'][0]['bstyles'][1]), "\$table['rowspans'][0]['bstyles'][1] must be set"); $this->assertSame('#222', $table['rowspans'][0]['bstyles'][1]['lineColor']); $this->assertArrayNotHasKey(3, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseSkipsNonAdjacentPreviousCellWhenRowspanOccupiesLeftBoundary(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 4, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [10.0, 10.0, 10.0, 10.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1Col0 = $this->makeHtmlNode(['opening' => true, 'value' => 'td']); $row1Col1 = $this->makeHtmlNode(['opening' => true, 'value' => 'td']); $row1Col2Rowspan = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'R' => ['lineWidth' => 0.5, 'lineColor' => '#111'], ], ]); $row1Col3 = $this->makeHtmlNode(['opening' => true, 'value' => 'td']); $row2Col0 = $this->makeHtmlNode(['opening' => true, 'value' => 'td']); $row2Col1 = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'R' => ['lineWidth' => 3.0, 'lineColor' => '#333'], ], ]); $row2Col3 = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'L' => ['lineWidth' => 2.0, 'lineColor' => '#222'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 50.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Col0, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Col0, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Col1, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Col1, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Col2Rowspan, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Col2Rowspan, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Col3, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Col3, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2Col0, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row2Col0, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2Col1, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row2Col1, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2Col3, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertCount(2, $table['cells']); assert(isset($table['cells'][1]), "\$table['cells'][1] must be set"); $this->assertArrayHasKey(1, $table['cells'][1]['bstyles']); assert(isset($table['cells'][1]['bstyles'][1]), "\$table['cells'][1]['bstyles'][1] must be set"); $this->assertSame(3.0, $table['cells'][1]['bstyles'][1]['lineWidth']); $this->assertSame('#333', $table['cells'][1]['bstyles'][1]['lineColor']); $this->assertNotEmpty($table['rowspans']); assert(isset($table['rowspans'][0]), "\$table['rowspans'][0] must be set"); $this->assertArrayHasKey(1, $table['rowspans'][0]['bstyles']); assert(isset($table['rowspans'][0]['bstyles'][1]), "\$table['rowspans'][0]['bstyles'][1] must be set"); $this->assertSame(2.0, $table['rowspans'][0]['bstyles'][1]['lineWidth']); $this->assertSame('#222', $table['rowspans'][0]['bstyles'][1]['lineColor']); $this->assertArrayNotHasKey(3, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseSuppressesColspanTopWhenCoveredByStrongerPreviousBottom(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => ['lineWidth' => 2.0, 'lineColor' => '#111'], ], ]); $row1RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => ['lineWidth' => 0.5, 'lineColor' => '#222'], ], ]); $row2SpanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['colspan' => '2'], 'border' => [ 'T' => ['lineWidth' => 1.0, 'lineColor' => '#333'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2SpanTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertArrayNotHasKey(0, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseSuppressesTopBelowCompletedRowspanBottom(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'B' => ['lineWidth' => 2.0, 'lineColor' => '#111'], ], ]); $row3Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'T' => ['lineWidth' => 1.0, 'lineColor' => '#222'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row3Td, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertArrayNotHasKey(0, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseSuppressesEqualHorizontalTopTieInRtlTable(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'dir' => 'rtl', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $row2Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'T' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2Td, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertArrayNotHasKey(0, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagCLOSEtrCollapsePrevRowBottomPrefersCompletedRowspanOnEqualCellTie(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'B' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $row2Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row2Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; $this->assertArrayHasKey(0, $table['prevrowbottom']); assert(isset($table['prevrowbottom'][0]), "\$table['prevrowbottom'][0] must be set"); $this->assertSame('#111', $table['prevrowbottom'][0]['lineColor']); } /** * @throws \Throwable */ public function testParseHTMLTagCLOSEtrCollapsePrevRowBottomPrefersCompletedRowspanOnCompetingCurrentCell(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'B' => [ 'lineWidth' => 1.5, 'lineColor' => '#111', ], ], ]); $row2Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => [ 'lineWidth' => 2.0, 'lineColor' => '#222', ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row2Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['tablestack']); assert(isset($hrc['tablestack'][0]), "\$hrc['tablestack'][0] must be set"); $table = $hrc['tablestack'][0]; $this->assertArrayHasKey(0, $table['prevrowbottom']); assert(isset($table['prevrowbottom'][0]), "\$table['prevrowbottom'][0] must be set"); $this->assertSame('#111', $table['prevrowbottom'][0]['lineColor']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapsePrefersSolidTopBelowCompletedRowspanBottomWhenWidthsMatch(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'B' => [ 'lineWidth' => 1.0, 'lineColor' => '#111', 'dashArray' => [3, 3], 'dashPhase' => 3, ], ], ]); $row3Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'T' => [ 'lineWidth' => 1.0, 'lineColor' => '#222', 'dashArray' => [], 'dashPhase' => 0, ], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row3Td, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertArrayHasKey(0, $currentCell['bstyles']); assert(isset($currentCell['bstyles'][0]), "\$currentCell['bstyles'][0] must be set"); $this->assertSame('#222', $currentCell['bstyles'][0]['lineColor']); $this->assertSame([], $currentCell['bstyles'][0]['dashArray']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseKeepsTopBelowCompletedRowspanBottomWhenCurrentTopIsWider(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 1, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [24.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'B' => ['lineWidth' => 0.5, 'lineColor' => '#111'], ], ]); $row3Td = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'T' => ['lineWidth' => 2.0, 'lineColor' => '#222'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1Td, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row3Td, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertArrayHasKey(0, $currentCell['bstyles']); assert(isset($currentCell['bstyles'][0]), "\$currentCell['bstyles'][0] must be set"); $this->assertSame(2.0, $currentCell['bstyles'][0]['lineWidth']); $this->assertSame('#222', $currentCell['bstyles'][0]['lineColor']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseKeepsColspanTopWhenAnyCoveredSegmentIsUnresolved(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => ['lineWidth' => 2.0, 'lineColor' => '#111'], ], ]); $row1RightRowspanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], ]); $row2SpanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['colspan' => '2'], 'border' => [ 'T' => ['lineWidth' => 1.0, 'lineColor' => '#222'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightRowspanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightRowspanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2SpanTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; // Mixed coverage keeps the colspan top edge when any segment has no previous-row owner. $this->assertArrayHasKey(0, $currentCell['bstyles']); assert(isset($currentCell['bstyles'][0]), "\$currentCell['bstyles'][0] must be set"); $this->assertSame('#222', $currentCell['bstyles'][0]['lineColor']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseKeepsColspanTopWhenAnyCoveredSegmentIsUnresolvedInRtlTable(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'dir' => 'rtl', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => ['lineWidth' => 2.0, 'lineColor' => '#111'], ], ]); $row1RightRowspanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], ]); $row2SpanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['colspan' => '2'], 'border' => [ 'T' => ['lineWidth' => 1.0, 'lineColor' => '#222'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightRowspanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightRowspanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2SpanTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertArrayHasKey(0, $currentCell['bstyles']); assert(isset($currentCell['bstyles'][0]), "\$currentCell['bstyles'][0] must be set"); $this->assertSame('#222', $currentCell['bstyles'][0]['lineColor']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseSuppressesColspanTopOnStrongerCoveredSegment(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftRowspanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'B' => ['lineWidth' => 2.0, 'lineColor' => '#111'], ], ]); $row1RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => ['lineWidth' => 0.5, 'lineColor' => '#222'], ], ]); $row2RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => ['lineWidth' => 0.5, 'lineColor' => '#333'], ], ]); $row3SpanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['colspan' => '2'], 'border' => [ 'T' => ['lineWidth' => 1.0, 'lineColor' => '#444'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftRowspanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftRowspanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row2RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row3SpanTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; // Covered colspan top edge is suppressed when any covered segment keeps a stronger previous-row owner. $this->assertArrayNotHasKey(0, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testParseHTMLTagOPENtdCollapseSuppressesColspanTopOnStrongerCoveredSegmentRtl(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $tableElm = $this->makeHtmlNode([ 'opening' => true, 'value' => 'table', 'dir' => 'rtl', 'border-collapse' => 'collapse', 'cols' => 2, 'pendingcellspacingh' => 0.0, 'pendingcellspacingv' => 0.0, 'pendingcellpadding' => 0.0, 'pendingcolwidths' => [12.0, 12.0], ]); $trElm = $this->makeHtmlNode(['value' => 'tr']); $row1LeftRowspanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['rowspan' => '2'], 'border' => [ 'B' => ['lineWidth' => 2.0, 'lineColor' => '#111'], ], ]); $row1RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => ['lineWidth' => 0.5, 'lineColor' => '#222'], ], ]); $row2RightTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'border' => [ 'B' => ['lineWidth' => 0.5, 'lineColor' => '#333'], ], ]); $row3SpanTd = $this->makeHtmlNode([ 'opening' => true, 'value' => 'td', 'attribute' => ['colspan' => '2'], 'border' => [ 'T' => ['lineWidth' => 1.0, 'lineColor' => '#444'], ], ]); $tpx = 0.0; $tpy = 0.0; $tpw = 30.0; $tph = 20.0; $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtable', $tableElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1LeftRowspanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1LeftRowspanTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row1RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row2RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtd', $row2RightTd, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagCLOSEtr', $trElm, $tpx, $tpy, $tpw, $tph); $obj->exposeInvokeParseHTMLTagMethod('parseHTMLTagOPENtd', $row3SpanTd, $tpx, $tpy, $tpw, $tph); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertNotEmpty($hrc['bcellctx']); assert(isset($hrc['bcellctx'][0]), "\$hrc['bcellctx'][0] must be set"); $currentCell = $hrc['bcellctx'][0]; $this->assertArrayNotHasKey(0, $currentCell['bstyles']); } /** * @throws \Throwable */ public function testGetHTMLCellTreatsFormAsBlockContainer(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('
    A
    B', 0, 0, 30, 20); $this->assertNotSame('', $out); $this->assertStringContainsString('A', $out); $this->assertStringContainsString('B', $out); $this->assertMatchesRegularExpression('/\(A\) Tj.*\(B\) Tj/s', $out); } /** * @throws \Throwable */ public function testCloseHTMLBlockAdvancesWhenInlineContentWasRendered(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 16.0, 'line-height' => 1.0, '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], ]); $obj->exposeInitHTMLCellContext(20.0, 120.0, 150.0, 0.0); $tpx = 48.0; $tpy = 120.0; $tpw = 122.0; $obj->exposeCloseHTMLBlock($elm, $tpx, $tpy, $tpw); $this->assertSame(20.0, $tpx); $this->assertSame(150.0, $tpw); $this->assertGreaterThan(120.0, $tpy); } /** * @throws \Throwable */ public function testCloseHTMLBlockDoesNotAddExtraLineWhenAlreadyAtLineStart(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 16.0, 'line-height' => 1.0, '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], ]); $obj->exposeInitHTMLCellContext(20.0, 120.0, 150.0, 0.0); $tpx = 20.0; $tpy = 140.0; $tpw = 150.0; $obj->exposeCloseHTMLBlock($elm, $tpx, $tpy, $tpw); $this->assertSame(20.0, $tpx); $this->assertSame(150.0, $tpw); $this->assertSame(140.0, $tpy); } /** * @throws \Throwable */ public function testOpenHTMLBlockAdvancesLineWhenInlineContentWasRendered(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 16.0, 'line-height' => 1.0, '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], ]); $obj->exposeInitHTMLCellContext(20.0, 120.0, 150.0, 0.0); $obj->exposeSetHTMLLineState(6.0, 120.0, false); // Simulate inline content rendered at tpx > originx (e.g. "c" text) $tpx = 48.0; $tpy = 120.0; $tpw = 122.0; $obj->exposeOpenHTMLBlock($elm, $tpx, $tpy, $tpw); // tpx must be reset to origin $this->assertSame(20.0, $tpx); // tpy must advance by at least a line height $this->assertGreaterThan(120.0, $tpy); } /** * @throws \Throwable */ public function testOpenHTMLBlockDoesNotAdvanceForIndentOffsetWithoutRenderedText(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 16.0, 'line-height' => 1.0, '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], ]); $obj->exposeInitHTMLCellContext(20.0, 120.0, 150.0, 0.0); $obj->exposeSetHTMLLineState(0.0, 120.0, false); // Simulate list/container indentation shifting tpx without any inline glyphs rendered. $tpx = 48.0; $tpy = 140.0; $tpw = 122.0; $obj->exposeOpenHTMLBlock($elm, $tpx, $tpy, $tpw); $this->assertSame(20.0, $tpx); $this->assertSame(140.0, $tpy); } /** * @throws \Throwable */ public function testOpenHTMLBlockDoesNotDoubleAdvanceWhenAlreadyAtLineStart(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $elm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 16.0, 'line-height' => 1.0, '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], ]); $obj->exposeInitHTMLCellContext(20.0, 120.0, 150.0, 0.0); // Cursor already at line start (after a closeHTMLBlock) $tpx = 20.0; $tpy = 140.0; $tpw = 150.0; $obj->exposeOpenHTMLBlock($elm, $tpx, $tpy, $tpw); // tpy should not advance by an extra line — no inline content to push past $this->assertSame(20.0, $tpx); $this->assertSame(140.0, $tpy); } /** * @throws \Throwable */ public function testAdjacentBlockMarginsCollapseToMax(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $closeElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 8.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], ]); $openElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'margin' => ['T' => 12.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], ]); $obj->exposeInitHTMLCellContext(20.0, 120.0, 150.0, 0.0); // Close previous block at line start: adds only bottom margin (8). $tpx = 20.0; $tpy = 140.0; $tpw = 150.0; $obj->exposeCloseHTMLBlock($closeElm, $tpx, $tpy, $tpw); $afterClose = $tpy; // Open next block on same line context: top margin (12) collapses with previous bottom (8), net +4. $obj->exposeOpenHTMLBlock($openElm, $tpx, $tpy, $tpw); $this->assertEqualsWithDelta($afterClose + 4.0, $tpy, 0.0001); } /** * @throws \Throwable */ public function testInlineContentPreventsMarginCollapse(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $closeElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 8.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], ]); $openElm = $this->makeHtmlNode([ 'fontname' => 'helvetica', 'fontsize' => 12.0, 'line-height' => 1.0, 'margin' => ['T' => 12.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], 'padding' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], ]); $obj->exposeInitHTMLCellContext(20.0, 120.0, 150.0, 0.0); $obj->exposeSetHTMLLineState(6.0, 120.0, false); // Store pending bottom margin from previous close. $tpx = 20.0; $tpy = 140.0; $tpw = 150.0; $obj->exposeCloseHTMLBlock($closeElm, $tpx, $tpy, $tpw); $afterClose = $tpy; // Simulate inline content before next block opening: collapse must not apply. $tpx = 48.0; $obj->exposeSetHTMLLineState(6.0, $tpy, false); $obj->exposeOpenHTMLBlock($openElm, $tpx, $tpy, $tpw); $this->assertGreaterThan($afterClose + 4.0, $tpy); } /** * @throws \Throwable */ public function testPdfuaClampHeadingRolePassesThroughNonHeadingRoles(): void { $obj = $this->getInternalTestObject(); $this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1'); $this->assertSame('P', $obj->exposePdfuaClampHeadingRole('P')); $this->assertSame('L', $obj->exposePdfuaClampHeadingRole('L')); $this->assertSame('Figure', $obj->exposePdfuaClampHeadingRole('Figure')); } /** * @throws \Throwable */ public function testPdfuaClampHeadingRoleAllowsFirstH1(): void { $obj = $this->getInternalTestObject(); $this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1'); $this->assertSame('H1', $obj->exposePdfuaClampHeadingRole('H1')); $this->assertSame(1, $this->getObjectProperty($obj, 'pdfuaHeadingLevel')); } /** * @throws \Throwable */ public function testPdfuaClampHeadingRoleClampsFirstH2ToH1(): void { $obj = $this->getInternalTestObject(); $this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1'); // First heading in document is H2 — must be clamped to H1 $this->assertSame('H1', $obj->exposePdfuaClampHeadingRole('H2')); $this->assertSame(1, $this->getObjectProperty($obj, 'pdfuaHeadingLevel')); } /** * @throws \Throwable */ public function testPdfuaClampHeadingRoleClampsSkippedLevel(): void { $obj = $this->getInternalTestObject(); $this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1'); // H1 → H3 skips H2; H3 should be clamped to H2 $this->assertSame('H1', $obj->exposePdfuaClampHeadingRole('H1')); $this->assertSame('H2', $obj->exposePdfuaClampHeadingRole('H3')); $this->assertSame(2, $this->getObjectProperty($obj, 'pdfuaHeadingLevel')); } /** * @throws \Throwable */ public function testPdfuaClampHeadingRoleSequentialLevelsUnclamped(): void { $obj = $this->getInternalTestObject(); $this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1'); // H1 → H2 → H3 is valid; no clamping should occur $this->assertSame('H1', $obj->exposePdfuaClampHeadingRole('H1')); $this->assertSame('H2', $obj->exposePdfuaClampHeadingRole('H2')); $this->assertSame('H3', $obj->exposePdfuaClampHeadingRole('H3')); $this->assertSame(3, $this->getObjectProperty($obj, 'pdfuaHeadingLevel')); } /** * @throws \Throwable */ public function testPdfuaClampHeadingRoleAllowsGoingBackUp(): void { $obj = $this->getInternalTestObject(); $this->setObjectProperty($obj, 'pdfuaMode', 'pdfua1'); // H1 → H2 → H3 → H1 is allowed; then H3 should be clamped to H2 $obj->exposePdfuaClampHeadingRole('H1'); $obj->exposePdfuaClampHeadingRole('H2'); $obj->exposePdfuaClampHeadingRole('H3'); $this->assertSame('H1', $obj->exposePdfuaClampHeadingRole('H1')); $this->assertSame(1, $this->getObjectProperty($obj, 'pdfuaHeadingLevel')); $this->assertSame('H2', $obj->exposePdfuaClampHeadingRole('H3')); $this->assertSame(2, $this->getObjectProperty($obj, 'pdfuaHeadingLevel')); } /** * @throws \Throwable */ public function testBrAtLineStartAfterWrappedPlainTextAdvancesOnce(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(20.0, 120.0, 150.0, 0.0); $dom = [ 0 => $this->makeHtmlNode([ 'tag' => false, 'value' => 'wrapped line', ]), 1 => $this->makeHtmlNode([ 'tag' => true, 'opening' => true, 'self' => true, 'value' => 'br', ]), ]; $tpx = 20.0; $tpy = 140.0; $tpw = 150.0; $tph = 0.0; $obj->exposeParseHTMLTagOPENbrWithDom($dom, 1, $tpx, $tpy, $tpw, $tph); $this->assertGreaterThan(140.0, $tpy); $this->assertSame(20.0, $tpx); } /** * @throws \Throwable */ public function testBrAtLineStartStillAdvancesAfterAnotherBrTag(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(20.0, 120.0, 150.0, 0.0); $dom = [ 0 => $this->makeHtmlNode([ 'tag' => true, 'opening' => true, 'self' => true, 'value' => 'br', ]), 1 => $this->makeHtmlNode([ 'tag' => true, 'opening' => true, 'self' => true, 'value' => 'br', ]), ]; $tpx = 20.0; $tpy = 140.0; $tpw = 150.0; $tph = 0.0; $obj->exposeParseHTMLTagOPENbrWithDom($dom, 1, $tpx, $tpy, $tpw, $tph); $this->assertGreaterThan(140.0, $tpy); $this->assertSame(20.0, $tpx); } /** * @throws \Throwable */ public function testSanitizeHTMLRemovesHeadAndStyleBlocks(): void { $obj = $this->getInternalTestObject(); $html = '

    A

    '; $out = $obj->exposeSanitizeHTML($html); $this->assertStringNotContainsString('assertStringNotContainsString('assertStringContainsString('

    ', $out); } /** * @throws \Throwable */ public function testSanitizeHTMLNormalizesSelectTextareaAndImgBlocks(): void { $obj = $this->getInternalTestObject(); $html = '' . ' tail'; $out = $obj->exposeSanitizeHTML($html); $this->assertStringContainsString(''; $out = $obj->exposeSanitizeHTML($html); $this->assertStringContainsString('assertStringContainsString('line1', $out); $this->assertStringContainsString('line2', $out); } /** * @throws \Throwable */ public function testSanitizeHTMLHandlesImagesWithoutSrc(): void { $obj = $this->getInternalTestObject(); $html = 'test

    after

    '; $out = $obj->exposeSanitizeHTML($html); $this->assertStringContainsString('assertStringContainsString('

    ', $out); } /** * @throws \Throwable */ public function testSanitizeHTMLHandlesEmptySelectAndOption(): void { $obj = $this->getInternalTestObject(); $html = ''; $out = $obj->exposeSanitizeHTML($html); $this->assertStringContainsString('getInternalTestObject(); $html = ''; $out = $obj->exposeSanitizeHTML($html); $this->assertStringContainsString('assertStringContainsString('opt="x#!TaB!#Group A - X"', $out); } /** * @throws \Throwable */ public function testSanitizeHTMLAcceptsSingleQuotedAndUnquotedSelectOptionAttributes(): void { $obj = $this->getInternalTestObject(); $html = ""; $out = $obj->exposeSanitizeHTML($html); $this->assertStringContainsString('assertStringContainsString('#!SeL!#v1#!TaB!#Group A - Alpha', $out); $this->assertStringContainsString('v2#!TaB!#Group A - Beta', $out); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesHandlesLineHeightNormalValue(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['line-height' => 1.0, 'fontsize' => 10.0]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'line-height' => 1.0, 'attribute' => [ 'style' => 'line-height:normal;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(1.0, $dom[1]['line-height']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesBorderShorthandParsing(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'attribute' => [ 'style' => 'border:1px solid black;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotEmpty($dom[1]['border']); } /** * @throws \Throwable */ public function testIsValidCSSSelectorForTagCoversTightCombinatorsAndAttributePresence(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['value' => 'root', 'tag' => true, 'opening' => true]), 1 => $this->makeHtmlNode([ 'value' => 'div', 'parent' => 0, 'tag' => true, 'opening' => true, ]), 2 => $this->makeHtmlNode([ 'value' => 'p', 'parent' => 1, 'tag' => true, 'opening' => true, ]), 3 => $this->makeHtmlNode([ 'value' => 'span', 'parent' => 1, 'tag' => true, 'opening' => true, 'attribute' => [ 'class' => 'target', 'data' => 'tokenized value', ], ]), ]; $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span[data]')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span[data~=tokenized]')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' div>span.target')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' p+span.target')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' p~span.target')); } /** * @throws \Throwable */ public function testIsValidCSSSelectorForTagCoversNestedChainsAndSiblingEdges(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['value' => 'root', 'tag' => true, 'opening' => true]), 1 => $this->makeHtmlNode([ 'value' => 'div', 'parent' => 0, 'tag' => true, 'opening' => true, 'attribute' => ['id' => 'outer'], ]), 2 => $this->makeHtmlNode([ 'value' => 'section', 'parent' => 1, 'tag' => true, 'opening' => true, ]), 3 => $this->makeHtmlNode([ 'value' => 'p', 'parent' => 2, 'tag' => true, 'opening' => true, 'attribute' => ['class' => 'lead'], ]), 4 => $this->makeHtmlNode([ 'value' => 'span', 'parent' => 2, 'tag' => true, 'opening' => true, 'attribute' => ['class' => 'middle'], ]), 5 => $this->makeHtmlNode([ 'value' => 'a', 'parent' => 2, 'tag' => true, 'opening' => true, 'attribute' => [ 'class' => 'target', 'href' => 'https://example.com', ], ]), ]; $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 5, ' div section a.target')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 5, ' div>section>a.target')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 5, ' div > section > a.target')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 5, ' section > .target')); $this->assertFalse($obj->isValidCSSSelectorForTag($dom, 5, ' div > p + a.target')); $this->assertFalse($obj->isValidCSSSelectorForTag($dom, 5, ' div > p ~ a.target')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 5, ' section > p ~ a.target')); $this->assertFalse($obj->isValidCSSSelectorForTag($dom, 5, ' div > article a.target')); } /** * @throws \Throwable */ public function testIsValidCSSSelectorForTagCoversMixedChainsAndNthFormulas(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['value' => 'root', 'tag' => true, 'opening' => true]), 1 => $this->makeHtmlNode([ 'value' => 'article', 'parent' => 0, 'tag' => true, 'opening' => true, 'attribute' => ['id' => 'doc'], ]), 2 => $this->makeHtmlNode([ 'value' => 'section', 'parent' => 1, 'tag' => true, 'opening' => true, 'attribute' => ['class' => 'panel', 'data-kind' => 'alpha'], ]), 3 => $this->makeHtmlNode([ 'value' => 'p', 'parent' => 2, 'tag' => true, 'opening' => true, 'attribute' => ['class' => 'note'], ]), 4 => $this->makeHtmlNode([ 'value' => 'a', 'parent' => 2, 'tag' => true, 'opening' => true, 'attribute' => ['class' => 'cta primary', 'href' => 'https://example.com/main'], ]), 5 => $this->makeHtmlNode([ 'value' => 'a', 'parent' => 2, 'tag' => true, 'opening' => true, 'attribute' => ['class' => 'cta secondary', 'href' => 'https://example.com/alt'], ]), 6 => $this->makeHtmlNode([ 'value' => 'span', 'parent' => 2, 'tag' => true, 'opening' => true, 'attribute' => ['class' => 'badge'], ]), ]; $this->assertTrue($obj->isValidCSSSelectorForTag( $dom, 5, ' article#doc > section.panel[data-kind=alpha] > a.secondary:nth-child(3)', )); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 5, ' section > a.cta + a.secondary')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 5, ' section > p.note ~ a.secondary:nth-child(2n+1)')); $this->assertFalse($obj->isValidCSSSelectorForTag($dom, 5, ' section > p.note + a.secondary')); $this->assertFalse($obj->isValidCSSSelectorForTag($dom, 5, ' section[data-kind=beta] > a.secondary')); $this->assertFalse($obj->isValidCSSSelectorForTag($dom, 5, ' section > a.secondary:nth-child(2n)')); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesCoversLinkFallbacksAndPerSideBorderProperties(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode([ 'line-height' => 1.2, 'fontsize' => 10.0, 'fontstyle' => '', 'font-stretch' => 100.0, 'letter-spacing' => 0.0, 'text-indent' => 0.0, 'listtype' => 'disc', ]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'value' => 'a', 'fontsize' => 0.0, 'fontstyle' => '', 'attribute' => [ 'style' => 'line-height:12pt;page-break-before:avoid;page-break-after:left;' . 'border-style:none none none hidden;' . 'border-left-color:#111;border-right-color:#222;' . 'border-top-color:#333;border-bottom-color:#444;' . 'border-left-width:1;border-right-width:2;border-top-width:3;border-bottom-width:4;' . 'border-left-style:dashed;border-right-style:dotted;' . 'border-top-style:solid;border-bottom-style:double;', ], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'value' => 'span', 'fontsize' => 10.0, 'fontstyle' => '', 'attribute' => [ 'style' => 'line-height:12pt;text-decoration:blink;page-break-after:avoid;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(1.0, $dom[1]['line-height']); $this->assertSame('blue', $dom[1]['fgcolor']); $this->assertStringContainsString('U', $dom[1]['fontstyle']); $this->assertSame('', $this->getHtmlNodeAttrString($dom[1], 'pagebreak')); $this->assertSame('left', $this->getHtmlNodeAttrString($dom[1], 'pagebreakafter')); $this->assertNotEmpty($dom[1]['border']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertGreaterThan(0.0, $dom[2]['line-height']); $this->assertSame('', $this->getHtmlNodeAttrString($dom[2], 'pagebreakafter')); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandlesFontTagWithSizePrefix(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'font', 'parent' => 0, 'attribute' => ['size' => '-1'], 'style' => [], 'fontstyle' => '', 'fontsize' => 10.0, ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertGreaterThan(0.0, $dom[1]['fontsize']); $this->assertLessThan(10.0, $dom[1]['fontsize']); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandlesFontTagWithPlusPrefix(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'font', 'parent' => 0, 'attribute' => ['size' => '+2'], 'style' => [], 'fontstyle' => '', 'fontsize' => 10.0, ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertGreaterThan(10.0, $dom[1]['fontsize']); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandlesHeading2Tag(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'h2', 'parent' => 0, 'attribute' => [], 'style' => [], 'fontstyle' => '', 'fontsize' => 10.0, ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertGreaterThan(10.0, $dom[1]['fontsize']); $this->assertStringContainsString('B', $dom[1]['fontstyle']); } /** * @throws \Throwable */ #[DataProvider('htmlLiBulletNamedTypeProvider')] public function testGetHTMLliBulletSupportsNamedTypes(string $type, ?string $expectedFragment): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $result = $obj->exposeGetHTMLliBullet(1, 5, 0, 0, $type); $this->assertNotSame('', $result); if ($expectedFragment !== null) { $this->assertStringContainsString($expectedFragment, $result); } } /** * @throws \Throwable */ public function testProcessHTMLDOMTextAppliesCapitalizeTransform(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['text-transform' => 'capitalize']), 1 => $this->makeHtmlNode(['value' => '']), ]; $obj->exposeProcessHTMLDOMText($dom, 'hello world', 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotSame('hello world', $dom[1]['value']); } /** * @throws \Throwable */ public function testProcessHTMLDOMClosingTagHandlesNonTableElements(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $root = $obj->exposeGetHTMLRootProperties(); $dom = [ 0 => $root, 1 => $root, ]; $dom[1]['value'] = 'div'; $dom[1]['parent'] = 0; $elm = ['

    ', '
    ']; $obj->exposeProcessHTMLDOMClosingTag($dom, $elm, 1, 0, ''); assert(isset($dom[0]), "\$dom[0] must be set"); $this->assertArrayHasKey('content', $dom[0]); } /** * @throws \Throwable */ public function testProcessHTMLDOMOpeningTagDetectsSelfClosingImg(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $root = $obj->exposeGetHTMLRootProperties(); $dom = [ 0 => $root, 1 => $root, ]; $dom[1]['parent'] = 0; $dom[1]['value'] = 'img'; $obj->exposeProcessHTMLDOMOpeningTag($dom, [], [0], 'img', 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertTrue($dom[1]['self']); } /** * @throws \Throwable */ public function testProcessHTMLDOMOpeningTagDetectsSelfClosingBr(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $root = $obj->exposeGetHTMLRootProperties(); $dom = [ 0 => $root, 1 => $root, ]; $dom[1]['parent'] = 0; $dom[1]['value'] = 'br'; $obj->exposeProcessHTMLDOMOpeningTag($dom, [], [0], 'br', 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertTrue($dom[1]['self']); } /** * @throws \Throwable */ public function testPageBreakMovesToNextPageRegion(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $pid = $obj->exposePageBreak(); $this->assertGreaterThan(0, $pid); } /** * @throws \Throwable */ public function testInheritHTMLPropertiesPreservesChildOverrides(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['align' => 'L', 'fontname' => 'helvetica', 'fontsize' => 10.0]), 1 => $this->makeHtmlNode(['align' => 'R', 'fontsize' => 0.0]), ]; $obj->exposeInheritHTMLProperties($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame('R', $dom[1]['align']); $this->assertSame('helvetica', $dom[1]['fontname']); $this->assertSame(0.0, $dom[1]['fontsize']); } /** * @throws \Throwable */ public function testGetHTMLDOMCSSDataHandlesMultiplePriorities(): void { $obj = $this->getTestObject(); $dom = [ 0 => $this->makeHtmlNode(['value' => 'root', 'csssel' => ['0010 p', '0020 p.x', '0005 p']]), 1 => $this->makeHtmlNode([ 'value' => 'p', 'parent' => 0, 'attribute' => ['class' => 'x'], ]), ]; $css = [ '0010 p' => 'color:red;', '0020 p.x' => 'color:blue;', '0005 p' => 'color:green;', ]; $obj->getHTMLDOMCSSData($dom, $css, 1); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotEmpty($dom[1]['cssdata']); $this->assertGreaterThanOrEqual(2, \count($dom[1]['cssdata'])); } /** * @throws \Throwable */ public function testIsValidCSSSelectorForTagCoversMultipleCases(): void { $obj = $this->getInternalTestObject(); $dom = [ 0 => $this->makeHtmlNode(['value' => 'div', 'tag' => true, 'opening' => true]), 1 => $this->makeHtmlNode([ 'value' => 'p', 'parent' => 0, 'tag' => true, 'opening' => true, 'attribute' => ['id' => 'main'], ]), ]; $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' p')); $this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' p#main')); $this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' span')); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandlesTableRowsAndCols(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0]), 1 => $this->makeHtmlNode([ 'value' => 'table', 'parent' => 0, 'attribute' => [], 'style' => [], ]), 2 => $this->makeHtmlNode([ 'value' => 'tr', 'parent' => 1, 'attribute' => [], 'style' => [], ]), 3 => $this->makeHtmlNode([ 'value' => 'td', 'parent' => 2, 'attribute' => ['colspan' => '2', 'rowspan' => '2'], 'style' => [], ]), ]; $obj->parseHTMLAttributes($dom, 1, false); $obj->parseHTMLAttributes($dom, 2, false); $obj->parseHTMLAttributes($dom, 3, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertGreaterThan(0, $dom[1]['rows']); assert(isset($dom[3]), "\$dom[3] must be set"); $this->assertSame('2', $this->getHtmlNodeAttrString($dom[3], 'rowspan')); } /** * @throws \Throwable */ public function testGetHTMLDOMSupportsAdditionalTableStructureTags(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = $obj->exposeGetHTMLDOM('' . '
    Cap
    Foot
    '); $values = \array_column($dom, 'value'); $this->assertContains('caption', $values); $this->assertContains('colgroup', $values); $this->assertContains('col', $values); $this->assertContains('tfoot', $values); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(1, $dom[1]['rows']); $this->assertSame(1, $dom[1]['cols']); $this->assertCount(1, $dom[1]['trids']); } /** * @throws \Throwable */ public function testParseHTMLAttributesCountsRowsWhenTrParentIsTfoot(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['value' => 'root']), 1 => $this->makeHtmlNode([ 'value' => 'table', 'parent' => 0, 'attribute' => [], 'style' => [], ]), 2 => $this->makeHtmlNode([ 'value' => 'tfoot', 'parent' => 1, 'attribute' => [], 'style' => [], ]), 3 => $this->makeHtmlNode([ 'value' => 'tr', 'parent' => 2, 'attribute' => [], 'style' => [], ]), ]; $obj->parseHTMLAttributes($dom, 1, false); $obj->parseHTMLAttributes($dom, 2, false); $obj->parseHTMLAttributes($dom, 3, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(1, $dom[1]['rows']); $this->assertSame([3], $dom[1]['trids']); } /** * @throws \Throwable */ public function testComputeHTMLTableColWidthsUsesColgroupSpanWidthHints(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = $obj->exposeGetHTMLDOM('' . '
    AB
    '); assert(isset($dom[2]), "\$dom[2] must be set"); $group = $dom[2]; $groupWidth = $group['width']; $widths = $obj->exposeComputeHTMLTableColWidths($dom, 1, 2, 100.0); $this->assertCount(2, $widths); $this->assertGreaterThan(0.0, $groupWidth); assert(isset($widths[0]), "\$widths[0] must be set"); $this->assertEqualsWithDelta($groupWidth / 2.0, $widths[0], 0.001); assert(isset($widths[1]), "\$widths[1] must be set"); $this->assertEqualsWithDelta($groupWidth / 2.0, $widths[1], 0.001); } /** * @throws \Throwable */ public function testComputeHTMLTableColWidthsUsesColSpanWidthHints(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = $obj->exposeGetHTMLDOM( '' . '
    ABC
    ', ); $colWidths = []; foreach ($dom as $elm) { if (!(!empty($elm['opening']) && $elm['value'] === 'col')) { continue; } $colWidths[] = $elm['width']; } $widths = $obj->exposeComputeHTMLTableColWidths($dom, 1, 3, 120.0); $this->assertCount(2, $colWidths); $this->assertCount(3, $widths); assert(isset($colWidths[0]), "\$colWidths[0] must be set"); assert(isset($widths[0]), "\$widths[0] must be set"); $this->assertEqualsWithDelta($colWidths[0] / 2.0, $widths[0], 0.001); assert(isset($widths[1]), "\$widths[1] must be set"); $this->assertEqualsWithDelta($colWidths[0] / 2.0, $widths[1], 0.001); assert(isset($colWidths[1]), "\$colWidths[1] must be set"); assert(isset($widths[2]), "\$widths[2] must be set"); $this->assertEqualsWithDelta($colWidths[1], $widths[2], 0.001); } /** * @throws \Throwable */ public function testComputeHTMLTableColWidthsPrefersFirstRowExplicitTdWidthOverColHints(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = $obj->exposeGetHTMLDOM('' . '
    AB
    '); $colWidths = []; $tdWidths = []; foreach ($dom as $elm) { if (!empty($elm['opening']) && $elm['value'] === 'col') { $colWidths[] = $elm['width']; } if (!empty($elm['opening']) && $elm['value'] === 'td') { $tdWidths[] = $elm['width']; } } $widths = $obj->exposeComputeHTMLTableColWidths($dom, 1, 2, 100.0); $this->assertCount(2, $colWidths); $this->assertCount(2, $tdWidths); $this->assertCount(2, $widths); assert(isset($tdWidths[0]), "\$tdWidths[0] must be set"); assert(isset($widths[0]), "\$widths[0] must be set"); $this->assertEqualsWithDelta($tdWidths[0], $widths[0], 0.001); assert(isset($colWidths[1]), "\$colWidths[1] must be set"); assert(isset($widths[1]), "\$widths[1] must be set"); $this->assertEqualsWithDelta($colWidths[1], $widths[1], 0.001); } /** * @throws \Throwable */ public function testParseHTMLAttributesFontSizeUsesNumericFallbackWhenParentSizeMissing(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 0.0]), 1 => $this->makeHtmlNode([ 'value' => 'font', 'parent' => 0, 'attribute' => ['size' => '13'], 'style' => [], 'fontsize' => 0.0, ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertSame(13.0, $dom[1]['fontsize']); } /** * @throws \Throwable */ public function testParseHTMLAttributesInitializesRowsAndTridsOnMissingParentTableState(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['value' => 'root']), 1 => $this->makeHtmlNode([ 'value' => 'tr', 'parent' => 0, 'attribute' => [], 'style' => [], ]), ]; unset($dom[0]['rows'], $dom[0]['trids']); $method = new \ReflectionMethod(\Com\Tecnick\Pdf\HTML::class, 'parseHTMLAttributes'); $method->invokeArgs($obj, [&$dom, 1, false]); /** @var THTMLAttrib $parent */ assert(isset($dom[0]), "\$dom[0] must be set"); $this->assertIsInt($dom[0]['rows'] ?? null); $this->assertIsArray($dom[0]['trids'] ?? null); $this->assertSame(1, $dom[0]['rows']); $this->assertSame([1], $dom[0]['trids']); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesHandlesMultipleBorderSides(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'attribute' => [ 'style' => 'border-left:1 solid red;border-right:2 dashed blue;' . 'border-top:3 dotted green;border-bottom:4 double black;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotEmpty($dom[1]['border']); } /** * @throws \Throwable */ public function testDrawHTMLRectBorderSidesRendersOnlyDefinedSides(): void { $obj = $this->getInternalTestObject(); $method = new \ReflectionMethod(\Com\Tecnick\Pdf\HTML::class, 'drawHTMLRectBorderSides'); $styles = [ 3 => [ 'lineWidth' => 0.2, 'lineCap' => 'square', 'lineJoin' => 'miter', 'miterLimit' => 10.0, 'dashArray' => [], 'dashPhase' => 0, 'lineColor' => '#ff0000', 'fillColor' => '', ], ]; /** @var string $out */ $out = $method->invoke($obj, 10.0, 20.0, 30.0, 40.0, $styles); $this->assertNotSame('', $out); $this->assertSame(1, \substr_count($out, "S\n")); } /** * @throws \Throwable */ public function testParseHTMLStyleAttributesHandlesPaddingAndMarginValues(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0]), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontsize' => 10.0, 'attribute' => [ 'style' => 'padding:5px 10px;margin:1px 2px 3px 4px;', ], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotSame(['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], $dom[1]['padding']); $this->assertNotSame(['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0], $dom[1]['margin']); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandlesStrongAndEmphasisTags(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'strong', 'parent' => 0, 'attribute' => [], 'style' => [], 'fontstyle' => '', ]), 2 => $this->makeHtmlNode([ 'value' => 'em', 'parent' => 0, 'attribute' => [], 'style' => [], 'fontstyle' => '', ]), ]; $obj->parseHTMLAttributes($dom, 1, false); $obj->parseHTMLAttributes($dom, 2, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertStringContainsString('B', $dom[1]['fontstyle']); assert(isset($dom[2]), "\$dom[2] must be set"); $this->assertStringContainsString('I', $dom[2]['fontstyle']); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandlesUnderlineTag(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'u', 'parent' => 0, 'attribute' => [], 'style' => [], 'fontstyle' => '', ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertStringContainsString('U', $dom[1]['fontstyle']); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandlesDeleteTag(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontstyle' => '']), 1 => $this->makeHtmlNode([ 'value' => 'del', 'parent' => 0, 'attribute' => [], 'style' => [], 'fontstyle' => '', ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertStringContainsString('D', $dom[1]['fontstyle']); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandlesPreTag(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontname' => 'helvetica']), 1 => $this->makeHtmlNode([ 'value' => 'pre', 'parent' => 0, 'attribute' => [], 'style' => [], 'fontname' => 'helvetica', ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotSame('helvetica', $dom[1]['fontname']); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandleTtTag(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $dom = [ 0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontname' => 'helvetica']), 1 => $this->makeHtmlNode([ 'value' => 'tt', 'parent' => 0, 'attribute' => [], 'style' => [], 'fontname' => 'helvetica', ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotSame('helvetica', $dom[1]['fontname']); } /** * @throws \Throwable */ public function testSanitizeHTMLPreservesHeadingTags(): void { $obj = $this->getInternalTestObject(); $html = '

    Title

    Subtitle

    '; $out = $obj->exposeSanitizeHTML($html); $this->assertStringContainsString('

    ', $out); $this->assertStringContainsString('

    ', $out); $this->assertStringContainsString('Title', $out); } /** * @throws \Throwable */ public function testSanitizeHTMLHandlesDivWrappers(): void { $obj = $this->getInternalTestObject(); $html = '

    Content

    '; $out = $obj->exposeSanitizeHTML($html); $this->assertStringContainsString('assertStringContainsString('

    ', $out); } /** * @throws \Throwable */ public function testParseHTMLAttributesHandlesListTypeInheritance(): void { $obj = $this->getTestObject(); $dom = [ 0 => $this->makeHtmlNode(['align' => 'L']), 1 => $this->makeHtmlNode([ 'value' => 'ul', 'parent' => 0, 'attribute' => [], 'style' => [], 'align' => '', ]), ]; $obj->parseHTMLAttributes($dom, 1, false); assert(isset($dom[1]), "\$dom[1] must be set"); $this->assertNotSame('', $dom[1]['align']); } /** * @throws \Throwable */ public function testGetHTMLliBulletHandlesDepthCycling(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $result1 = $obj->exposeGetHTMLliBullet(1, 1, 0, 0, '!'); $result2 = $obj->exposeGetHTMLliBullet(2, 1, 0, 0, '!'); $result3 = $obj->exposeGetHTMLliBullet(4, 1, 0, 0, '!'); $this->assertNotSame('', $result1); $this->assertNotSame('', $result2); $this->assertNotSame('', $result3); } /** * @throws \Throwable */ #[DataProvider('htmlLiBulletShapeProvider')] public function testGetHTMLliBulletShapeVariants( string $type, bool $isunicode, bool $rtl, float $posx, float $posy, ): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $this->setObjectProperty($obj, 'isunicode', $isunicode); $this->setObjectProperty($obj, 'rtl', $rtl); $result = $obj->exposeGetHTMLliBullet(1, 1, $posx, $posy, $type); $this->assertNotSame('', $result); $this->assertGreaterThan(0, \strlen($result)); } /** * @throws \Throwable */ public function testGetHTMLliBulletUsesGraphicFallbackForUnicodeByteFonts(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); // Loads core Helvetica (byte font) $this->setObjectProperty($obj, 'isunicode', true); $disc = $obj->exposeGetHTMLliBullet(1, 1, 0, 0, 'disc'); $circle = $obj->exposeGetHTMLliBullet(1, 1, 0, 0, 'circle'); $square = $obj->exposeGetHTMLliBullet(1, 1, 0, 0, 'square'); $this->assertNotSame('', $disc); $this->assertNotSame('', $circle); $this->assertNotSame('', $square); $this->assertStringNotContainsString('Tj', $disc); $this->assertStringNotContainsString('Tj', $circle); $this->assertStringNotContainsString('Tj', $square); } /** * @throws \Throwable */ #[DataProvider('htmlLiBulletNumericFormatProvider')] public function testGetHTMLliBulletNumericFormats(string $type, int $count, string $expectedFragment): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $result = $obj->exposeGetHTMLliBullet(1, $count, 0, 0, $type); $this->assertNotSame('', $result); $this->assertStringContainsString($expectedFragment, $result); } /** * @throws \Throwable */ #[DataProvider('htmlLiBulletTextDirectionProvider')] public function testGetHTMLliBulletTextFormattingByDirection(bool $rtl, string $expectedFragment): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $this->setObjectProperty($obj, 'rtl', $rtl); $result = $obj->exposeGetHTMLliBullet(1, 10, 0, 0, 'decimal'); $this->assertNotSame('', $result); $this->assertStringContainsString($expectedFragment, $result); } /** * @throws \Throwable */ #[DataProvider('htmlLiBulletScriptTypeProvider')] public function testGetHTMLliBulletUnicodeAndScriptTypes(string $type): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); if ($type === 'cjk-ideographic') { /** @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/cid0/cid0jp.json'); if ($fontfile === '') { $this->markTestSkipped('CID0JP font definition is not available.'); } $font->insert($pon, 'cid0jp', '', 10, null, null, $fontfile); } $count = $type === 'cjk-ideographic' ? 1 : 3; $result = $obj->exposeGetHTMLliBullet(1, $count, 0, 0, $type); $this->assertNotSame('', $result); } /** * @throws \Throwable */ public function testGetHTMLliBulletEmptyTypeStringFallsBackToDefault(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $result = $obj->exposeGetHTMLliBullet(1, 5, 0, 0, ''); $this->assertNotSame('', $result); $this->assertStringContainsString('5', $result); } /** * @throws \Throwable */ #[DataProvider('htmlLiBulletCountProvider')] public function testGetHTMLliBulletCountFormatting(int $count, string $expectedFragment): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $result = $obj->exposeGetHTMLliBullet(1, $count, 0, 0, 'decimal'); $this->assertNotSame('', $result); $this->assertStringContainsString($expectedFragment, $result); } /** * @throws \Throwable */ #[DataProvider('htmlLiBulletAlphaBoundaryProvider')] public function testGetHTMLliBulletAlphaBoundaryCase( string $type, string $expectedLast, string $expectedFirst, ): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $resultZ = $obj->exposeGetHTMLliBullet(1, 26, 0, 0, $type); $resultA = $obj->exposeGetHTMLliBullet(1, 1, 0, 0, $type); $this->assertNotSame('', $resultZ); $this->assertNotSame('', $resultA); $this->assertStringContainsString($expectedLast, $resultZ); $this->assertStringContainsString($expectedFirst, $resultA); } /** * @throws \Throwable */ public function testGetHTMLliBulletWithNonZeroPositions(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $result = $obj->exposeGetHTMLliBullet(1, 5, 100, 200, 'decimal'); $this->assertNotSame('', $result); $this->assertStringContainsString('5', $result); } /** * @throws \Throwable */ public function testGetHTMLliBulletDepthModuloCalculation(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $this->setObjectProperty($obj, 'isunicode', true); $depthOne = $obj->exposeGetHTMLliBullet(1, 1, 0, 0, '!'); $depthFour = $obj->exposeGetHTMLliBullet(4, 1, 0, 0, '!'); $depthSeven = $obj->exposeGetHTMLliBullet(7, 1, 0, 0, '!'); $this->assertNotSame('', $depthOne); $this->assertNotSame('', $depthFour); $this->assertNotSame('', $depthSeven); } /** @return array */ public static function htmlLiBulletScriptTypeProvider(): array { return [ 'lower-greek' => ['lower-greek'], 'hebrew' => ['hebrew'], 'armenian' => ['armenian'], 'georgian' => ['georgian'], 'cjk-ideographic' => ['cjk-ideographic'], 'hiragana' => ['hiragana'], 'hiragana-iroha' => ['hiragana-iroha'], 'katakana' => ['katakana'], 'katakana-iroha' => ['katakana-iroha'], ]; } /** @return array */ public static function htmlLiBulletCountProvider(): array { return [ 'count-one' => [1, '1.'], 'count-large' => [999, '999'], ]; } /** @return array */ public static function htmlLiBulletNumericFormatProvider(): array { return [ 'decimal' => ['decimal', 42, '42.'], 'short-decimal' => ['1', 15, '15.'], 'leading-zero' => ['decimal-leading-zero', 5, '05'], ]; } /** @return array */ public static function htmlLiBulletTextDirectionProvider(): array { return [ 'rtl' => [true, '.10'], 'ltr' => [false, '10.'], ]; } /** @return array */ public static function htmlLiBulletAlphaBoundaryProvider(): array { return [ 'lower-alpha' => ['lower-alpha', 'z', 'a'], 'upper-alpha' => ['upper-alpha', 'Z', 'A'], ]; } /** * @throws \Throwable */ public function testGetHTMLliBulletImageTypeParsing(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $this->expectException(\Throwable::class); $obj->exposeGetHTMLliBullet(1, 1, 0, 0, 'img|png|10|10|/nonexistent/file.png'); } /** * @throws \Throwable */ #[DataProvider('htmlLiBulletShortAlphaProvider')] public function testGetHTMLliBulletShortAlphaForms(string $type, string $expectedFragment): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $result = $obj->exposeGetHTMLliBullet(1, 5, 0, 0, $type); $this->assertNotSame('', $result); $this->assertStringContainsString($expectedFragment, $result); } // --- Fix tests:
    line advance --- /** * @throws \Throwable */ public function testGetHTMLCellBrAdvancesLine(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); // Two words without a break — both on the same line $outSameLine = $obj->getHTMLCell('Hello World', 0, 0, 40, 20); // Same words with a
    — second word is on a new (lower) line $obj2 = $this->getTestObject(); $this->initFontAndPage($obj2); $outNewLine = $obj2->getHTMLCell('Hello
    World', 0, 0, 40, 20); $this->assertNotSame('', $outSameLine); $this->assertNotSame('', $outNewLine); $this->assertStringContainsString('Hello', $outSameLine); $this->assertStringContainsString('Hello', $outNewLine); $this->assertStringContainsString('World', $outNewLine); // The y coordinates differ —
    produced a line advance $this->assertNotSame($outSameLine, $outNewLine); } /** * @throws \Throwable */ public function testParseHTMLTagOPENbrSkipsAdvanceAfterWrappedLine(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 0.0, 100.0, 0.0); $obj->exposeSetHTMLLineState(0.0, 0.0, true); $dom = [ $this->makeHtmlNode([ 'tag' => true, 'opening' => false, 'value' => 'font', ]), $this->makeHtmlNode([ 'tag' => true, 'opening' => true, 'self' => true, 'value' => 'br', ]), ]; $tpx = 10.0; $tpy = 20.0; $tpw = 100.0; $tph = 0.0; $obj->exposeParseHTMLTagOPENbrWithDom($dom, 1, $tpx, $tpy, $tpw, $tph); $this->assertEqualsWithDelta(20.0, $tpy, 1e-9); $this->assertEqualsWithDelta(10.0, $tpx, 1e-9); } /** * @throws \Throwable */ public function testParseHTMLTagOPENbrAdvancesWhenLineIsNotWrapped(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 0.0, 100.0, 0.0); $obj->exposeSetHTMLLineState(0.0, 0.0, false); $dom = [ $this->makeHtmlNode([ 'tag' => false, 'value' => 'normal', ]), $this->makeHtmlNode([ 'tag' => true, 'opening' => true, 'self' => true, 'value' => 'br', ]), ]; $tpx = 10.0; $tpy = 20.0; $tpw = 100.0; $tph = 0.0; $obj->exposeParseHTMLTagOPENbrWithDom($dom, 1, $tpx, $tpy, $tpw, $tph); $this->assertGreaterThan(20.0, $tpy); $this->assertEqualsWithDelta(10.0, $tpx, 1e-9); } /** * @throws \Throwable */ public function testParseHTMLTextOverflowAdvancesByCurrentLineMaxHeight(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 0.0, 40.0, 0.0); // Simulate an in-progress line that already contains a taller inline fragment. $obj->exposeSetHTMLLineState(12.0, 0.0, false); $elm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'self' => false, 'value' => 'thisisaverylongwordthisisaverylongwordthisisaverylongword', 'fontsize' => 6.0, ]); $tpx = 38.0; // near line end => tiny remaining width $tpy = 20.0; $tpw = 12.0; $tph = 0.0; $out = $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $this->assertNotSame('', $out); // The parser must advance to next line using the tracked max line height (12.0) // before rendering the overflow fragment. $this->assertGreaterThanOrEqual(32.0, $tpy); } /** * @throws \Throwable */ public function testGetHTMLCellMixedInlineSizesShareBaseline(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $html = 'ABC'; $out = $obj->getHTMLCell($html, 0, 0, 200, 20); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertCount(3, $trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertSame('A', $trace[0]['txt']); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertSame('B', $trace[1]['txt']); assert(isset($trace[2]), "\$trace[2] must be set"); $this->assertSame('C', $trace[2]['txt']); // Small fragments on the same line must align to the same baseline offset. $this->assertEqualsWithDelta($trace[0]['bbox_y'], $trace[2]['bbox_y'], 1e-9); // The larger fragment sits higher while sharing the same baseline. $this->assertGreaterThan($trace[1]['bbox_y'], $trace[0]['bbox_y']); } // --- Fix tests:


    width/height --- /** * @throws \Throwable */ public function testGetHTMLCellHrRespectsWidthAttribute(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); // Full-width HR (no attribute) $outFull = $obj->getHTMLCell('
    ', 0, 0, 40, 5); // HR with width="50" (50px ≈ 13mm — narrower than the 40mm cell) $obj2 = $this->getTestObject(); $this->initFontAndPage($obj2); $outShort = $obj2->getHTMLCell('
    ', 0, 0, 40, 5); $this->assertNotSame('', $outFull); $this->assertNotSame('', $outShort); // The line endpoints differ when width is constrained $this->assertNotSame($outFull, $outShort); } /** * @throws \Throwable */ public function testGetHTMLCellHrRespectsHeightAsStrokeWidth(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); // Default HR (stroke 0.2) $outDefault = $obj->getHTMLCell('
    ', 0, 0, 40, 5); // HR with height="5" (5px ≈ 1.32mm stroke) $obj2 = $this->getTestObject(); $this->initFontAndPage($obj2); $outThick = $obj2->getHTMLCell('
    ', 0, 0, 40, 5); $this->assertNotSame('', $outDefault); $this->assertNotSame('', $outThick); // Different stroke width produces different PDF operator stream $this->assertNotSame($outDefault, $outThick); } // --- Fix tests: inline image vertical alignment --- /** * @throws \Throwable */ public function testGetHTMLCellImgTopAlignmentDiffersFromBottom(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); // Generate a minimal valid PNG in memory $img = \imagecreate(4, 4); \imagecolorallocate($img, 255, 255, 255); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $b64src = 'data:image/png;base64,' . \base64_encode((string) $raw); $outBottom = $obj->getHTMLCell( '', 0, 0, 40, 20, ); $obj2 = $this->getTestObject(); $this->initFontAndPage($obj2); $outTop = $obj2->getHTMLCell('', 0, 0, 40, 20); $this->assertNotSame('', $outBottom); $this->assertNotSame('', $outTop); // Different y-offsets produce different PDF streams $this->assertNotSame($outBottom, $outTop); } /** * @throws \Throwable */ public function testGetHTMLCellImgWidthOnlyPreservesIntrinsicAspectRatio(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $img = \imagecreate(4, 2); \imagecolorallocate($img, 255, 255, 255); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $b64src = 'data:image/png;base64,' . \base64_encode((string) $raw); $out = $obj->getHTMLCell('', 0, 0, 80, 20); $imgMatch = []; $imgPattern = '/q\s+([-0-9.]+)\s+0\s+0\s+([-0-9.]+)\s+[-0-9.]+\s+[-0-9.]+\s+cm\s+\/IMG\d+\s+Do\s+Q/'; $this->assertSame(1, \preg_match($imgPattern, $out, $imgMatch)); assert(isset($imgMatch[1]), "\$imgMatch[1] must be set"); assert(isset($imgMatch[2]), "\$imgMatch[2] must be set"); $drawWidth = \floatval($imgMatch[1]); $drawHeight = \floatval($imgMatch[2]); $this->assertGreaterThan(0.0, $drawWidth); $this->assertEqualsWithDelta(0.5, $drawHeight / $drawWidth, 0.01); } /** * @throws \Throwable */ public function testGetHTMLCellImgHeightOnlyPreservesIntrinsicAspectRatio(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $img = \imagecreate(4, 2); \imagecolorallocate($img, 255, 255, 255); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $b64src = 'data:image/png;base64,' . \base64_encode((string) $raw); $out = $obj->getHTMLCell('', 0, 0, 80, 20); $imgMatch = []; $imgPattern = '/q\s+([-0-9.]+)\s+0\s+0\s+([-0-9.]+)\s+[-0-9.]+\s+[-0-9.]+\s+cm\s+\/IMG\d+\s+Do\s+Q/'; $this->assertSame(1, \preg_match($imgPattern, $out, $imgMatch)); assert(isset($imgMatch[1]), "\$imgMatch[1] must be set"); assert(isset($imgMatch[2]), "\$imgMatch[2] must be set"); $drawWidth = \floatval($imgMatch[1]); $drawHeight = \floatval($imgMatch[2]); $this->assertGreaterThan(0.0, $drawHeight); $this->assertEqualsWithDelta(0.5, $drawHeight / $drawWidth, 0.01); } /** * Issue #247: a percentage width must be resolved against the containing * block width, not the default unit reference (which collapsed it to ~1pt). * * @throws \Throwable */ public function testGetHTMLCellImgPercentWidthResolvesAgainstContainerWidth(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $img = \imagecreate(4, 2); \imagecolorallocate($img, 255, 255, 255); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $b64src = 'data:image/png;base64,' . \base64_encode((string) $raw); $out = $obj->getHTMLCell('', 0, 0, 80, 20); $imgMatch = []; $imgPattern = '/q\s+([-0-9.]+)\s+0\s+0\s+([-0-9.]+)\s+[-0-9.]+\s+[-0-9.]+\s+cm\s+\/IMG\d+\s+Do\s+Q/'; $this->assertSame(1, \preg_match($imgPattern, $out, $imgMatch)); assert(isset($imgMatch[1]), "\$imgMatch[1] must be set"); assert(isset($imgMatch[2]), "\$imgMatch[2] must be set"); $drawWidth = \floatval($imgMatch[1]); $drawHeight = \floatval($imgMatch[2]); // 50% of the 80-unit wide cell. $this->assertEqualsWithDelta($obj->toPoints(40.0), $drawWidth, 0.5); // The unspecified height follows the intrinsic 4:2 aspect ratio. $this->assertEqualsWithDelta(0.5, $drawHeight / $drawWidth, 0.01); } /** * Issue #247: an image with width:100% inside a fixed-width table cell * must fill the cell width. * * @throws \Throwable */ public function testGetHTMLCellImgPercentWidthFillsFixedWidthTableCell(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $img = \imagecreate(4, 2); \imagecolorallocate($img, 255, 255, 255); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $b64src = 'data:image/png;base64,' . \base64_encode((string) $raw); $html = '' . '' . '' . '
    second cell
    '; $out = $obj->getHTMLCell($html, 0, 0, 80, 0); $imgMatch = []; $imgPattern = '/q\s+([-0-9.]+)\s+0\s+0\s+[-0-9.]+\s+[-0-9.]+\s+[-0-9.]+\s+cm\s+\/IMG\d+\s+Do\s+Q/'; $this->assertSame(1, \preg_match($imgPattern, $out, $imgMatch)); assert(isset($imgMatch[1]), "\$imgMatch[1] must be set"); $drawWidth = \floatval($imgMatch[1]); $this->assertEqualsWithDelta($obj->toPoints(30.0), $drawWidth, 0.5); } /** * Issue #247: a percentage hr width must be resolved against the * containing block width. * * @throws \Throwable */ public function testGetHTMLCellHrPercentWidthResolvesAgainstContainerWidth(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $out = $obj->getHTMLCell('
    ', 0, 0, 80, 20); $lineMatch = []; $linePattern = '/([-0-9.]+) [-0-9.]+ m\s+([-0-9.]+) [-0-9.]+ l/'; $this->assertSame(1, \preg_match($linePattern, $out, $lineMatch)); assert(isset($lineMatch[1]), "\$lineMatch[1] must be set"); assert(isset($lineMatch[2]), "\$lineMatch[2] must be set"); $lineLength = \floatval($lineMatch[2]) - \floatval($lineMatch[1]); $this->assertEqualsWithDelta($obj->toPoints(40.0), $lineLength, 0.5); } /** * Issue #247: a percentage width on a form control must be resolved * against the containing block width. * * @throws \Throwable */ public function testGetHTMLCellInputPercentWidthResolvesAgainstContainerWidth(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 80, 10); $annotation = $this->getObjectArrayProperty($obj, 'annotation'); $this->assertIsArray($annotation); $this->assertNotEmpty($annotation); /** @var array{w: float} $field */ $field = \end($annotation); $this->assertEqualsWithDelta(40.0, $field['w'], 0.1); } /** * @throws \Throwable */ public function testGetHTMLCellImgBottomAlignmentUsesTextBaseline(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $img = \imagecreate(4, 4); \imagecolorallocate($img, 255, 255, 255); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $b64src = 'data:image/png;base64,' . \base64_encode((string) $raw); $out = $obj->getHTMLCell('left right', 0, 0, 80, 40); $textMatch = []; $this->assertSame(1, \preg_match('/BT .*? [-0-9.]+ ([-0-9.]+) Td \(left \) Tj ET/s', $out, $textMatch)); $imgPattern = '/q [-0-9.]+ 0 0 [-0-9.]+ [-0-9.]+ ([-0-9.]+) cm \/IMG\d+ Do Q/'; $imgMatch = []; $this->assertSame(1, \preg_match($imgPattern, $out, $imgMatch)); assert(isset($textMatch[1]), "\$textMatch[1] must be set"); assert(isset($imgMatch[1]), "\$imgMatch[1] must be set"); $this->assertEqualsWithDelta(\floatval($textMatch[1]), \floatval($imgMatch[1]), 0.01); } /** * @throws \Throwable */ public function testGetHTMLCellTallBottomAlignedImageShiftsWholeLineDown(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $img = \imagecreate(4, 4); \imagecolorallocate($img, 255, 255, 255); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $b64src = 'data:image/png;base64,' . \base64_encode((string) $raw); $obj->exposeResetBBoxTrace(); $obj->getHTMLCell('left right', 0, 0, 80, 40); $plainTrace = $obj->exposeGetBBoxTrace(); $obj2 = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj2); $obj2->exposeResetBBoxTrace(); $obj2->getHTMLCell('left right', 0, 0, 80, 40); $imageTrace = $obj2->exposeGetBBoxTrace(); $this->assertCount(1, $plainTrace); $this->assertCount(2, $imageTrace); assert(isset($imageTrace[0]), "\$imageTrace[0] must be set"); $this->assertSame('left ', $imageTrace[0]['txt']); assert(isset($imageTrace[1]), "\$imageTrace[1] must be set"); $this->assertSame(' right', $imageTrace[1]['txt']); assert(isset($plainTrace[0]), "\$plainTrace[0] must be set"); $this->assertGreaterThan($plainTrace[0]['bbox_y'], $imageTrace[0]['bbox_y']); $this->assertEqualsWithDelta($imageTrace[0]['bbox_y'], $imageTrace[1]['bbox_y'], 1e-9); } /** * @throws \Throwable */ public function testGetHTMLCellCentersInlineImageRunInsideDiv(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $img = \imagecreate(4, 4); \imagecolorallocate($img, 0, 0, 0); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $src = 'data:image/png;base64,' . \base64_encode((string) $raw); $htmlCenter = '
    ' . '' . '' . '
    '; $htmlLeft = '
    ' . '' . '' . '
    '; $outCenter = $obj->getHTMLCell($htmlCenter, 0, 0, 40, 20); $obj2 = $this->getTestObject(); $this->initFontAndPage($obj2); $outLeft = $obj2->getHTMLCell($htmlLeft, 0, 0, 40, 20); $this->assertNotSame('', $outCenter); $this->assertNotSame('', $outLeft); $this->assertNotSame($outLeft, $outCenter); } /** * @throws \Throwable */ public function testGetHTMLCellCentersSingleInlineImageInsideTableCell(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $img = \imagecreate(4, 4); \imagecolorallocate($img, 0, 0, 0); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $src = 'data:image/png;base64,' . \base64_encode((string) $raw); $htmlCenter = '' . '' . '
    '; $htmlLeft = '' . '' . '
    '; $outCenter = $obj->getHTMLCell($htmlCenter, 0, 0, 40, 20); $obj2 = $this->getTestObject(); $this->initFontAndPage($obj2); $outLeft = $obj2->getHTMLCell($htmlLeft, 0, 0, 40, 20); $this->assertNotSame('', $outCenter); $this->assertNotSame('', $outLeft); $this->assertNotSame($outLeft, $outCenter); } /** * @throws \Throwable */ public function testParseHTMLTextJustifyTracksSpacingAcrossInlineFragments(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 10.0, 40.0, 0.0); $html = '
    ' . 'Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel ' . 'India Juliett Kilo Lima Mike November' . '
    '; $dom = $obj->exposeGetHTMLDOM($html); $firstTextKey = null; foreach ($dom as $key => $elm) { if (!empty($elm['tag'])) { continue; } if (\str_starts_with($elm['value'], 'Alfa')) { $firstTextKey = $key; break; } } $this->assertNotNull($firstTextKey); $tpx = 10.0; $tpy = 10.0; $tpw = 40.0; $tph = 0.0; $out = $obj->exposeParseHTMLTextWithDom($dom, (int) $firstTextKey, $tpx, $tpy, $tpw, $tph); $this->assertNotSame('', $out); $ctx = $obj->exposeGetHTMLRenderContext(); $lineWordSpacing = $ctx['cellctx']['linewordspacing']; $this->assertGreaterThan(0.0, $lineWordSpacing); $bbox = $obj->getLastBBox(); $bboxX = \is_numeric($bbox['x'] ?? null) ? (float) $bbox['x'] : 0.0; $bboxW = \is_numeric($bbox['w'] ?? null) ? (float) $bbox['w'] : 0.0; $this->assertGreaterThan($bboxX + $bboxW, $tpx); } /** * @throws \Throwable */ public function testParseHTMLTextPlainJustifyDoesNotUseInlineCursorSpacingHack(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 10.0, 40.0, 0.0); $html = '
    ' . 'Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett ' . 'Kilo Lima Mike November Oscar Papa Quebec Romeo Sierra Tango' . '
    '; $dom = $obj->exposeGetHTMLDOM($html); $firstTextKey = null; foreach ($dom as $key => $elm) { if (!empty($elm['tag'])) { continue; } if (\str_starts_with($elm['value'], 'Alfa')) { $firstTextKey = $key; break; } } $this->assertNotNull($firstTextKey); $tpx = 10.0; $tpy = 10.0; $tpw = 40.0; $tph = 0.0; $out = $obj->exposeParseHTMLTextWithDom($dom, (int) $firstTextKey, $tpx, $tpy, $tpw, $tph); $this->assertNotSame('', $out); $ctx = $obj->exposeGetHTMLRenderContext(); $lineWordSpacing = $ctx['cellctx']['linewordspacing']; $this->assertSame(0.0, $lineWordSpacing); } /** * @throws \Throwable */ public function testGetHTMLCellMixedInlineJustifyKeepsUniformWordGaps(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeResetBBoxTrace(); $html = '
    ' . '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' . '
    '; $out = $obj->getHTMLCell($html, 20.0, 10.0, 150.0, 0.0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $line = []; foreach ($trace as $row) { if (\abs($row['bbox_y'] - 10.0) >= 0.001) { continue; } $line[] = $row; } $this->assertGreaterThan(5, \count($line)); $gaps = []; for ($idx = 1, $max = \count($line); $idx < $max; ++$idx) { $prev = $this->getTraceRow($line, $idx - 1); $curr = $this->getTraceRow($line, $idx); $gap = $curr['bbox_x'] - ($prev['bbox_x'] + $prev['bbox_w']); $gaps[] = $gap; } $this->assertNotSame([], $gaps); $expected = $gaps[0]; foreach ($gaps as $gap) { $this->assertEqualsWithDelta($expected, $gap, 1e-6); } } /** * @throws \Throwable */ public function testGetHTMLCellJustifySecondLineWithImagesKeepsUniformGaps(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $bfont = $obj->font->insert($obj->pon, 'dejavusans', '', 10); $obj->page->addContent($bfont['out']); $logo = \realpath(__DIR__ . '/../examples/images/tcpdf_logo.jpg'); $box = \realpath(__DIR__ . '/../examples/images/tcpdf_box.svg'); $this->assertNotFalse($logo); $this->assertNotFalse($box); $html = '
    ' . 'JUSTIFY: Alfa Bravo Charlie Delta Echo ' . 'TCPDF logo' . 'TCPDF box ' . 'Foxtrot Golf Hotel India Juliett Kilo Lima Mike November ' . 'Oscar Papa Quebec Romeo Sierra Tango Uniform Victor ' . 'Whiskey Xray Yankee Zulu' . '
    '; $originX = 20.0; $cellWidth = 150.0; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, $originX, 10.0, $cellWidth, 0.0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); $lineY = null; foreach ($trace as $row) { if (\trim($row['txt']) !== 'India') { continue; } $lineY = $row['bbox_y']; break; } $this->assertNotNull($lineY); $secondLine = []; foreach ($trace as $row) { if (\abs($row['bbox_y'] - $lineY) >= 0.01) { continue; } $secondLine[] = $row; } $this->assertGreaterThan(10, \count($secondLine)); $gaps = []; for ($idx = 1, $max = \count($secondLine); $idx < $max; ++$idx) { $prev = $this->getTraceRow($secondLine, $idx - 1); $curr = $this->getTraceRow($secondLine, $idx); $gaps[] = $curr['bbox_x'] - ($prev['bbox_x'] + $prev['bbox_w']); } $this->assertNotSame([], $gaps); $expectedGap = $gaps[0]; foreach ($gaps as $gap) { $this->assertEqualsWithDelta($expectedGap, $gap, 1e-6); } assert(isset($secondLine[0]), "\$secondLine[0] must be set"); $firstRow = $this->getTraceRow($secondLine, 0); $lineLeft = $firstRow['bbox_x']; $lastRow = $this->getTraceRow($secondLine, \count($secondLine) - 1); $lineRight = $lastRow['bbox_x'] + $lastRow['bbox_w']; $this->assertEqualsWithDelta($originX, $lineLeft, 1e-6); $this->assertEqualsWithDelta($originX + $cellWidth, $lineRight, 1e-6); } /** * Regression: a justified inline run that wraps over several visual lines * must not over-stretch the word gaps on a continuation line. * * A wide inline image followed by mixed bold fragments used to drive one * wrapped line into a runaway word-spacing: the per-line spacing was first * estimated from the greedy (zero-spacing) fill, then the line was * re-measured with that spacing applied. Subtracting the spacing again * shrank the line capacity, dropped several words onto the next line and * inflated the spacing - leaving a heavily under-packed line whose few words * were spread across the full width with gaps many times the natural space. * * The word spacing must now stay a small fraction of the natural space width * on every wrapped line. * * @throws \Throwable */ public function testGetHTMLCellJustifyWrappedInlineRunDoesNotOverstretchWordGaps(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $logo = \realpath(__DIR__ . '/../examples/images/tcpdf_logo.jpg'); $this->assertNotFalse($logo); $html = '
    ' . 'a abc abcdefghijkl (abcdef) abcdefg abcdefghi a ((abc)) abcd ' . 'TCPDF logo ' . 'abcdef abcdefg abcdefghi a abc abcd abcdef abcdefg abcdefghi ' . 'a abc abcd abcdef abcdefg abcdefghi a abc abcd abcdef abcdefg abcdefghi ' . 'a abc \(abcd\) abcdef abcdefg abcdefghi a abc \\\(abcd\\\) abcdef abcdefg abcdefghi ' . 'a abc abcd abcdef abcdefg abcdefghi a abc abcd abcdef abcdefg abcdefghi ' . 'a abc abcd abcdef abcdefg abcdefghi a abc abcd ' . 'abcdef abcdefg start a abc before ' . 'yellow color after ' . 'a abc abcd abcdef abcdefg abcdefghi a abc abcd end abcdefg abcdefghi ' . 'a abc abcd abcdef abcdefg abcdefghi a abc abcd abcdef abcdefg abcdefghi ' . 'a abc abcd abcdef abcdefg abcdefghi a abc abcd abcdef abcdefg abcdefghi ' . 'a abc abcd abcdef abcdefg abcdefghi a abc abcd abcdef abcdefg abcdefghi ' . 'a abc abcd abcdef abcdefg abcdefghi' . '
    abcd abcdef abcdefg abcdefghi
    abcd abcde abcdef' . '
    '; $out = $obj->getHTMLCell($html, 20.0, 10.0, 180.0, 0.0); $this->assertNotSame('', $out); // The natural width of a single space; justification adds the Tw // word-spacing on top of it. A correctly packed justified line only needs // a small fraction of the natural space, never a large multiple of it. $naturalSpace = $obj->toPoints($obj->exposeGetStringWidth(' ')); $this->assertGreaterThan(0.0, $naturalSpace); $matches = []; \preg_match_all('/(-?\d+(?:\.\d+)?) Tw/', $out, $matches, \PREG_SET_ORDER); $wordSpacings = []; foreach ($matches as $match) { if (!isset($match[1]) || !\is_numeric($match[1])) { continue; } $wordSpacings[] = (float) $match[1]; } $this->assertNotSame([], $wordSpacings); $positive = \array_filter($wordSpacings, static fn(float $tw): bool => $tw > 1e-4); $this->assertNotSame([], $positive, 'Justified wrapped lines must use positive word spacing.'); // Before the fix a continuation line reached ~5.2x the natural space; every // line now stays below 1x. Bound at 3x to catch the runaway while // tolerating legitimate per-line variation. $maxWordSpacing = \max($wordSpacings); $this->assertLessThan( 3.0 * $naturalSpace, $maxWordSpacing, 'A wrapped justified line over-stretched its word gaps (under-packed line).', ); } /** * @throws \Throwable */ public function testGetHTMLCellRightAlignedMixedInlineLinesReachRightEdge(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $bfont = $obj->font->insert($obj->pon, 'dejavusans', '', 10); $obj->page->addContent($bfont['out']); $logo = \realpath(__DIR__ . '/../examples/images/tcpdf_logo.jpg'); $box = \realpath(__DIR__ . '/../examples/images/tcpdf_box.svg'); $this->assertNotFalse($logo); $this->assertNotFalse($box); $html = '
    ' . 'RIGHT: Alfa Bravo Charlie Delta Echo ' . 'TCPDF logo' . 'TCPDF box ' . 'Foxtrot Golf Hotel India Juliett Kilo Lima Mike November ' . 'Oscar Papa Quebec Romeo Sierra Tango Uniform Victor ' . 'Whiskey Xray Yankee Zulu' . '
    '; $originX = 20.0; $cellWidth = 150.0; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, $originX, 10.0, $cellWidth, 0.0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); $lines = []; foreach ($trace as $row) { $key = \sprintf('%.3F', $row['bbox_y']); if (!isset($lines[$key])) { $lines[$key] = []; } $lines[$key][] = $row; } $this->assertCount(3, $lines); foreach ($lines as $line) { $lineRight = 0.0; foreach ($line as $row) { $lineRight = \max($lineRight, $row['bbox_x'] + $row['bbox_w']); } $this->assertEqualsWithDelta($originX + $cellWidth, $lineRight, 1e-6); } $kiloLineY = null; foreach ($trace as $row) { if (\strpos($row['txt'], 'Kilo') === false) { continue; } $kiloLineY = \sprintf('%.3F', $row['bbox_y']); break; } $this->assertNotNull($kiloLineY); $kiloLineRight = 0.0; $kiloRows = $lines[$kiloLineY] ?? []; $this->assertIsArray($kiloRows); foreach ($kiloRows as $row) { $kiloLineRight = \max($kiloLineRight, $row['bbox_x'] + $row['bbox_w']); } $this->assertEqualsWithDelta($originX + $cellWidth, $kiloLineRight, 1e-6); } /** * @throws \Throwable */ public function testProbeRightAlignTextOnlyMixedInlineFragmentPositions(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $bfont = $obj->font->insert($obj->pon, 'dejavusans', '', 10); $obj->page->addContent($bfont['out']); $obj->setDefaultCellPadding(2, 2, 2, 2); $html = '
    ' . 'RIGHT: 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' . '
    '; $originX = 22.0; $cellWidth = 186.0; $obj->exposeResetBBoxTrace(); $out = $obj->getHTMLCell($html, $originX, 10.0, $cellWidth, 0.0); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotSame([], $trace); $lines = []; foreach ($trace as $row) { $key = \sprintf('%.3F', $row['bbox_y']); if (!isset($lines[$key])) { $lines[$key] = []; } $lines[$key][] = $row; } $this->assertCount(2, $lines); $rightEdge = $originX + $cellWidth - 2.0; $lineKeys = \array_keys($lines); assert(isset($lineKeys[0]), "\$lineKeys[0] must be set"); $firstLine = $lines[$lineKeys[0]] ?? []; $this->assertIsArray($firstLine); assert(isset($lineKeys[1]), "\$lineKeys[1] must be set"); $secondLine = $lines[$lineKeys[1]] ?? []; $this->assertIsArray($secondLine); $firstLast = $firstLine[\count($firstLine) - 1] ?? null; $secondLast = $secondLine[\count($secondLine) - 1] ?? null; $this->assertIsArray($firstLast); $this->assertIsArray($secondLast); $firstLineRight = $firstLast['bbox_x'] + $firstLast['bbox_w']; $secondLineRight = $secondLast['bbox_x'] + $secondLast['bbox_w']; // Fixed: first line now reaches the right edge and includes Oscar. $this->assertEqualsWithDelta($rightEdge, $firstLineRight, 0.5); $this->assertEqualsWithDelta($rightEdge, $secondLineRight, 0.01); $firstLineHasOscar = false; $secondStartsPapa = false; foreach ($firstLine as $row) { if (\strpos($row['txt'], 'Oscar') === false) { continue; } $firstLineHasOscar = true; } $secondStartsPapa = \strpos($secondLine[0]['txt'] ?? '', 'Papa') !== false; $this->assertTrue($firstLineHasOscar); $this->assertTrue($secondStartsPapa); } /** * @throws \Throwable */ public function testParseHTMLTextForcedWrapTrimsLeadingSpaceAtNewLine(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 10.0, 40.0, 0.0); $obj->exposeResetBBoxTrace(); $elm = $this->makeHtmlNode([ 'align' => 'J', 'value' => ' Quebec Romeo', ]); // Simulate a nearly full current line so parseHTMLText pre-wraps this fragment. $tpx = 49.0; $tpy = 10.0; $tpw = 1.0; $tph = 0.0; $out = $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotEmpty($trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertSame('Quebec Romeo', $trace[0]['txt']); } /** * @throws \Throwable */ public function testParseHTMLTextInlineBlockChildRespectsParentWidthForWrap(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 10.0, 180.0, 0.0); $obj->exposeResetBBoxTrace(); $dom = [ 0 => $this->makeHtmlNode([ 'tag' => true, 'opening' => true, 'value' => 'root', 'display' => 'block', 'parent' => 0, ]), 1 => $this->makeHtmlNode([ 'tag' => true, 'opening' => true, 'value' => 'span', 'display' => 'inline-block', 'width' => 18.0, 'x' => 10.0, 'parent' => 0, ]), 2 => $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => 'Styled number input', 'parent' => 1, ]), ]; $tpx = 10.0; $tpy = 10.0; $tpw = 180.0; $tph = 0.0; $out = $obj->exposeParseHTMLTextWithDom($dom, 2, $tpx, $tpy, $tpw, $tph); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotEmpty($trace); $this->assertGreaterThan(10.0, $tpy, 'Inline-block child text should wrap onto a later line.'); $this->assertEqualsWithDelta(28.0, $tpx, 1.0, 'Inline-block run should end near parent width boundary.'); } /** * @throws \Throwable */ public function testParseHTMLTextWordSpacingIncreasesRenderedAdvance(): void { $base = $this->getBBoxProbeTestObject(); $this->initFontAndPage($base); $base->exposeInitHTMLCellContext(10.0, 10.0, 120.0, 0.0); $base->exposeResetBBoxTrace(); $baseElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => 'Alpha Beta Gamma', 'word-spacing' => 0.0, ]); $baseX = 10.0; $baseY = 10.0; $baseW = 120.0; $baseH = 0.0; $baseOut = $base->exposeParseHTMLText($baseElm, $baseX, $baseY, $baseW, $baseH); $this->assertNotSame('', $baseOut); $baseTrace = $base->exposeGetBBoxTrace(); $this->assertNotEmpty($baseTrace); $spaced = $this->getBBoxProbeTestObject(); $this->initFontAndPage($spaced); $spaced->exposeInitHTMLCellContext(10.0, 10.0, 120.0, 0.0); $spaced->exposeResetBBoxTrace(); $spacedElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => 'Alpha Beta Gamma', 'word-spacing' => 2.0, ]); $spacedX = 10.0; $spacedY = 10.0; $spacedW = 120.0; $spacedH = 0.0; $spacedOut = $spaced->exposeParseHTMLText($spacedElm, $spacedX, $spacedY, $spacedW, $spacedH); $this->assertNotSame('', $spacedOut); $spacedTrace = $spaced->exposeGetBBoxTrace(); $this->assertNotEmpty($spacedTrace); $this->assertGreaterThan( $baseX + 0.001, $spacedX, 'word-spacing should increase cursor advance for the text fragment.', ); $this->assertStringContainsString(' Tw', $spacedOut); } /** * @throws \Throwable */ public function testParseHTMLTextNegativeWordSpacingIsClampedToZero(): void { $base = $this->getBBoxProbeTestObject(); $this->initFontAndPage($base); $base->exposeInitHTMLCellContext(10.0, 10.0, 120.0, 0.0); $baseElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => 'Alpha Beta Gamma', 'word-spacing' => 0.0, ]); $baseX = 10.0; $baseY = 10.0; $baseW = 120.0; $baseH = 0.0; $baseOut = $base->exposeParseHTMLText($baseElm, $baseX, $baseY, $baseW, $baseH); $this->assertNotSame('', $baseOut); $negative = $this->getBBoxProbeTestObject(); $this->initFontAndPage($negative); $negative->exposeInitHTMLCellContext(10.0, 10.0, 120.0, 0.0); $negativeElm = $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => 'Alpha Beta Gamma', 'word-spacing' => -2.0, ]); $negativeX = 10.0; $negativeY = 10.0; $negativeW = 120.0; $negativeH = 0.0; $negativeOut = $negative->exposeParseHTMLText($negativeElm, $negativeX, $negativeY, $negativeW, $negativeH); $this->assertNotSame('', $negativeOut); $this->assertEqualsWithDelta($baseX, $negativeX, 0.001); $this->assertStringNotContainsString(' Tw', $negativeOut); } /** * @throws \Throwable */ public function testParseHTMLTextForcedWrapPreservesLeadingSpaceForPreWrap(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 10.0, 40.0, 0.0); $obj->exposeResetBBoxTrace(); $elm = $this->makeHtmlNode([ 'align' => 'J', 'white-space' => 'pre-wrap', 'value' => ' Quebec Romeo', ]); // Simulate a nearly full current line so parseHTMLText pre-wraps this fragment. $tpx = 49.0; $tpy = 10.0; $tpw = 1.0; $tph = 0.0; $out = $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertNotEmpty($trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertStringStartsWith(' ', $trace[0]['txt']); $this->assertStringContainsString('Quebec', $trace[0]['txt']); } /** * @throws \Throwable */ public function testParseHTMLTextPreWrapHonorsExplicitNewlineBreaks(): void { $single = $this->getBBoxProbeTestObject(); $this->initFontAndPage($single); $single->exposeInitHTMLCellContext(10.0, 10.0, 120.0, 0.0); $single->exposeResetBBoxTrace(); $singleElm = $this->makeHtmlNode([ 'white-space' => 'pre-wrap', 'value' => 'Alpha Beta', ]); $singleX = 10.0; $singleY = 10.0; $singleW = 120.0; $singleH = 0.0; $singleOut = $single->exposeParseHTMLText($singleElm, $singleX, $singleY, $singleW, $singleH); $this->assertNotSame('', $singleOut); $singleTrace = $single->exposeGetBBoxTrace(); $this->assertNotEmpty($singleTrace); $multiline = $this->getBBoxProbeTestObject(); $this->initFontAndPage($multiline); $multiline->exposeInitHTMLCellContext(10.0, 10.0, 120.0, 0.0); $multiline->exposeResetBBoxTrace(); $multiElm = $this->makeHtmlNode([ 'white-space' => 'pre-wrap', 'value' => "Alpha\nBeta", ]); $multiX = 10.0; $multiY = 10.0; $multiW = 120.0; $multiH = 0.0; $multiOut = $multiline->exposeParseHTMLText($multiElm, $multiX, $multiY, $multiW, $multiH); $this->assertNotSame('', $multiOut); $multiTrace = $multiline->exposeGetBBoxTrace(); $this->assertNotEmpty($multiTrace); $this->assertGreaterThanOrEqual(2, \count($multiTrace)); assert(isset($multiTrace[0]), "\$multiTrace[0] must be set"); $this->assertSame('Alpha', $multiTrace[0]['txt']); assert(isset($multiTrace[1]), "\$multiTrace[1] must be set"); $this->assertSame('Beta', $multiTrace[1]['txt']); $this->assertGreaterThan( $multiTrace[0]['bbox_y'] + 0.001, $multiTrace[1]['bbox_y'], 'The second pre-wrap segment should render on a later line after an explicit newline.', ); $this->assertGreaterThan( $singleY + 0.001, $multiY, 'pre-wrap text containing a newline should advance the Y cursor to the next line.', ); } /** * @throws \Throwable */ public function testParseHTMLTextPreWrapConsecutiveNewlinesKeepBlankLineAdvance(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 10.0, 120.0, 0.0); $obj->exposeResetBBoxTrace(); $elm = $this->makeHtmlNode([ 'white-space' => 'pre-wrap', 'value' => "Alpha\n\nBeta", ]); $textX = 10.0; $textY = 10.0; $textW = 120.0; $textH = 0.0; $out = $obj->exposeParseHTMLText($elm, $textX, $textY, $textW, $textH); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertCount(2, $trace); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertSame('Alpha', $trace[0]['txt']); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertSame('Beta', $trace[1]['txt']); $firstY = $trace[0]['bbox_y']; $secondY = $trace[1]['bbox_y']; $lineHeight = $trace[0]['bbox_h']; $this->assertGreaterThan( $firstY + $lineHeight + 0.001, $secondY, 'Two explicit newlines should keep one blank rendered line between text segments.', ); $this->assertGreaterThan( 10.0 + 0.001, $textY, 'Consecutive explicit newlines should advance the text cursor vertically.', ); } /** * @throws \Throwable */ public function testParseHTMLTextPreModePreservesLeadingSpacesAcrossExplicitNewline(): void { $obj = $this->getBBoxProbeTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(10.0, 10.0, 120.0, 0.0); $obj->exposeResetBBoxTrace(); $elm = $this->makeHtmlNode([ 'white-space' => 'pre', 'value' => " Alpha\n Beta", ]); $textX = 10.0; $textY = 10.0; $textW = 120.0; $textH = 0.0; $out = $obj->exposeParseHTMLText($elm, $textX, $textY, $textW, $textH); $this->assertNotSame('', $out); $trace = $obj->exposeGetBBoxTrace(); $this->assertGreaterThanOrEqual(2, \count($trace)); assert(isset($trace[0]), "\$trace[0] must be set"); $this->assertStringStartsWith(' ', $trace[0]['txt']); $this->assertStringContainsString('Alpha', $trace[0]['txt']); assert(isset($trace[1]), "\$trace[1] must be set"); $this->assertStringStartsWith(' ', $trace[1]['txt']); $this->assertStringContainsString('Beta', $trace[1]['txt']); $this->assertGreaterThan( $trace[0]['bbox_y'] + 0.001, $trace[1]['bbox_y'], 'The second pre-mode segment should render on a later line after an explicit newline.', ); } // --- Fix tests: base64 data URI images --- /** * @throws \Throwable */ public function testGetHTMLCellRendersBase64DataUriImage(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); // Generate a minimal valid PNG in memory $img = \imagecreate(4, 4); \imagecolorallocate($img, 255, 0, 0); \ob_start(); \imagepng($img); $raw = \ob_get_clean(); $src = 'data:image/png;base64,' . \base64_encode((string) $raw); $out = $obj->getHTMLCell('', 0, 0, 20, 10); // Image must render — output must not fall back to literal '[img]' text $this->assertStringNotContainsString('[img]', $out); } /** * @throws \Throwable */ public function testGetHTMLCellBase64DataUriWithInvalidDataFallsBackToAlt(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); // Invalid base64 payload → base64_decode returns false → src stays as original → // image library throws → fallback alt text is rendered $out = $obj->getHTMLCell( 'err', 0, 0, 20, 10, ); $this->assertNotSame('', $out); $this->assertStringContainsString('err', $out); } /** * @throws \Throwable */ public function testGetHTMLCellResolvesRelativeImagePathFromScriptFilename(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $previousScriptFilename = $_SERVER['SCRIPT_FILENAME']; $_SERVER['SCRIPT_FILENAME'] = (string) \realpath(__DIR__ . '/../examples/E043_html_tables.php'); try { $out = $obj->getHTMLCell( 'should-not-fallback', 0, 0, 20, 10, ); $this->assertNotSame('', $out); $this->assertStringNotContainsString('should-not-fallback', $out); } finally { $_SERVER['SCRIPT_FILENAME'] = $previousScriptFilename; } } // --- Fix tests: setHtmlVSpace() --- /** * @throws \Throwable */ public function testSetHtmlVSpaceAddsExtraSpacingBeforeBlock(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); // Two paragraphs: the second

    opens after the first, so openHTMLBlock will apply vspace $outDefault = $obj->getHTMLCell('

    A

    B

    ', 0, 0, 40, 30); $obj2 = $this->getTestObject(); $this->initFontAndPage($obj2); $obj2->setHtmlVSpace(['p' => [['h' => 0.0, 'n' => 2], ['h' => 0.0, 'n' => 0]]]); $outSpaced = $obj2->getHTMLCell('

    A

    B

    ', 0, 0, 40, 30); $this->assertNotSame('', $outDefault); $this->assertNotSame('', $outSpaced); // Extra 2-line top spacing before the second

    shifts its text downward → different PDF stream $this->assertNotSame($outDefault, $outSpaced); } /** * @throws \Throwable */ public function testSetHtmlVSpaceAddsExtraSpacingAfterBlock(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $outDefault = $obj->getHTMLCell('

    A

    B

    ', 0, 5, 40, 30); $obj2 = $this->getTestObject(); $this->initFontAndPage($obj2); $obj2->setHtmlVSpace(['p' => [['h' => 0.0, 'n' => 0], ['h' => 2.0, 'n' => 0]]]); $outSpaced = $obj2->getHTMLCell('

    A

    B

    ', 0, 5, 40, 30); $this->assertNotSame('', $outDefault); $this->assertNotSame('', $outSpaced); // Extra bottom spacing between paragraphs → different positions for second paragraph $this->assertNotSame($outDefault, $outSpaced); } /** * @throws \Throwable */ public function testSetHtmlVSpaceFixedHeightAddsSpace(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->setHtmlVSpace(['p' => [['h' => 5.0, 'n' => 0], ['h' => 5.0, 'n' => 0]]]); $out = $obj->getHTMLCell('

    spacing test

    ', 0, 5, 40, 30); $this->assertNotSame('', $out); $this->assertStringContainsString('spacing test', $out); } /** * @throws \Throwable */ public function testHtmlImageImportResizeDisabledDoesNotResolveRasterImportDimensions(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $imgpath = \realpath(__DIR__ . '/../examples/images/tcpdf_logo.jpg'); $this->assertNotFalse($imgpath); $src = $imgpath; $dims = $obj->exposeGetHTMLRasterImportDimensions($src, 12.7, 12.7); $this->assertNull($dims); } /** * @throws \Throwable */ public function testHtmlImageImportResizeEnabledResolvesDownscaledRasterImportDimensions(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->setHtmlImageImportResize(true); $imgpath = \realpath(__DIR__ . '/../examples/images/tcpdf_logo.jpg'); $this->assertNotFalse($imgpath); $src = $imgpath; $dims = $obj->exposeGetHTMLRasterImportDimensions($src, 0.5, 0.5); $this->assertIsArray($dims); $this->assertGreaterThan(0, $dims['width']); $this->assertGreaterThan(0, $dims['height']); $this->assertLessThan(300, $dims['width']); $this->assertLessThan(100, $dims['height']); $upscaled = $obj->exposeGetHTMLRasterImportDimensions($src, 500.0, 500.0); $this->assertNull($upscaled); } /** * @throws \Throwable */ public function testHtmlImageImportResizeEnabledSkipsSvgSources(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->setHtmlImageImportResize(true); $dims = $obj->exposeGetHTMLRasterImportDimensions( '@', 12.7, 12.7, ); $this->assertNull($dims); } /** * @throws \Throwable */ public function testGetHTMLInputDisplayValueHelperCoversSupportedTypes(): void { $obj = $this->getInternalTestObject(); $this->assertSame('', $obj->exposeGetHTMLInputDisplayValue(['attribute' => 'invalid'])); $this->assertSame( '', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode([ 'attribute' => ['type' => 'hidden'], ])), ); $this->assertSame( '[x]', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode([ 'attribute' => ['type' => 'checkbox', 'checked' => 'checked'], ])), ); $this->assertSame( '[ ]', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode([ 'attribute' => ['type' => 'radio'], ])), ); $this->assertSame( '***', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode([ 'attribute' => ['type' => 'password', 'value' => 'abc'], ])), ); $this->assertSame( 'Go', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode([ 'attribute' => ['type' => 'submit', 'value' => 'Go'], ])), ); $this->assertSame( 'button', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode([ 'attribute' => ['type' => 'button'], ])), ); $this->assertSame( 'reset', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode([ 'attribute' => ['type' => 'reset'], ])), ); $this->assertSame( 'Filled', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode([ 'attribute' => ['value' => 'Filled'], ])), ); $this->assertSame( 'Hint', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode([ 'attribute' => ['placeholder' => 'Hint'], ])), ); $this->assertSame('', $obj->exposeGetHTMLInputDisplayValue($this->makeHtmlNode())); } /** * @throws \Throwable */ public function testGetHTMLSelectDisplayValueHelperCoversSelectionFallbacks(): void { $obj = $this->getInternalTestObject(); $this->assertSame('', $obj->exposeGetHTMLSelectDisplayValue(['attribute' => 'invalid'])); $this->assertSame( '', $obj->exposeGetHTMLSelectDisplayValue($this->makeHtmlNode([ 'attribute' => [], ])), ); $opt = 'one#!TaB!#One#!NwL!##!SeL!#two#!TaB!#Two#!NwL!#three#!TaB!#Three#!NwL!#'; $this->assertSame( 'Three, Two', $obj->exposeGetHTMLSelectDisplayValue($this->makeHtmlNode([ 'attribute' => ['opt' => $opt, 'value' => 'three, two'], ])), ); $this->assertSame( 'Two', $obj->exposeGetHTMLSelectDisplayValue($this->makeHtmlNode([ 'attribute' => ['opt' => $opt], ])), ); $this->assertSame( 'One', $obj->exposeGetHTMLSelectDisplayValue($this->makeHtmlNode([ 'attribute' => ['opt' => 'one#!TaB!#One#!NwL!#two#!TaB!#Two#!NwL!#'], ])), ); } /** * @throws \Throwable */ public function testHTMLListHelperMethodsTrackMarkerTypesAndCounters(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->setULLIDot('square'); /** @var array $dom */ $dom = [ $this->makeHtmlNode([ 'value' => 'ol', 'opening' => true, 'attribute' => ['type' => 'A', 'start' => '3'], ]), $this->makeHtmlNode([ 'value' => 'li', 'opening' => true, 'attribute' => ['value' => '7'], 'parent' => 0, ]), ]; $this->assertSame('#', $obj->exposeGetCurrentHTMLListMarkerType()); $this->assertSame('square', $obj->exposeGetHTMLListMarkerTypeWithDom($dom, -1, false)); $this->assertSame('#', $obj->exposeGetHTMLListMarkerTypeWithDom($dom, -1, true)); $this->assertSame('', $obj->exposeGetHTMLListImageMarkerType('')); $this->assertSame('', $obj->exposeGetHTMLListImageMarkerType('not-a-url')); $this->assertStringStartsWith('img|png|', $obj->exposeGetHTMLListImageMarkerType('url(icon.png)')); $prevScriptFilename = $_SERVER['SCRIPT_FILENAME']; $_SERVER['SCRIPT_FILENAME'] = (string) \realpath(__DIR__ . '/../examples/E043_html_tables.php'); try { $resolvedMarker = $obj->exposeGetHTMLListImageMarkerType('url(images/tcpdf_logo.jpg)'); $this->assertStringContainsString( (string) \realpath(__DIR__ . '/../examples/images/tcpdf_logo.jpg'), $resolvedMarker, ); } finally { $_SERVER['SCRIPT_FILENAME'] = $prevScriptFilename; } $this->assertStringStartsWith( 'img|svg|', $obj->exposeGetHTMLListImageMarkerType('url(data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=)'), ); $dom[2] = $this->makeHtmlNode([ 'value' => 'ul', 'opening' => true, 'style' => ['list-style-image' => 'url(icon.png)'], ]); $this->assertStringStartsWith('img|png|', $obj->exposeGetHTMLListMarkerTypeWithDom($dom, 2, false)); $obj->exposePushHTMLListWithDom($dom, -1, false); $this->assertSame('square', $obj->exposeGetCurrentHTMLListMarkerType()); $obj->exposePopHTMLList(); $this->assertSame('#', $obj->exposeGetCurrentHTMLListMarkerType()); $this->assertSame(1, $obj->exposeGetHTMLListItemCounterWithDom($dom, 1)); $obj->exposePushHTMLListWithDom($dom, 0, true); $this->assertSame('a', $obj->exposeGetCurrentHTMLListMarkerType()); $this->assertSame(7, $obj->exposeGetHTMLListItemCounterWithDom($dom, 1)); $this->assertSame(8, $obj->exposeGetHTMLListItemCounterWithDom($dom, -1)); $obj->exposePopHTMLList(); $this->assertSame('#', $obj->exposeGetCurrentHTMLListMarkerType()); } /** * @throws \Throwable */ public function testHTMLTableAndAncestorHelpersCoverBorderFillAndLookupBranches(): void { $obj = $this->getInternalTestObject(); $dom = [ $this->makeHtmlNode([ 'value' => 'div', 'opening' => true, 'block' => true, 'bgcolor' => '#ff0', 'parent' => -1, ]), $this->makeHtmlNode([ 'value' => 'span', 'opening' => true, 'bgcolor' => '#ff0', 'parent' => 0, 'border' => [ 'LTRB' => ['lineWidth' => 1.0, 'lineColor' => '#000'], ], ]), $this->makeHtmlNode([ 'value' => 'td', 'opening' => true, 'bgcolor' => '#f00', 'parent' => 0, 'border' => [ 'T' => ['lineWidth' => 0.2, 'lineColor' => '#111'], 'B' => ['lineWidth' => 0.4, 'lineColor' => '#222'], ], ]), $this->makeHtmlNode([ 'value' => 'input', 'opening' => true, 'parent' => 4, ]), $this->makeHtmlNode([ 'value' => 'form', 'opening' => true, 'attribute' => ['action' => 'https://example.test/form', 'method' => 'get'], 'parent' => -1, ]), ]; $all = $obj->exposeGetHTMLTableCellBorderStylesWithDom($dom, 1); $this->assertArrayHasKey('all', $all); $allBorder = $all['all'] ?? null; $this->assertIsArray($allBorder); $this->assertIsFloat($allBorder['lineWidth'] ?? null); $this->assertSame(1.0, $allBorder['lineWidth']); $sides = $obj->exposeGetHTMLTableCellBorderStylesWithDom($dom, 2); $this->assertArrayHasKey(0, $sides); $this->assertArrayHasKey(2, $sides); $this->assertArrayNotHasKey(1, $sides); $this->assertSame([], $obj->exposeGetHTMLTableCellBorderStylesWithDom($dom, -1)); $fill = $obj->exposeGetHTMLTableCellFillStyleWithDom($dom, 2); $this->assertIsArray($fill); $this->assertIsString($fill['fillColor'] ?? null); $this->assertSame('#f00', $fill['fillColor']); $this->assertNull($obj->exposeGetHTMLTableCellFillStyleWithDom($dom, -1)); $fillStyle = $obj->exposeGetHTMLFillStyle('#0f0'); $this->assertSame('#0f0', $fillStyle['fillColor'] ?? null); $this->assertTrue($obj->exposeHasBlockLvBgAncestorWithDom($dom, 1)); $this->assertFalse($obj->exposeHasBlockLvBgAncestorWithDom($dom, 2)); $this->assertSame(4, $obj->exposeFindHTMLAncestorOpeningTagWithDom($dom, 3, 'form')); $cyclic = [ $this->makeHtmlNode(['value' => 'span', 'opening' => true, 'parent' => 1]), $this->makeHtmlNode(['value' => 'em', 'opening' => true, 'parent' => 0]), ]; $this->assertSame(-1, $obj->exposeFindHTMLAncestorOpeningTagWithDom($cyclic, 0, 'form')); } /** * @throws \Throwable */ public function testGetHTMLInputButtonActionHelperCoversOverrideAndFormBranches(): void { $obj = $this->getInternalTestObject(); $dom = [ $this->makeHtmlNode([ 'value' => 'form', 'opening' => true, 'attribute' => ['action' => 'https://example.test/form', 'method' => 'get'], 'parent' => -1, ]), $this->makeHtmlNode([ 'value' => 'input', 'opening' => true, 'parent' => 0, ]), ]; $this->assertSame('alert(1)', $obj->exposeGetHTMLInputButtonActionWithDom($dom, 1, 'submit', [ 'onclick' => 'alert(1)', ])); $this->assertSame(['S' => 'ResetForm'], $obj->exposeGetHTMLInputButtonActionWithDom($dom, 1, 'reset', [])); $this->assertSame('', $obj->exposeGetHTMLInputButtonActionWithDom($dom, 1, 'button', [])); $override = $obj->exposeGetHTMLInputButtonActionWithDom($dom, 1, 'submit', [ 'formaction' => 'https://example.test/override', 'formmethod' => 'get', ]); $this->assertIsArray($override); $this->assertSame('SubmitForm', $override['S'] ?? null); $this->assertSame('https://example.test/override', $override['F'] ?? null); $this->assertSame(['ExportFormat', 'GetMethod'], $override['Flags'] ?? null); $inherited = $obj->exposeGetHTMLInputButtonActionWithDom($dom, 1, 'submit', []); $this->assertIsArray($inherited); $this->assertSame('https://example.test/form', $inherited['F'] ?? null); $this->assertSame(['ExportFormat', 'GetMethod'], $inherited['Flags'] ?? null); } /** * @throws \Throwable */ public function testHtmlTcpdfHelperMethodsParseAllowAndResetCursor(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(12.0, 0.0, 80.0, 0.0); $payload = \rawurlencode(\json_encode(['m' => 'AddPage', 'p' => [true]], JSON_THROW_ON_ERROR)); $data = '64+' . \str_repeat('a', 64) . '+' . $payload; $parsed = $obj->exposeParseHTMLTcpdfSerializedData($data); $this->assertIsArray($parsed); $this->assertSame('AddPage', $parsed['m']); $this->assertSame([true], $parsed['p']); $this->assertNull($obj->exposeParseHTMLTcpdfSerializedData('broken')); $this->assertNull($obj->exposeParseHTMLTcpdfSerializedData('0+a+payload')); $this->assertTrue($obj->exposeIsAllowedHTMLTcpdfMethod('pagebreak')); $this->assertTrue($obj->exposeIsAllowedHTMLTcpdfMethod('addpage')); $this->assertFalse($obj->exposeIsAllowedHTMLTcpdfMethod('setNamedDestination')); $before = $obj->exposePageBreak(); $tpx = 41.0; $tpw = 9.0; $obj->exposeExecuteHTMLTcpdfPageBreak('true', $tpx, $tpw); $after = $obj->exposePageBreak(); $this->assertGreaterThan($before, $after); $this->assertSame(12.0, $tpx); $this->assertSame(80.0, $tpw); } /** * @throws \Throwable */ public function testEstimateHTMLTableHeadHeightAccountsForTableCellpaddingAttribute(): void { // Regression: estimateHTMLTableHeadHeight previously ignored the // table-level cellpadding attribute. parseHTMLTagOPENtd applies it // as a default for cells with zero CSS padding, so the standalone // thead estimate must mirror that or the replayed header on a new // page is shorter than what is actually rendered (see example 018). $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 80.0, 0.0); $bare = $obj->exposeEstimateHTMLTableHeadHeight( '
    H
    ', ); $padded = $obj->exposeEstimateHTMLTableHeadHeight( '
    H
    ', ); $this->assertGreaterThan(0.0, $bare); $this->assertGreaterThan($bare, $padded); // 2 * 6 (default unit 'px') of vertical cellpadding converted to the // default 'mm' unit is roughly 3.17 mm (6px = 4.5pt; 2 sides ≈ 3.17 mm); // allow generous tolerance for any minor rounding/font interplay. $delta = $padded - $bare; $this->assertGreaterThanOrEqual(2.5, $delta); $this->assertLessThanOrEqual(4.0, $delta); } /** * @throws \Throwable */ public function testEstimateHTMLTableHeadHeightAccountsForTableCellspacingAttribute(): void { // Regression: the standalone thead estimate must also mirror the // cellspacing the runtime adds in parseHTMLTagOPENtable (one initial // gap) and parseHTMLTagCLOSEtr (one gap per closed row). $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 80.0, 0.0); $nospacing = $obj->exposeEstimateHTMLTableHeadHeight( '
    H
    ', ); $spaced = $obj->exposeEstimateHTMLTableHeadHeight( '
    H
    ', ); $this->assertGreaterThan($nospacing, $spaced); } /** * @throws \Throwable */ public function testEstimateHTMLTableHeadHeightAccountsForCssBorderSpacingStyle(): void { // Regression: the standalone thead estimate should honor table-level // CSS border-spacing (vertical axis), not only the HTML cellspacing // attribute, because runtime table opening/row closing uses the same // effective vertical spacing path. $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 80.0, 0.0); $nospacing = $obj->exposeEstimateHTMLTableHeadHeight( '
    H
    ', ); $spaced = $obj->exposeEstimateHTMLTableHeadHeight( '
    H
    ', ); $this->assertGreaterThan($nospacing, $spaced); } /** * @throws \Throwable */ public function testHtmlEstimateHelpersCoverTableTextAndNobrBranches(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 80.0, 0.0); $this->assertSame(0.0, $obj->exposeEstimateHTMLTableHeadHeight('')); $this->assertGreaterThan( 0.0, $obj->exposeEstimateHTMLTableHeadHeight('Head'), ); $dom = [ $this->makeHtmlNode(['value' => 'tr', 'opening' => true, 'parent' => -1]), $this->makeHtmlNode([ 'value' => 'td', 'opening' => true, 'parent' => 0, 'height' => 9.0, 'padding' => ['T' => 1.0, 'R' => 0.0, 'B' => 1.0, 'L' => 0.0], ]), $this->makeHtmlNode(['value' => 'tr', 'opening' => false, 'parent' => -1]), $this->makeHtmlNode(['value' => 'div', 'opening' => true, 'parent' => -1]), $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => 'Alpha Beta Gamma', 'parent' => 3, ]), $this->makeHtmlNode(['value' => 'br', 'opening' => true, 'parent' => 3]), $this->makeHtmlNode(['value' => 'img', 'opening' => true, 'parent' => 3, 'height' => 6.0]), $this->makeHtmlNode([ 'value' => 'input', 'opening' => true, 'parent' => 3, 'attribute' => ['type' => 'password', 'value' => 'abc'], ]), $this->makeHtmlNode([ 'value' => 'select', 'opening' => true, 'parent' => 3, 'attribute' => ['opt' => 'one#!TaB!#One#!NwL!##!SeL!#two#!TaB!#Two#!NwL!#'], ]), $this->makeHtmlNode([ 'value' => 'textarea', 'opening' => true, 'parent' => 3, 'attribute' => ['value' => 'Delta'], ]), $this->makeHtmlNode(['value' => 'table', 'opening' => true, 'parent' => 3]), $this->makeHtmlNode(['value' => 'tr', 'opening' => true, 'parent' => 10]), $this->makeHtmlNode([ 'value' => 'td', 'opening' => true, 'parent' => 11, 'height' => 7.0, 'padding' => ['T' => 1.0, 'R' => 0.0, 'B' => 1.0, 'L' => 0.0], ]), $this->makeHtmlNode(['value' => 'tr', 'opening' => false, 'parent' => 10]), $this->makeHtmlNode(['value' => 'table', 'opening' => false, 'parent' => 3]), $this->makeHtmlNode(['value' => 'div', 'opening' => false, 'parent' => -1]), ]; $rowHeight = $obj->exposeEstimateHTMLTableRowHeightWithDom($dom, 0); $this->assertGreaterThanOrEqual(9.0, $rowHeight); $this->assertSame(15, $obj->exposeFindHTMLClosingTagIndex($dom, 3)); $this->assertSame(1, $obj->exposeFindHTMLClosingTagIndex($dom, 1)); $textHeightWide = $obj->exposeEstimateHTMLTextHeightWithDom($dom, 4, 'Alpha Beta Gamma', 0.0); $textHeightNarrow = $obj->exposeEstimateHTMLTextHeightWithDom($dom, 4, 'Alpha Beta Gamma', 10.0); $this->assertGreaterThan(0.0, $textHeightWide); $this->assertGreaterThanOrEqual($textHeightWide, $textHeightNarrow); $this->assertSame(0.0, $obj->exposeEstimateHTMLTextHeightWithDom($dom, 999, 'Alpha', 10.0)); $this->assertEqualsWithDelta($rowHeight, $obj->exposeEstimateHTMLNobrHeightWithDom($dom, 0, 20.0), 0.01); $this->assertGreaterThan($rowHeight, $obj->exposeEstimateHTMLNobrHeightWithDom($dom, 3, 20.0)); $emptyDiv = [$this->makeHtmlNode(['value' => 'div', 'opening' => true])]; $this->assertSame(0.0, $obj->exposeEstimateHTMLNobrHeightWithDom($emptyDiv, 0, 20.0)); } /** * @throws \Throwable */ public function testHtmlFontAndLineAdvanceHelpersCoverFallbackAndCachingBranches(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 80.0, 0.0); $this->assertSame('helvetica', $obj->exposeGetHTMLBaseFontName()); $this->assertSame([], $obj->exposeGetHTMLFontMetricWithDom([], 0)); $this->assertSame('', $obj->exposeGetHTMLTextPrefixWithDom([], 0)); $this->assertSame(0.0, $obj->exposeGetHTMLLineAdvanceWithDom([], 0)); $dom = [ $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => 'Alpha', 'fontname' => 'helveticaBI', 'fontstyle' => 'BIU', 'fontsize' => 12.0, 'fgcolor' => 'red', 'line-height' => 1.5, ]), ]; $metricA = $obj->exposeGetHTMLFontMetricWithDom($dom, 0); $metricB = $obj->exposeGetHTMLFontMetricWithDom($dom, 0); $this->assertNotSame([], $metricA); $this->assertSame($metricA['key'] ?? null, $metricB['key'] ?? null); $prefix = $obj->exposeGetHTMLTextPrefixWithDom($dom, 0); $this->assertNotSame('', $prefix); $dom[0]['fgcolor'] = 'rgba(120,40,200,0.5)'; $alphaPrefixRgba = $obj->exposeGetHTMLTextPrefixWithDom($dom, 0); $this->assertStringContainsString(" gs\n", $alphaPrefixRgba); $dom[0]['fgcolor'] = 'hsla(300, 60%, 50%, 0.25)'; $alphaPrefixHsla = $obj->exposeGetHTMLTextPrefixWithDom($dom, 0); $this->assertStringContainsString(" gs\n", $alphaPrefixHsla); $lineAdvance = $obj->exposeGetHTMLLineAdvanceWithDom($dom, 0); $this->assertGreaterThan(0.0, $lineAdvance); $obj->exposeSetHTMLLineState(5.0, 0.0, false); $this->assertGreaterThanOrEqual(5.0, $obj->exposeGetCurrentHTMLLineAdvanceWithDom($dom, 0)); $obj->exposeUpdateHTMLLineAdvance(0.0); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertSame(5.0, $hrc['cellctx']['lineadvance']); $obj->exposeUpdateHTMLLineAdvance(3.0); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertSame(5.0, $hrc['cellctx']['lineadvance']); $obj->exposeUpdateHTMLLineAdvance(8.0); $hrc = $obj->exposeGetHTMLRenderContext(); $this->assertSame(8.0, $hrc['cellctx']['lineadvance']); } /** * Regression test: font family names ending in style-suffix letters * ('b', 'i', 'u', 'd', 'o') must not be truncated when deriving the * HTML base font name. For example "dejavusanscondensed" was returned * as "dejavusanscondense" because the trailing 'd' was treated as the * strikeout style suffix, even though it is part of the family name. * * @throws \Throwable */ public function testHtmlBaseFontNamePreservesFamilyNamesEndingInStyleLetters(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $fontfile = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/dejavu/dejavusanscondensed.json'); $obj->font->insert($obj->pon, 'dejavusanscondensed', '', 12, null, null, $fontfile); $this->assertSame('dejavusanscondensed', $obj->exposeGetHTMLBaseFontName()); // The uppercase 'B'/'I' font-key style suffix must still be stripped. $fontfileb = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/dejavu/dejavusanscondensedb.json'); $obj->font->insert($obj->pon, 'dejavusanscondensed', 'B', 12, null, null, $fontfileb); $this->assertSame('dejavusanscondensedB', $obj->font->getCurrentFontKey()); $this->assertSame('dejavusanscondensed', $obj->exposeGetHTMLBaseFontName()); // getHTMLFontMetric() must not truncate the family name either, // even when a strikeout (D) style is requested. $obj->exposeInitHTMLCellContext(0.0, 0.0, 80.0, 0.0); $dom = [ $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => 'Alpha', 'fontname' => 'dejavusanscondensed', 'fontstyle' => 'D', 'fontsize' => 12.0, ]), ]; $metric = $obj->exposeGetHTMLFontMetricWithDom($dom, 0); $this->assertSame('dejavusanscondensed', $metric['key'] ?? null); // Rendering HTML captures and restores the caller font state: // captureHTMLCallerFontState() must not truncate the family name. $obj->font->insert($obj->pon, 'dejavusanscondensed', '', 12); $obj->getHTMLCell('

    Plain bold strike

    ', 10.0, 10.0, 100.0, 0.0); $this->assertSame('dejavusanscondensed', $obj->font->getCurrentFontKey()); } /** * @throws \Throwable */ public function testCssLineHeightAbsoluteAndRelativeValuesUseFontSizeSemantics(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 80.0, 0.0); /** @var array $dom */ $dom = [ 0 => $this->makeHtmlNode(), 1 => $this->makeHtmlNode([ 'parent' => 0, 'fontname' => 'helvetica', 'fontsize' => 12.0, 'attribute' => ['style' => 'line-height:20pt;'], ]), 2 => $this->makeHtmlNode([ 'parent' => 0, 'fontname' => 'helvetica', 'fontsize' => 12.0, 'attribute' => ['style' => 'line-height:150%;'], ]), 3 => $this->makeHtmlNode([ 'parent' => 0, 'fontname' => 'helvetica', 'fontsize' => 12.0, 'attribute' => ['style' => 'line-height:1.5;'], ]), ]; $obj->parseHTMLStyleAttributes($dom, 1, 0); $obj->parseHTMLStyleAttributes($dom, 2, 0); $obj->parseHTMLStyleAttributes($dom, 3, 0); $expectedAbsolute = $obj->exposeToUnitPoints($obj->exposeGetUnitValuePoints('20pt')); $expectedRelative = $obj->exposeToUnitPoints(12.0 * 1.5); $this->assertEqualsWithDelta($expectedAbsolute, $obj->exposeGetHTMLLineAdvanceWithDom($dom, 1), 0.001); $this->assertEqualsWithDelta($expectedRelative, $obj->exposeGetHTMLLineAdvanceWithDom($dom, 2), 0.001); $this->assertEqualsWithDelta($expectedRelative, $obj->exposeGetHTMLLineAdvanceWithDom($dom, 3), 0.001); } /** * @throws \Throwable */ public function testHtmlInlineMetricHelpersCoverWrapSpaceAndAscentBranches(): void { $obj = $this->getInternalTestObject(); $this->initFontAndPage($obj); $obj->exposeInitHTMLCellContext(0.0, 0.0, 80.0, 0.0); $dom = [ $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => ' Alpha Beta ', ]), $this->makeHtmlNode([ 'value' => 'img', 'opening' => true, 'width' => 6.0, 'height' => 12.0, 'attribute' => ['align' => 'middle'], ]), $this->makeHtmlNode([ 'tag' => false, 'opening' => false, 'value' => 'Gamma', ]), $this->makeHtmlNode([ 'value' => 'br', 'opening' => true, ]), ]; $zero = $obj->exposeMeasureHTMLInlineLineMetricsWithDom($dom, 0, 0.0); $this->assertSame(['width' => 0.0, 'spaces' => 0, 'wrapped' => false], $zero); $wide = $obj->exposeMeasureHTMLInlineLineMetricsWithDom($dom, 0, 200.0); $this->assertGreaterThan(0.0, $wide['width']); $this->assertGreaterThanOrEqual(1, $wide['spaces']); $this->assertFalse($wide['wrapped']); $narrow = $obj->exposeMeasureHTMLInlineLineMetricsWithDom($dom, 0, 10.0, 2.0); $this->assertTrue($narrow['wrapped']); $this->assertSame(0, $obj->exposeGetHTMLTextFirstLineSpaces('', '', 20.0)); $this->assertSame(0, $obj->exposeGetHTMLTextFirstLineSpaces('Alpha', '', 20.0)); $this->assertGreaterThanOrEqual(1, $obj->exposeGetHTMLTextFirstLineSpaces('Alpha Beta Gamma', '', 200.0)); $normalText = $obj->exposeNormalizeHTMLTextWithMode('Alpha ', 'normal'); $preWrapText = $obj->exposeNormalizeHTMLTextWithMode('Alpha ', 'pre-wrap'); $normalTrailingSpaces = $obj->exposeGetHTMLTextFirstLineSpaces($normalText, '', 200.0); $preWrapTrailSpaces = $obj->exposeGetHTMLTextFirstLineSpaces($preWrapText, '', 200.0); $this->assertSame('Alpha ', $normalText); $this->assertSame('Alpha ', $preWrapText); $this->assertGreaterThan( $normalTrailingSpaces, $preWrapTrailSpaces, 'pre-wrap should preserve additional trailing spaces compared to normal mode.', ); $this->assertTrue($obj->exposeHasHTMLTextBreakOpportunity('Alpha Beta')); $this->assertTrue($obj->exposeHasHTMLTextBreakOpportunity("Alpha\u{00AD}Beta")); $this->assertFalse($obj->exposeHasHTMLTextBreakOpportunity('AlphaBeta')); $this->assertFalse($obj->exposeHasHTMLTextBreakOpportunity(' ')); $this->assertFalse($obj->exposeHasHTMLTextBreakOpportunityWithMode('Alpha Beta', 'nowrap')); $this->assertTrue($obj->exposeHasHTMLTextBreakOpportunityWithMode('Alpha Beta', 'pre-wrap')); $ascent = $obj->exposeMeasureHTMLInlineRunMaxAscentWithDom($dom, 0); $this->assertGreaterThan(0.0, $ascent); $topOnly = [ $this->makeHtmlNode([ 'value' => 'img', 'opening' => true, 'height' => 10.0, 'attribute' => ['align' => 'top'], ]), $this->makeHtmlNode([ 'value' => 'br', 'opening' => true, ]), ]; $this->assertSame(0.0, $obj->exposeMeasureHTMLInlineRunMaxAscentWithDom($topOnly, 0)); } // --- Fix tests: interactive form field input types --- /** * @throws \Throwable */ public function testGetHTMLCellInputButtonCreatesButtonAnnotation(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 40, 10); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Btn', $this->getMapString($opt, 'ft')); } /** * @throws \Throwable */ public function testGetHTMLCellInputSubmitUsesEnclosingFormAction(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '
    ' . '
    ', 0, 0, 60, 10, ); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Btn', $this->getMapString($opt, 'ft')); $action = $this->getMapString($opt, 'a'); $this->assertNotSame('', $action); $this->assertStringContainsString('/S /SubmitForm', $action); $this->assertStringContainsString('/F (https://example.test/form)', $action); $this->assertStringContainsString('/Flags 12', $action); } /** * @throws \Throwable */ public function testGetHTMLCellInputResetCreatesResetFormAction(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 40, 10); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Btn', $this->getMapString($opt, 'ft')); $this->assertStringContainsString('/S /ResetForm', $this->getMapString($opt, 'a')); } /** * @throws \Throwable */ public function testGetHTMLCellInputTextCreatesTextFieldAnnotation(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 40, 10); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Tx', $this->getMapString($opt, 'ft')); } /** * @throws \Throwable */ public function testGetHTMLCellTextareaCreatesMultilineTextFieldAnnotation(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 40, 20); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Tx', $this->getMapString($opt, 'ft')); // Multiline flag (bit 13 = 4096) must be set $this->assertSame(1 << 12, $this->getMapInt($opt, 'ff')); } /** * @throws \Throwable */ public function testGetHTMLCellTextareaColsControlsFieldWidth(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell('', 0, 0, 80, 30); $annotation = $this->getObjectArrayProperty($obj, 'annotation'); $this->assertIsArray($annotation); $this->assertNotEmpty($annotation); /** @var array{w: float} $auto */ $auto = \end($annotation); $obj2 = $this->getTestObject(); $this->initFontAndPage($obj2); $obj2->getHTMLCell('', 0, 0, 80, 30); $annotation2 = $this->getObjectArrayProperty($obj2, 'annotation'); $this->assertIsArray($annotation2); $this->assertNotEmpty($annotation2); /** @var array{w: float} $withCols */ $withCols = \end($annotation2); $this->assertLessThan($auto['w'], $withCols['w']); $this->assertLessThan(80.0, $withCols['w']); } /** * @throws \Throwable */ public function testGetHTMLCellSelectCreatesComboBoxAnnotation(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 10, ); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Ch', $this->getMapString($opt, 'ft')); $labels = $this->extractComboBoxLabels($opt['opt'] ?? null); $this->assertContains('Red', $labels); $this->assertContains('Green', $labels); } /** * @throws \Throwable */ public function testGetHTMLCellSelectMultipleCreatesListBoxAndEnablesMultipleSelection(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 10, ); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Ch', $this->getMapString($opt, 'ft')); $fieldFlags = $this->getMapInt($opt, 'ff'); // Combo flag (bit 18 = 1<<17) must not be set for list boxes. $this->assertSame(0, $fieldFlags & (1 << 17)); // Multi-select flag (bit 22 = 1<<21) must be set when HTML select has "multiple". $this->assertSame(1 << 21, $fieldFlags & (1 << 21)); } /** * @throws \Throwable */ public function testGetHTMLCellSelectMultipleZeroKeepsListBoxWithoutMultiSelectFlag(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 10, ); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Ch', $this->getMapString($opt, 'ft')); $this->assertSame('b', $this->getMapString($opt, 'v')); $fieldFlags = $this->getMapInt($opt, 'ff'); // Combo flag (bit 18 = 1<<17) must not be set for list boxes. $this->assertSame(0, $fieldFlags & (1 << 17)); // multiple="0" is a false boolean value and must not enable multiselect. $this->assertSame(0, $fieldFlags & (1 << 21)); } /** * @throws \Throwable */ public function testGetHTMLCellSelectMultipleStoresSelectedIndicesFromSelectedOptions(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 10, ); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Ch', $this->getMapString($opt, 'ft')); $this->assertSame('a', $this->getMapString($opt, 'v')); $this->assertSame([0, 2], $this->getMapIntList($opt, 'i')); } /** * @throws \Throwable */ public function testGetHTMLCellSelectMultipleValueFallbackStoresValidSelectedIndices(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 10, ); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Ch', $this->getMapString($opt, 'ft')); $this->assertSame('c', $this->getMapString($opt, 'v')); $this->assertSame([2, 1], $this->getMapIntList($opt, 'i')); } /** * @throws \Throwable */ public function testGetHTMLCellSelectMultiplePrefersSelectedOptionsOverSelectValueFallback(): void { $obj = $this->getTestObject(); $this->initFontAndPage($obj); $obj->getHTMLCell( '', 0, 0, 40, 10, ); $opt = $this->getLastAnnotationOptFromObject($obj); $this->assertSame('Ch', $this->getMapString($opt, 'ft')); // selected