| Server IP : 139.59.63.204 / Your IP : 216.73.217.62 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ubuntu-s-1vcpu-1gb-blr1-01 6.8.0-110-generic #110-Ubuntu SMP PREEMPT_DYNAMIC Thu Mar 19 15:09:20 UTC 2026 x86_64 User : root ( 0) PHP Version : 8.3.6 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/abyogasms.com/vendor/tecnickcom/tc-lib-pdf/test/ |
Upload File : |
<?php
/**
* HTMLTest.php
*
* @category Library
* @package Pdf
* @author Nicola Asuni <info@tecnick.com>
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE.TXT)
* @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<string, mixed> $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<string, mixed> $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<string, mixed>
*/
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<string, array<string, mixed>>
*/
private function getHtmlNodeBorders(array $node): array
{
return $node['border'];
}
/**
* @param THTMLAttrib $node
* @return array<string, mixed>
*/
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<array-key, mixed>
*/
private function requireMixedArray(mixed $value, string $error): array
{
if (!\is_array($value)) {
$this->fail($error);
}
return $value;
}
/** @return array<int, array<string, mixed>> */
private function getObjectArrayProperty(object $obj, string $property): array
{
$rawValue = $this->requireMixedArray(
$this->getObjectProperty($obj, $property),
'Expected array property: ' . $property,
);
/** @var array<int, array<array-key, mixed>> $entries */
$entries = \array_values(\array_filter($rawValue, static fn(mixed $entry): bool => \is_array($entry)));
/** @var array<int, array<string, mixed>> $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<int, array{opt: array<string, mixed>}>
*/
private function getFormAnnotations(\Com\Tecnick\Pdf\Tcpdf $obj): array
{
$raw = $this->getObjectArrayProperty($obj, 'annotation');
/** @var array<int, array{opt: array<string, mixed>}> $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<int, array{opt: array<string, mixed>}> $annotations
* @return array<int, string>
*/
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<int, array{opt: array<string, mixed>}> $annotations
* @return array<string, mixed>
*/
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<int, array<string, mixed>>
*/
private function getAnnotationList(object $obj): array
{
$raw = $this->getObjectArrayProperty($obj, 'annotation');
if ($raw === []) {
$this->fail('Expected non-empty annotation array.');
}
/** @var array<int, array<string, mixed>> $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<string, mixed> $annotation
* @return array<string, mixed>
*/
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<string, mixed> $typed */
$typed = [];
foreach (\array_keys($opt) as $key) {
if (!\is_string($key)) {
continue;
}
$typed[$key] = $opt[$key] ?? null;
}
return $typed;
}
/**
* @return array<string, mixed>
*/
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<string, mixed> $map */
private function getMapString(array $map, string $key): string
{
return isset($map[$key]) && \is_string($map[$key]) ? $map[$key] : '';
}
/** @param array<string, mixed> $map */
private function getMapInt(array $map, string $key): int
{
return isset($map[$key]) && \is_int($map[$key]) ? $map[$key] : 0;
}
/**
* @param array<string, mixed> $map
* @return array<int, int>
*/
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<int, string>
*/
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<int, array{left: float|null, right: float|null}> $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<int, array<string, mixed>> $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<int, array<string, mixed>> $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 = '<html><head><style>p { COLOR: RED; }</style></head><body><p>Hello</p><br></body></html>';
$out = $obj->tidyHTML($html, 'body{font-size:10pt;}');
$this->assertStringStartsWith('<style>', $out);
$this->assertStringContainsString('body{font-size:10pt;}', $out);
$this->assertStringContainsString('p { color: red; }', \strtolower($out));
$this->assertStringContainsString('<br />', $out);
}
/**
* @throws \Throwable
*/
public function testIsValidCSSSelectorForTagMatchesClassAndId(): void
{
$obj = $this->getTestObject();
$dom = [
0 => $this->makeHtmlNode([
'value' => 'root',
'attribute' => ['lang' => 'en-US'],
]),
1 => $this->makeHtmlNode(['value' => 'div', 'attribute' => ['id' => 'main', 'class' => 'hero card']]),
];
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' div.hero'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' div#main'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' span.hero'));
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMCSSDataCollectsApplicableStyles(): void
{
$obj = $this->getTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode(['value' => 'p', 'attribute' => ['class' => 'x', 'style' => 'font-weight:bold;']]),
];
$css = [
'0010 p.x' => 'color:red;',
'0001 div' => 'color:blue;',
];
$obj->getHTMLDOMCSSData($dom, $css, 1);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertNotEmpty($dom[1]['cssdata']);
$combined = '';
foreach ($dom[1]['cssdata'] as $row) {
$combined .= $row['c'];
}
$this->assertStringContainsString('color:red', $combined);
$this->assertStringContainsString('font-weight:bold', $combined);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesParsesBasicInlineStyles(): void
{
$obj = $this->getTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode([
'value' => 'div',
'attribute' => ['style' => 'direction:rtl;display:none;text-transform:uppercase;text-align:center;'],
]),
];
$obj->parseHTMLStyleAttributes($dom, 1, 0);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertSame('rtl', $dom[1]['dir']);
$this->assertTrue($dom[1]['hide']);
$this->assertSame('uppercase', $dom[1]['text-transform']);
$this->assertSame('C', $dom[1]['align']);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesResolvesInitialValuesByPropertyMap(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['line-height' => 1.0]),
1 => $this->makeHtmlNode([
'parent' => 0,
'dir' => 'rtl',
'hide' => true,
'align' => 'J',
'fgcolor' => 'red',
'line-height' => 2.0,
'border-collapse' => 'collapse',
'list-style-position' => 'inside',
'listtype' => 'square',
'font-size-adjust' => 0.61,
'font-variant' => 'small-caps',
'fontstyle' => 'BIU',
'orphans' => 4,
'page' => 'chapter',
'quotes' => 'none',
'widows' => 4,
'attribute' => [
'style' =>
'direction:initial;display:initial;text-align:initial;color:initial;'
. 'line-height:initial;border-collapse:initial;list-style-position:initial;'
. 'list-style-type:initial;text-decoration:initial;font-weight:initial;font-style:initial;'
. 'font-size-adjust:initial;font-variant:initial;orphans:initial;page:initial;quotes:initial;widows:initial;',
],
]),
];
$obj->parseHTMLStyleAttributes($dom, 1, 0);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertSame('ltr', $dom[1]['dir']);
$this->assertFalse($dom[1]['hide']);
$this->assertSame('L', $dom[1]['align']);
$this->assertSame('rgb(0%,0%,0%)', $dom[1]['fgcolor']);
$this->assertSame($dom[0]['line-height'], $dom[1]['line-height']);
$this->assertSame('separate', $dom[1]['border-collapse']);
$this->assertSame('outside', $dom[1]['list-style-position']);
$this->assertSame('disc', $dom[1]['listtype']);
$this->assertSame('', $dom[1]['fontstyle']);
$this->assertSame('none', $dom[1]['font-size-adjust']);
$this->assertSame('normal', $dom[1]['font-variant']);
$this->assertSame(2, $dom[1]['orphans']);
$this->assertSame('auto', $dom[1]['page']);
$this->assertSame('auto', $dom[1]['quotes']);
$this->assertSame(2, $dom[1]['widows']);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesAllInitialAppliesMapAndKeepsExplicitOverrides(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['line-height' => 1.0]),
1 => $this->makeHtmlNode([
'parent' => 0,
'dir' => 'rtl',
'hide' => true,
'align' => 'J',
'fgcolor' => 'red',
'line-height' => 2.0,
'border-collapse' => 'collapse',
'list-style-position' => 'inside',
'listtype' => 'square',
'font-size-adjust' => 0.61,
'font-variant' => 'small-caps',
'orphans' => 4,
'page' => 'chapter',
'quotes' => 'none',
'widows' => 5,
'attribute' => [
'style' => 'all:initial;color:blue;',
],
]),
];
$obj->parseHTMLStyleAttributes($dom, 1, 0);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertSame('ltr', $dom[1]['dir']);
$this->assertFalse($dom[1]['hide']);
$this->assertSame('L', $dom[1]['align']);
$this->assertSame('rgb(0%,0%,100%)', $dom[1]['fgcolor']);
$this->assertSame($dom[0]['line-height'], $dom[1]['line-height']);
$this->assertSame('separate', $dom[1]['border-collapse']);
$this->assertSame('outside', $dom[1]['list-style-position']);
$this->assertSame('disc', $dom[1]['listtype']);
$this->assertSame('none', $dom[1]['font-size-adjust']);
$this->assertSame('normal', $dom[1]['font-variant']);
$this->assertSame(2, $dom[1]['orphans']);
$this->assertSame('auto', $dom[1]['page']);
$this->assertSame('auto', $dom[1]['quotes']);
$this->assertSame(2, $dom[1]['widows']);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesPlannedPropertiesModesAndInherit(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode([
'font-size-adjust' => 0.51,
'font-variant' => 'small-caps',
'orphans' => 4,
'page' => 'chapter',
'quotes' => 'none',
'widows' => 5,
]),
1 => $this->makeHtmlNode([
'parent' => 0,
'attribute' => [
'style' => 'font-size-adjust:0.62;font-variant:normal;orphans:3;page:appendix;quotes:"[" "]";widows:6;',
],
]),
2 => $this->makeHtmlNode([
'parent' => 0,
'attribute' => [
'style' => 'font-size-adjust:inherit;font-variant:inherit;orphans:inherit;page:inherit;quotes:inherit;widows:inherit;',
],
]),
3 => $this->makeHtmlNode([
'parent' => 0,
'font-size-adjust' => 0.8,
'font-variant' => 'normal',
'orphans' => 9,
'page' => 'keepme',
'quotes' => 'auto',
'widows' => 9,
'attribute' => [
'style' => 'font-size-adjust:invalid;font-variant:bad;orphans:foo;page:9bad;quotes:bad;widows:foo;',
],
]),
];
$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(0.62, $dom[1]['font-size-adjust']);
$this->assertSame('normal', $dom[1]['font-variant']);
$this->assertSame(3, $dom[1]['orphans']);
$this->assertSame('appendix', $dom[1]['page']);
$this->assertSame('"[" "]"', $dom[1]['quotes']);
$this->assertSame(6, $dom[1]['widows']);
assert(isset($dom[2]), "\$dom[2] must be set");
$this->assertSame($dom[0]['font-size-adjust'], $dom[2]['font-size-adjust']);
$this->assertSame($dom[0]['font-variant'], $dom[2]['font-variant']);
$this->assertSame($dom[0]['orphans'], $dom[2]['orphans']);
$this->assertSame($dom[0]['page'], $dom[2]['page']);
$this->assertSame($dom[0]['quotes'], $dom[2]['quotes']);
$this->assertSame($dom[0]['widows'], $dom[2]['widows']);
assert(isset($dom[3]), "\$dom[3] must be set");
$this->assertSame(0.8, $dom[3]['font-size-adjust']);
$this->assertSame('normal', $dom[3]['font-variant']);
$this->assertSame(9, $dom[3]['orphans']);
$this->assertSame('keepme', $dom[3]['page']);
$this->assertSame('auto', $dom[3]['quotes']);
$this->assertSame(9, $dom[3]['widows']);
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMStyleTagSelectorsApplyPlannedProperties(): void
{
$obj = $this->getInternalTestObject();
$html =
'<style>.probe{font-size-adjust:0.59;font-variant:small-caps;orphans:5;page:chapter;quotes:"[" "]";widows:4;}</style>'
. '<p class="probe">Hello</p>';
$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('<<A<B>>>', $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<int, mixed> $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<int, mixed> $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<int, THTMLAttrib> $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<int, THTMLAttrib> $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<int, THTMLAttrib> $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 =
'<div>INTRO</div>'
. '<div><section><p style="break-before:'
. $breakSide
. '">'
. $marker
. '</p></section></div>'
. '<div>OUTRO</div>';
$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 = '<p style="line-height:' . $lineHeight . ';">' . $text . '</p>';
$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<int, THTMLAttrib> $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<int, THTMLAttrib> $dom */
/** @var array<int, THTMLAttrib> $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<int, THTMLAttrib> $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 <a> that already has bold from a parent <b>
$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 <a> inside <b>');
$this->assertStringContainsString('U', $dom[1]['fontstyle'], 'Underline must be added on <a>');
}
/**
* @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('<p>Hello</p>', 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('<div id="sec-1">Hello</div>', 10, 12, 40, 20);
/** @var array<string, array<string, int|float>> $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('<p>A</p>', 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('<p>AddedByMethod</p>', 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('<p>StyledAdd</p>', 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 =
'<p>Lorem ipsum dolor sit amet consectetur adipiscing elit.'
. ' Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>';
$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 =
'<div style="line-height:1.25;">'
. '<p style="text-align:justify;font-size:12pt;">'
. '<span style="font-size:14pt;font-weight:bold;">TEST:</span> '
. $lorem
. ' <img src="'
. $img
. '" width="5mm" height="5mm" /> '
. $lorem
. '</p></div>';
$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 .= '<p>Word' . \str_pad((string) $i, 3, '0', \STR_PAD_LEFT) . '</p>';
}
$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 =
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
. ' Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>';
$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 =
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
. ' Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>';
$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<float>
*/
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 =
'<div style="text-align:center;">'
. '<p><b>BOTTOM BORDER</b><br />text inside the bottom margin.</p>'
. '<table border="1"><tr><td>Table Cell BOTTOM</td></tr></table>'
. '</div>';
$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 = '<div><p>BOTTOM</p><table border="1"><tr><td>Table Cell BOTTOM</td></tr></table></div>';
$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 = '<div><p>BOTTOM</p><table border="1"><tr><td>Table Cell BOTTOM</td></tr></table></div>';
$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 = '<div><p>BOTTOM</p><table border="1"><tr><td>Table Cell BOTTOM</td></tr></table></div>';
$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 = '<div><p>BOTTOM</p><table border="1"><tr><td>Table Cell BOTTOM</td></tr></table></div>';
$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 .= '<li>Item ' . $i . ' Lorem ipsum dolor sit amet consectetur</li>';
}
$obj->addHTMLCell('<ol>' . $items . '</ol>', 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 .= '<li>Item ' . $i . ' with long text to force wrapping and page flow inside the list block.</li>';
}
$html =
'<style>'
. '.panel{border:0.2mm solid #333;background-color:#f2f6fb;padding:2mm;margin-bottom:2mm;}'
. '</style>'
. '<div class="panel">'
. '<h2>SECTION4</h2>'
. '<ul>'
. $items
. '</ul>'
. '</div>'
. '<div class="panel">'
. '<h2>SECTION6</h2>'
. '<p>After list block</p>'
. '</div>';
$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 .= '<tr><td>Row ' . $i . '</td><td>Lorem ipsum dolor sit amet</td></tr>';
}
$obj->addHTMLCell('<table border="1"><tr><th>A</th><th>B</th></tr>' . $rows . '</table>', 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 =
'<span>Alfa</span> <span>Bravo</span> <span>Charlie</span> <span>Delta</span> '
. '<span>Echo</span> <span>Foxtrot</span> <span>Golf</span> <span>Hotel</span> '
. '<span>India</span> <span>Juliett</span> <span>Kilo</span> <span>Lima</span> '
. '<span>Mike</span> <span>November</span> <span>Oscar</span> <span>Papa</span> '
. '<span>Quebec</span> <span>Romeo</span> <span>Sierra</span> <span>Tango</span> '
. '<span>Uniform</span> <span>Victor</span> <span>Whiskey</span> <span>Xray</span> '
. '<span>Yankee</span> <span>Zulu</span>';
$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 =
'<table border="1" cellspacing="3" cellpadding="4">'
. '<tr><td align="left"><span>1L</span> '
. $spanWords
. '</td></tr>'
. '<tr><td align="center"><span>1C</span> '
. $spanWords
. '</td></tr>'
. '<tr><td align="right"><span>1R</span> '
. $spanWords
. '</td></tr>'
. '<tr><td align="left"><span>2L</span> A1 ex<i>amp</i>le <a href="https://tcpdf.org">link</a> '
. 'column span. '
. $plainWords
. '.</td></tr>'
. '<tr><td align="center"><span>2C</span> A1 ex<i>amp</i>le <a href="https://tcpdf.org">link</a> '
. 'column span. '
. $plainWords
. '.</td></tr>'
. '<tr><td align="right"><span>2R</span> A1 ex<i>amp</i>le <a href="https://tcpdf.org">link</a> '
. 'column span. '
. $plainWords
. '.</td></tr>'
. '<tr><td align="left"><small>3L small text</small> '
. $plainWords
. '</td></tr>'
. '<tr><td align="center"><small>3C small text</small> '
. $plainWords
. '</td></tr>'
. '<tr><td align="right"><small>3R small text</small> '
. $plainWords
. '</td></tr>'
. '</table>';
$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 = '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>';
$html = '<div style="background-color:#ffeeaa;border:1px solid #000">' . \str_repeat($chunk, 150) . '</div>';
$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('<p>de­nounce</p>', 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 =
'<ol><li style="text-indent: 6mm;">'
. '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.'
. '</li></ol>';
$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('<p>A</p>', 0, 0, 20, 8, $nopad, []);
$paddedOut = $obj->getHTMLCell('<p>A</p>', 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('<p>A</p>', 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 =
'<body>'
. '<a href="https://example.com">'
. '<b>B</b><em>E</em><font>F</font><i>I</i><label>L</label><marker>M</marker>'
. '<s>S</s><small>sm</small><span>sp</span><strike>st</strike><strong>sg</strong>'
. '<tt>tt</tt><u>u</u><del>d</del><form>frm</form>'
. '</a>'
. '<blockquote>q</blockquote>'
. '<div>dv</div>'
. '<dl><dt>dt</dt><dd>dd</dd></dl>'
. '<h1>1</h1><h2>2</h2><h3>3</h3><h4>4</h4><h5>5</h5><h6>6</h6>'
. '<hr></hr><br></br>'
. '<img alt="img"></img>'
. '<input value="inp"></input>'
. '<ol><li>o1</li></ol>'
. '<ul><li>u1</li></ul>'
. '<select value="v2"><option value="v1">A</option><option value="v2" selected>B</option></select>'
. '<output value="out"></output>'
. '<p>p<sub>sub</sub><sup>sup</sup></p>'
. '<pre>pre</pre>'
. '<table><thead><tr><th>H</th></tr></thead><tr><td>T</td></tr></table>'
. '<tablehead><tr><td>TH</td></tr></tablehead>'
. '<tcpdf method="noop"></tcpdf>'
. '<textarea value="txt"></textarea>'
. '</body>';
$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>small text</small> normal <small>small text</small>';
$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 = '<table border="1" cellspacing="3" cellpadding="4"><tr><td style="border:0">X</td></tr></table>';
$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 = '<table border="0" cellspacing="0" cellpadding="2"><tr><td>A</td><td>B</td></tr></table>';
$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 = '<table style="border:0" cellspacing="0" cellpadding="2"><tr><td>A</td><td>B</td></tr></table>';
$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 = '<table style="border:1px solid black;border-width:0"><tr><td>A</td></tr></table>';
$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 = '<table style="border:1px solid black;border-style:none"><tr><td>A</td></tr></table>';
$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 = '<table style="border-width:0;border:1px solid black"><tr><td>A</td></tr></table>';
$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 = '<table style="border:1px solid black;border-color:red"><tr><td>A</td></tr></table>';
$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 = '<table border="1" cellspacing="3" cellpadding="2"><tr><td style="border-width:0">A</td></tr></table>';
$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 =
'<div style="text-align:center">'
. 'The words “<span dir="rtl">מזל [mazel] טוב [tov]</span>'
. '” mean “Congratulations!”</div>';
$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 =
'<table border="1" cellspacing="3" cellpadding="4"><tr><td align="center">'
. '<span>Alfa</span> <span>Bravo</span> <span>Charlie</span> <span>Delta</span> '
. '<span>Echo</span> <span>Foxtrot</span> <span>Golf</span> <span>Hotel</span>'
. '</td></tr></table>';
$obj->exposeResetBBoxTrace();
$out = $obj->getHTMLCell($html, 0, 0, $cellWidth, 0);
$this->assertNotSame('', $out);
$trace = $obj->exposeGetBBoxTrace();
$this->assertNotSame([], $trace);
/** @var array<string, array{left: float, right: float}> $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 =
'<table border="1" cellspacing="3" cellpadding="4"><tr><td align="right">'
. '<span>1R</span> <span>Alfa</span> <span>Bravo</span> <span>Charlie</span> <span>Delta</span> '
. '<span>Echo</span> <span>Foxtrot</span> <span>Golf</span> <span>Hotel</span> <span>India</span> '
. '<span>Juliett</span> <span>Kilo</span> <span>Lima</span> <span>Mike</span> <span>November</span> '
. '<span>Oscar</span> <span>Papa</span> <span>Quebec</span> <span>Romeo</span> <span>Sierra</span> '
. '<span>Tango</span> <span>Uniform</span> <span>Victor</span> <span>Whiskey</span> <span>Xray</span> '
. '<span>Yankee</span> <span>Zulu</span>'
. '</td></tr></table>';
$obj->exposeResetBBoxTrace();
$out = $obj->getHTMLCell($html, 0, 0, $cellWidth, 0);
$this->assertNotSame('', $out);
$trace = $obj->exposeGetBBoxTrace();
$this->assertNotSame([], $trace);
/** @var array<string, bool> $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 =
'<table border="0" cellspacing="1" cellpadding="2" style="width:100%;">'
. '<tr>'
. '<td style="width:50%;">Gesch\u{00E4}ftsf\u{00FC}hrer Egon Schrempp Amtsgericht Stuttgart HRB 1234</td>'
. '<td style="width:50%;">RIGHTCOL</td>'
. '</tr>'
. '</table>';
$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 = '<table border="1" cellspacing="3" cellpadding="4"><tr>' . $cellHtml . '</tr></table>';
$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<string, bool> $linekeys */
$linekeys = [];
/** @var array<int, float> $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<int, array{0: string, 1: string, 2: int, 3: string, 4: ?string}>
*/
public static function tableLineRegressionProvider(): array
{
$line1Spans =
'<span>Alfa</span> <span>Bravo</span> <span>Charlie</span> <span>Delta</span> '
. '<span>Echo</span> <span>Foxtrot</span> <span>Golf</span> <span>Hotel</span> '
. '<span>India</span> <span>Juliett</span> <span>Kilo</span> <span>Lima</span> '
. '<span>Mike</span> <span>November</span> <span>Oscar</span> <span>Papa</span> '
. '<span>Quebec</span> <span>Romeo</span> <span>Sierra</span> <span>Tango</span> '
. '<span>Uniform</span> <span>Victor</span> <span>Whiskey</span> <span>Xray</span> '
. '<span>Yankee</span> <span>Zulu</span>';
$line2Text =
' A1 ex<i>amp</i>le <a href="https://tcpdf.org">link</a> column span. '
. 'One two tree four five six seven eight nine ten.';
return [
[
'1L',
'<td align="left"><span>1L</span> ' . $line1Spans . '</td>',
2,
'1L',
null,
],
[
'1C',
'<td align="center"><span>1C</span> ' . $line1Spans . '</td>',
2,
'1C',
null,
],
[
'1R',
'<td align="right"><span>1R</span> ' . $line1Spans . '</td>',
2,
'1R',
null,
],
[
'2L',
'<td align="left"><span>2L</span>' . $line2Text . '</td>',
1,
'2L',
'A1 ex',
],
[
'2C',
'<td align="center"><span>2C</span>' . $line2Text . '</td>',
1,
'2C',
'A1 ex',
],
[
'2R',
'<td align="right"><span>2R</span>' . $line2Text . '</td>',
1,
'2R',
'A1 ex',
],
[
'3L',
'<td align="left"><small>3L small text</small>'
. ' 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</td>',
2,
'3L small text',
'Alfa',
],
[
'3C',
'<td align="center"><small>3C small text</small>'
. ' 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</td>',
2,
'3C small text',
'Alfa',
],
[
'3R',
'<td align="right"><small>3R small text</small>'
. ' 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</td>',
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 =
'<table border="1" cellspacing="3" cellpadding="4"><tr><td align="'
. $align
. '">'
. '<small>3X small text</small> 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'
. '</td></tr></table>';
$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 </small> 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 =
'<table border="1" cellspacing="3" cellpadding="4">'
. '<tr><td align="left"><small>3L small text</small>'
. ' 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</td></tr>'
. '<tr><td align="center"><small>3C small text</small>'
. ' 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</td></tr>'
. '<tr><td align="right"><small>3R small text</small>'
. ' 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</td></tr>'
. '</table>';
$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<int, array{0: string}>
*/
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 "(<em>Sierra-Tango</em>)") 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 =
'<p>Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett Kilo. '
. 'Lima Mike November Oscar Papa Quebec Romeo (<em>Sierra-Tango</em>) Uniform Victor '
. 'Whiskey (<em>Xray-Yankee</em>). Zulu.</p>';
$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<string, bool> $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 =
'<div style="font-size:10mm;">'
. '<p>D1_DEFAULT<br/>D2_DEFAULT<br/>D3_DEFAULT</p>'
. '<p style="line-height:normal;">N1_NORMAL<br/><br/>N2_NORMAL<br/>N3_NORMAL</p>'
. '<p style="line-height:100%;">H1_100<br/><br/>H2_100<br/>H3_100</p>'
. '<p style="line-height:50%;">H1_050<br/><br/>H2_050<br/>H3_050</p>'
. '<p style="line-height:150%;">H1_150<br/><br/>H2_150<br/>H3_150</p>'
. '</div>';
$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 + <br/><br/> must add extra vertical distance.');
$this->assertGreaterThan($h100Single, $h100Double, '100% + <br/><br/> must add extra vertical distance.');
$this->assertGreaterThan($h050Single, $h050Double, '50% + <br/><br/> must add extra vertical distance.');
$this->assertGreaterThan($h150Single, $h150Double, '150% + <br/><br/> 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 =
'<div style="font-size:20mm;">'
. '<div style="line-height:20mm;">B1<br/>B2<br/>B3<br/>B4</div>'
. '<div style="line-height:20mm;">D1<br/>D2<br/>D3<div>D4</div></div>'
. '</div>';
$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 <em> 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
// <em> on the SAME line. The previous logic considered the line
// "deep" because the italic <em> 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 =
'<p>This document demonstrates PDF encryption and permission controls using tc-lib-pdf. '
. 'The file is protected with a user password (<em>demo-user</em>) and an owner password '
. '(<em>demo-owner</em>). Encryption restricts unauthorized access while the owner password '
. 'grants full control.</p>';
$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('<a href="https://example.com">Click</a>', 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('<a href="https://example.com">A</a>B', 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('<a href="https://example.com">A<textarea value=""></textarea>B</a>', 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('<a href="https://example.com">A<select></select>B</a>', 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('<a href="https://example.com"><del>A</del>B</a>', 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('<ol><li>One</li><li>Two</li></ol>', 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('<ul><li>First</li><li>Second</li></ul>', 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('<ol><li>Probe item baseline</li></ol>', 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('<ol style="padding-left:9mm"><li>Probe item baseline</li></ol>', 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('<ol style="padding-left:9mm"><li>Probe precedence</li></ol>', 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(
'<ol style="padding-left:9mm"><li style="margin-left:4mm">Probe precedence</li></ol>',
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('<ol><li>OUTER_A<ul><li>INNER_B</li></ul></li><li>OUTER_C</li></ol>', 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(
'<ol style="padding-left:9mm"><li>OUTER_D<ul><li>INNER_E</li></ul></li></ol>',
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(
'<ol style="padding-left:9mm"><li>OUTER_D<ul style="margin-left:2mm"><li>INNER_E</li></ul></li></ol>',
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(
'<style>li::marker { color: red; }</style><ol><li>ITEM_1</li><li>ITEM_2</li></ol>',
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('<style>li::marker { color: #FF0000; }</style><ul><li>ITEM_1</li></ul>', 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(
'<style>li::marker { '
. 'color: red; '
. 'text-decoration: underline; '
. 'margin-left: 5mm; '
. 'padding: 2mm; '
. '}</style>'
. '<ol><li>ITEM_1</li></ol>',
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(
'<style>.marker-red li::marker { color: red; font-weight: bold; text-decoration: underline; }</style>'
. '<ol class="marker-red"><li>Item</li></ol>',
);
$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(
'<ul style="list-style-image: url(' . $listImageDataUri . ')"><li>Custom bullet image</li></ul>',
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('|@<svg', $marker);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesPreservesDataUriValues(): void
{
$obj = $this->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 =
'<style>.img-list{list-style-image:url(data:image/svg+xml;base64,PHN2Zz4=);}</style>'
. '<ul class="img-list"><li>Item</li></ul>';
$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 =
'<style>.list-img-svg{list-style-image:url(data:image/svg+xml;base64,PHN2Zz4=);}</style>'
. '<ul class="list-img-svg"><li>A<ul class="list-img-svg"><li>B</li></ul></li></ul>';
$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(
'<table><tr><th>H1</th><th>H2</th></tr><tr><td>A</td><td>B</td></tr></table>',
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(
'<table><tr><td colspan="2">Top</td></tr><tr><td>Left</td><td>Right</td></tr></table>',
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('<div nobr="true"><div nobr="true">A</div>B</div>', 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('<div nobr="true"><p>A</p><p>B</p></div>', 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(
'<input type="text" value="field" /><textarea>notes</textarea><input type="hidden" value="secret" />',
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(
'<input type="checkbox" checked="checked" />'
. '<input type="radio" />'
. '<input type="password" value="abc" />',
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('<input type="file" name="userfile" size="20" />', 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('<textarea rows="3" value="line1 line2">line1 line2</textarea>', 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(
'<label for="username">User Name</label><input id="username" type="text" value="alice" />',
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('<label>Notes<textarea value="x"></textarea></label>', 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(
'<label for="missing">Missing</label><input id="present" type="text" value="ok" />',
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(
'<input type="text" value="abcdef" maxlength="5" readonly required disabled />',
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('<select required disabled><option value="v1">Alpha</option></select>', 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(
'<select readonly required><option value="v1">Alpha</option></select>'
. '<textarea maxlength="3" disabled>hello</textarea>',
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<string, mixed>} $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<string, mixed>} $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(
'<input type="email" value="a@example.com" /><input type="number" value="42" />',
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<string, mixed>} $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<string, mixed>} $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(
'<input type="email" value="a@example.com" required disabled />'
. '<input type="number" value="42" required disabled readonly />',
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<string, mixed>} $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<string, mixed>} $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(
'<input type="date" value="2026-04-27" required disabled />'
. '<input type="tel" value="12345" readonly required />',
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<string, mixed>} $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<string, mixed>} $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(
'<input type="url" value="https://example.test" required disabled />'
. '<input type="url" value="https://example.org" readonly required />',
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<string, mixed>} $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<string, mixed>} $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<int, string>
*/
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(
'<select><option value="v1">Alpha</option><option value="v2">Beta</option></select>',
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(
'<select value="v2"><option value="v1">Alpha</option><option value="v2">Beta</option></select>',
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(
"<select value=\"v2\"><option value='v1'>Alpha</option><option value='v2'>Beta</option></select>",
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(
'<select value="v2"><option value=v1>Alpha</option><option value=v2>Beta</option></select>',
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(
'<select value="v2,v1"><option value="v1">Alpha</option><option value="v2">Beta</option></select>',
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(
'<select><option value="v1">Alpha</option><option value="v2" selected="selected">Beta</option></select>',
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(
'<select><option selected="selected">Alpha</option><option selected="selected">Beta</option></select>',
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('<select><option>Alpha</option><option selected>Beta</option></select>', 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('<select><option>Alpha</option><option selected="true">Beta</option></select>', 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(
"<select><option>Alpha</option><option selected='selected'>Beta</option></select>",
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('<select><option>Alpha</option><option SELECTED>Beta</option></select>', 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(
'<select>'
. '<optgroup label="Fruits"><option value="a">Apple</option><option value="b">Banana</option></optgroup>'
. '<optgroup label="Veg"><option value="c">Carrot</option></optgroup>'
. '</select>',
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(
'<select value="v1"><option value="v1">Alpha</option><option value="v2" selected>Beta</option></select>',
0,
0,
40,
20,
);
$this->assertSame('v2', $this->getLastComboBoxValue($obj));
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellSelectValueFallbackSkipsUnknownValues(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$obj->getHTMLCell(
'<select value="missing,v2"><option value="v1">Alpha</option><option value="v2">Beta</option></select>',
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('<img src="/tmp/__tc_lib_pdf_missing_image__.png" />', 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('<img alt="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(
'<img src="/tmp/__tc_lib_pdf_missing_image__.png" alt="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('<table><tr><td style="border:1px solid black">A</td></tr></table>', 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(
'<table><tr><td style="border:1px solid black">A</td>'
. '<td style="border:1px solid black">This is a longer wrapped cell value</td></tr></table>',
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(
'<table><tr><td rowspan="2">A</td><td>Top</td></tr><tr><td>Bottom</td></tr></table>',
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(
'<table>'
. '<tr><td rowspan="2" style="border:1px solid black">A</td>'
. '<td style="border:1px solid black">Top</td></tr>'
. '<tr><td style="border:1px solid black">Bottom</td></tr>'
. '</table>',
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(
'<table cellspacing="3">'
. '<tr><td rowspan="2" style="border:1px solid black">A</td>'
. '<td style="border:1px solid black">Top</td></tr>'
. '<tr><td style="border:1px solid black">Bottom</td></tr>'
. '</table>',
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(
'<table style="border-spacing:0 3">'
. '<tr><td rowspan="2" style="border:1px solid black">A</td>'
. '<td style="border:1px solid black">Top</td></tr>'
. '<tr><td style="border:1px solid black">Bottom</td></tr>'
. '</table>',
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('<table><tr><td style="background-color:#eeeeee">A</td></tr></table>', 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('<span bgcolor="#eeeeee">A</span>', 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 =
'<table style="caption-side:bottom;border:1px solid #000" cellspacing="0" cellpadding="1">'
. '<caption>CAPTION-BOTTOM</caption>'
. '<tr><td>ROW-CELL</td></tr>'
. '</table>';
$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 =
'<table style="caption-side:top;border:1px solid #000" cellspacing="0" cellpadding="1">'
. '<caption>CAPTION-TOP</caption>'
. '<tr><td>ROW-CELL</td></tr>'
. '</table>';
$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 color="#ff0000" bgcolor="#ffff00">small 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 <small> background must cover more than one rendered line.',
);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellDrawsMultipleFillRectsForWrappedInlineSmallBackground(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$html =
'<small color="#ff0000" bgcolor="#ffff00">'
. 'small 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 <small> 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 = '<span style="background-color:#ffff00;">' . $text . '</span>';
$nowrap = '<span style="background-color:#ffff00;white-space:nowrap;">' . $text . '</span>';
$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(
'<div style="background-color:#880000;color:white;">Hello World!<br />Hello</div>',
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('<table><thead><tr><th>H</th></tr></thead><tr><td>T</td></tr></table>', 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(
'<table><thead><tr><th>H</th></tr></thead>'
. '<tr style="page-break-before:always"><td>T</td></tr></table>',
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(
'<table><thead><tr><th>H</th></tr></thead><tr><td>Tall</td></tr><tr><td>Next</td></tr></table>',
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 </table> 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 .= '<tr><td style="border-width:0">Row ' . $i . '</td></tr>';
}
$out = $obj->getHTMLCell(
'<table border="1" cellspacing="0" cellpadding="2">' . $rows . '</table>',
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
// <table cellpadding="N"> 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(
'<table cellpadding="6"><thead><tr><th>HDR</th></tr></thead><tr><td>BODY</td></tr></table>',
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 = '<table cellpadding="6" cellspacing="0"><tr><th>HDR</th></tr></table>';
$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(
'<table cellpadding="3" cellspacing="0">'
. '<thead><tr style="background-color:#cccccc"><th>H</th><th>HH</th></tr></thead>'
. '<tr style="page-break-before:always"><td>A</td><td>B</td></tr></table>',
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) <style> block makes the CSS map
// non-empty, which triggers recomputeHTMLDOMCSSAgainstFinalTree(). That
// re-cascade re-runs the table defaults and cleared the serialized
// header rows stored on the table node, so the header was no longer
// replayed on continuation pages. The stored header must survive the
// re-cascade regardless of any unrelated style rules.
$obj = new \Com\Tecnick\Pdf\Tcpdf('px');
$this->initFontAndPage($obj);
$out = $obj->getHTMLCell(
'<style> .unused { color:red; } </style>'
. '<table cellpadding="3" cellspacing="0">'
. '<thead><tr style="background-color:#cccccc"><th>H</th><th>HH</th></tr></thead>'
. '<tr style="page-break-before:always"><td>A</td><td>B</td></tr></table>',
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 <style> block is present.',
);
}
/**
* @throws \Throwable
*/
public function testEmbeddedStyleBlockDoesNotAlterAutoTableColumnWidths(): void
{
// Regression: an embedded <style> block makes the CSS map non-empty,
// which triggers the final-tree re-cascade. That pass (1) reclassified
// the THEAD row as a body row in the table 'trids', and (2) left the
// serialized <cssarray> payload inside each cell's content, which the
// auto-layout weighting measured as visible text. Both skewed the
// computed column widths. The widths must be independent of any
// unrelated embedded style rules.
$obj = $this->getInternalTestObject();
$body =
'<table border="1" cellpadding="3" cellspacing="0">'
. '<thead><tr style="background-color:#cccccc"><th>#</th><th>Description</th><th>Amount</th></tr></thead>'
. '<tr><td>1</td><td>Lorem ipsum dolor sit amet</td><td>10</td></tr>'
. '<tr><td>2</td><td>Lorem ipsum dolor sit amet</td><td>20</td></tr>'
. '</table>';
$plain = $this->columnWidthsFromHTML($obj, $body);
$styled = $this->columnWidthsFromHTML($obj, '<style> .unused { color:red; } </style>' . $body);
$this->assertSame(
$plain,
$styled,
'Auto table column widths must not change when an unrelated embedded <style> block is present.',
);
// Pin the baseline so the narrow first column cannot silently collapse
// again: weights are the visible-text lengths of the first body row
// ("1" / "Lorem ipsum dolor sit amet" / "10") on top of the empty CSS
// map floor, distributed across the 360pt available width.
$this->assertSame([30.8571, 288.0, 41.1429], $plain);
}
/**
* Compute the auto-layout column widths for the first table found in $html.
*
* @return array<int, float>
*
* @throws \Throwable
*/
private function columnWidthsFromHTML(TestableHTML $obj, string $html): array
{
$dom = $obj->exposeGetHTMLDOM($html);
$tablekey = null;
$cols = 0;
foreach ($dom as $key => $node) {
if ($node['value'] !== 'table') {
continue;
}
$tablekey = (int) $key;
$cols = (int) $node['cols'];
break;
}
$this->assertNotNull($tablekey, 'Expected a table node in the DOM.');
$widths = $obj->exposeComputeHTMLTableColWidths($dom, $tablekey, $cols, 360.0);
return \array_map(static fn(float $w): float => \round($w, 4), $widths);
}
/**
* @throws \Throwable
*/
public function testMeasureHTMLCellDivExplicitHeightReservesSpaceWithoutBackground(): void
{
// Regression for https://github.com/tecnickcom/tc-lib-pdf/issues/225:
// an explicit CSS height on a block element was honored only when the
// block also declared its own background or border.
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$plain = $obj->exposeMeasureHTMLCellRenderedHeight('<div>x</div>', 0.0, 0.0, 100.0, 0.0);
$fixed = $obj->exposeMeasureHTMLCellRenderedHeight('<div style="height:50mm">x</div>', 0.0, 0.0, 100.0, 0.0);
$this->assertGreaterThan(0.0, $plain);
$this->assertGreaterThan($plain, $fixed);
$this->assertGreaterThanOrEqual(
50.0 - 0.001,
$fixed,
'A block with an explicit CSS height but no background/border must reserve at least that height.',
);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellPaintsDivBackgroundBehindTextInsideTableCell(): void
{
// Regression for https://github.com/tecnickcom/tc-lib-pdf/issues/225:
// the background of a styled DIV inside a table cell was emitted after
// the already-captured cell text, covering it.
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$out = $obj->getHTMLCell(
'<table cellpadding="3" cellspacing="0"><tr>'
. '<td><div style="color:blue; background-color:yellow; height:30mm;">Z</div></td>'
. '</tr></table>',
0,
0,
100,
0,
);
$fillpos = \strpos($out, '1.000000 1.000000 0.000000 rg');
$textpos = \strpos($out, '(Z)');
$this->assertNotFalse($fillpos, 'The yellow DIV background fill must be present in the output.');
$this->assertNotFalse($textpos, 'The DIV text content must be present in the output.');
$this->assertLessThan(
$textpos,
$fillpos,
'The DIV background fill must be painted before (behind) its text content.',
);
}
/**
* @throws \Throwable
*/
public function testEstimateHTMLTableRowHeightAccountsForNestedDivExplicitHeight(): void
{
// Regression for https://github.com/tecnickcom/tc-lib-pdf/issues/225:
// row-height estimation ignored the explicit CSS height of block
// elements nested in the cells, so rows containing fixed-height DIVs
// started near the page bottom and were split across pages.
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = $obj->exposeGetHTMLDOM('<table><tr><td><div style="height:60mm">x</div></td></tr></table>');
$trkey = -1;
foreach ($dom as $key => $elm) {
if (!$elm['tag'] || !$elm['opening'] || $elm['value'] !== 'tr') {
continue;
}
$trkey = $key;
break;
}
$this->assertGreaterThan(0, $trkey);
$rowheight = $obj->exposeEstimateHTMLTableRowHeightWithDom($dom, $trkey);
$this->assertGreaterThanOrEqual(
60.0 - 0.001,
$rowheight,
'Row height estimation must account for the explicit CSS height of nested block elements.',
);
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMDoesNotLeakEmptyPseudoClassStylesToSiblings(): void
{
// The streaming DOM pass evaluates :empty before children are parsed,
// so every <p> initially matches; the final-tree recompute pass must
// clear the stale style-derived height from the non-empty siblings.
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = $obj->exposeGetHTMLDOM(
'<style>p:empty { height: 25mm; background-color: #ffee00; }</style>'
. '<div><p></p><p class="plain">plain one</p><p class="plain">plain two</p></div>',
);
$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 <p> 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(
'<table><tr>'
. '<td width="160" style="border:1px solid black">Wide</td>'
. '<td style="border:1px solid black">Narrow</td>'
. '</tr></table>',
0,
0,
60,
20,
);
$domObj = $this->getInternalTestObject();
$this->initFontAndPage($domObj);
$dom = $domObj->exposeGetHTMLDOM('<table><tr><td width="160">Wide</td><td>Narrow</td></tr></table>');
$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(
'<table><tr><td style="border:1px solid black">A</td></tr>'
. '<tr><td style="border:1px solid black">B</td></tr></table>',
0,
0,
30,
30,
);
$withSpacing = $obj->getHTMLCell(
'<table cellspacing="5"><tr><td style="border:1px solid black">A</td></tr>'
. '<tr><td style="border:1px solid black">B</td></tr></table>',
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(
'<table cellpadding="5"><tr><td style="border:1px solid black">X</td></tr></table>',
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(
'<table cellspacing="3" cellpadding="4"><tr><td>A</td></tr></table>',
0,
0,
30,
20,
);
$withBorder = $obj->getHTMLCell(
'<table border="1" cellspacing="3" cellpadding="4"><tr><td>A</td></tr></table>',
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(
'<table border="1" cellspacing="3"><tr><td style="border:1px solid black">A</td></tr></table>',
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(
'<table border="1" style="border-spacing:3 0"><tr><td style="border:1px solid black">A</td></tr></table>',
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(
'<table><tr><td style="border:1px solid black">A</td></tr>'
. '<tr><td style="border:1px solid black">B</td></tr></table>',
0,
0,
30,
30,
);
$withSpacing = $obj->getHTMLCell(
'<table style="border-spacing:0 5"><tr><td style="border:1px solid black">A</td></tr>'
. '<tr><td style="border:1px solid black">B</td></tr></table>',
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(
'<table cellspacing="0"><tr><td style="border:1px solid black" valign="top">A</td>'
. '<td style="border:1px solid black">B<br/>C<br/>D</td></tr></table>',
0,
0,
40,
30,
);
$bottomOut = $obj->getHTMLCell(
'<table cellspacing="0"><tr><td style="border:1px solid black" valign="bottom">A</td>'
. '<td style="border:1px solid black">B<br/>C<br/>D</td></tr></table>',
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(
'<table cellspacing="0"><tr><td style="border:1px solid black;vertical-align:top">A</td>'
. '<td style="border:1px solid black">B<br/>C<br/>D</td></tr></table>',
0,
0,
40,
30,
);
$bottomOut = $obj->getHTMLCell(
'<table cellspacing="0"><tr><td style="border:1px solid black;vertical-align:bottom">A</td>'
. '<td style="border:1px solid black">B<br/>C<br/>D</td></tr></table>',
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(
'<table cellspacing="0"><tr><td style="border:1px solid black;vertical-align:top" valign="bottom">A</td>'
. '<td style="border:1px solid black">B<br/>C<br/>D</td></tr></table>',
0,
0,
40,
30,
);
$cssBottomOut = $obj->getHTMLCell(
'<table cellspacing="0"><tr><td style="border:1px solid black;vertical-align:bottom" valign="top">A</td>'
. '<td style="border:1px solid black">B<br/>C<br/>D</td></tr></table>',
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(
'<table cellspacing="0">'
. '<tr><td rowspan="2" style="border:1px solid black" valign="top">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D<br/>E</td></tr>'
. '</table>',
0,
0,
40,
40,
);
$bottomOut = $obj->getHTMLCell(
'<table cellspacing="0">'
. '<tr><td rowspan="2" style="border:1px solid black" valign="bottom">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D<br/>E</td></tr>'
. '</table>',
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(
'<table cellspacing="0"><tr><td style="border:1px solid black" valign="top">A</td>'
. '<td style="border:1px solid black">B<br/>C<br/>D</td></tr></table>',
0,
0,
40,
30,
);
$middleOut = $obj->getHTMLCell(
'<table cellspacing="0"><tr><td style="border:1px solid black" valign="middle">A</td>'
. '<td style="border:1px solid black">B<br/>C<br/>D</td></tr></table>',
0,
0,
40,
30,
);
$bottomOut = $obj->getHTMLCell(
'<table cellspacing="0"><tr><td style="border:1px solid black" valign="bottom">A</td>'
. '<td style="border:1px solid black">B<br/>C<br/>D</td></tr></table>',
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(
'<table cellspacing="0">'
. '<tr><td rowspan="2" style="border:1px solid black" valign="top">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D<br/>E</td></tr>'
. '</table>',
0,
0,
40,
40,
);
$middleOut = $obj->getHTMLCell(
'<table cellspacing="0">'
. '<tr><td rowspan="2" style="border:1px solid black" valign="middle">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D<br/>E</td></tr>'
. '</table>',
0,
0,
40,
40,
);
$bottomOut = $obj->getHTMLCell(
'<table cellspacing="0">'
. '<tr><td rowspan="2" style="border:1px solid black" valign="bottom">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D<br/>E</td></tr>'
. '</table>',
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' =>
'<style>'
. '.vc-box{width:150mm;height:90mm;border:0.6mm solid #003366;background-color:#e9f2ff;}'
. '.vc-fill{width:100%;height:100%;}'
. '.vc-content{vertical-align:middle;text-align:center;color:#002244;}'
. '</style>'
. '<div class="vc-box">'
. '<table class="vc-fill" border="0" cellpadding="0" cellspacing="0">'
. '<tr><td class="vc-content">'
. '<h1>Vertically Centered Block</h1>'
. '<p>This content is centered in the middle of the box.</p>'
. '<p><b>HTML:</b> headings, paragraphs, and inline formatting.</p>'
. '</td></tr></table></div>',
],
[
'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' =>
'<table border="1" cellpadding="0" cellspacing="0" '
. 'style="width:150mm;height:100mm;background-color:blue;border-color:#003366;">'
. '<tr><td style="height:100mm;vertical-align:middle;text-align:center;color:cmyk(100,0,0,0);">'
. '<h1>Vertically Centered Block</h1>'
. '<p>This content is centered in the middle of the box.</p>'
. '<p><b>HTML:</b> headings, paragraphs, and inline formatting.</p>'
. '</td></tr></table>',
],
];
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(
'<table cellspacing="0">'
. '<tr><td rowspan="3" style="border:1px solid black" valign="top">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D</td></tr>'
. '<tr><td style="border:1px solid black">E<br/>F<br/>G<br/>H</td></tr>'
. '</table>',
0,
0,
40,
50,
);
$middleOut = $obj->getHTMLCell(
'<table cellspacing="0">'
. '<tr><td rowspan="3" style="border:1px solid black" valign="middle">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D</td></tr>'
. '<tr><td style="border:1px solid black">E<br/>F<br/>G<br/>H</td></tr>'
. '</table>',
0,
0,
40,
50,
);
$bottomOut = $obj->getHTMLCell(
'<table cellspacing="0">'
. '<tr><td rowspan="3" style="border:1px solid black" valign="bottom">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D</td></tr>'
. '<tr><td style="border:1px solid black">E<br/>F<br/>G<br/>H</td></tr>'
. '</table>',
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(
'<table cellspacing="0">'
. '<tr><td rowspan="2" style="border:1px solid black;vertical-align:middle" valign="bottom">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D<br/>E</td></tr>'
. '</table>',
0,
0,
40,
40,
);
$attrBottomOut = $obj->getHTMLCell(
'<table cellspacing="0">'
. '<tr><td rowspan="2" style="border:1px solid black" valign="bottom">A</td>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D<br/>E</td></tr>'
. '</table>',
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(
'<table cellspacing="0">'
. '<tr><th rowspan="2" style="border:1px solid black" valign="top">A</th>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D<br/>E</td></tr>'
. '</table>',
0,
0,
40,
40,
);
$bottomOut = $obj->getHTMLCell(
'<table cellspacing="0">'
. '<tr><th rowspan="2" style="border:1px solid black" valign="bottom">A</th>'
. '<td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C<br/>D<br/>E</td></tr>'
. '</table>',
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(
'<table border="1" style="border-collapse:collapse"><tr>'
. '<td style="border:1px solid black">A</td></tr></table>',
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(
'<table border="1" style="border-collapse:collapse;border-spacing:3 5"><tr>'
. '<td style="border:1px solid black">A</td></tr></table>',
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(
'<table><tr><td style="border:1px solid black">A</td><td style="border:1px solid black">B</td></tr>'
. '<tr><td style="border:1px solid black">C</td><td style="border:1px solid black">D</td></tr></table>',
0,
0,
40,
30,
);
$withSpacing = $obj->getHTMLCell(
'<table style="border-spacing:3 5"><tr><td style="border:1px solid black">A</td>'
. '<td style="border:1px solid black">B</td></tr><tr><td style="border:1px solid black">C</td>'
. '<td style="border:1px solid black">D</td></tr></table>',
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('<form>A</form>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 = '<head><style>p{color:red;}</style></head><p>A</p><script>x</script>';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringNotContainsString('<head', $out);
$this->assertStringNotContainsString('<style', $out);
$this->assertStringContainsString('<p>', $out);
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLNormalizesSelectTextareaAndImgBlocks(): void
{
$obj = $this->getInternalTestObject();
$html =
'<select><option value="v1">Alpha</option><option>Beta</option></select>'
. '<textarea>x"y'
. "\n"
. 'z</textarea><img src="a.png"> tail';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<select opt="v1#!TaB!#Alpha#!NwL!#Beta" />', $out);
$this->assertStringContainsString('<textarea value="x\'\'y', $out);
$this->assertStringContainsString('z" />', $out);
$this->assertStringContainsString('<img src="a.png"><span><marker style="font-size:0"/></span>', $out);
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLPreservesPreNewlinesAndSpaces(): void
{
$obj = $this->getInternalTestObject();
$html = '<pre>line1' . "\n" . ' line2</pre>';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<pre>', $out);
$this->assertStringContainsString('line1', $out);
$this->assertStringContainsString(' ', $out);
$this->assertStringContainsString('line2', $out);
$this->assertStringContainsString('</pre>', $out);
}
/**
* @throws \Throwable
*/
public function testGetHTMLRootPropertiesIncludesExpectedDefaults(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$root = $obj->exposeGetHTMLRootProperties();
$this->assertArrayHasKey('fontname', $root);
$this->assertArrayHasKey('fontsize', $root);
$this->assertArrayHasKey('padding', $root);
$this->assertArrayHasKey('margin', $root);
$this->assertSame('black', $root['fgcolor']);
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMBuildsRootAndTagNodes(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = $obj->exposeGetHTMLDOM('<p class="x">Hello</p>');
$this->assertArrayHasKey(0, $dom);
$this->assertNotEmpty($dom);
$this->assertGreaterThanOrEqual(2, \count($dom));
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMParsesQuotedUnquotedAndBooleanOpeningTagAttributes(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = $obj->exposeGetHTMLDOM(
'<input type=text data-one="alpha" data-two=beta readonly required custom-flag />',
);
$input = null;
foreach ($dom as $node) {
if (!($node['value'] === 'input' && !empty($node['opening']))) {
continue;
}
$input = $node;
break;
}
$this->assertIsArray($input);
/** @var array{attribute: array<string, string>} $input */
$this->assertSame('text', $input['attribute']['type'] ?? null);
$this->assertSame('alpha', $input['attribute']['data-one'] ?? null);
$this->assertSame('beta', $input['attribute']['data-two'] ?? null);
$this->assertSame('true', $input['attribute']['readonly'] ?? null);
$this->assertSame('true', $input['attribute']['required'] ?? null);
$this->assertSame('true', $input['attribute']['custom-flag'] ?? null);
}
/**
* @throws \Throwable
*/
public function testProcessHTMLDOMClosingTagStoresTableHeadAndNoBrAttribute(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$root = $obj->exposeGetHTMLRootProperties();
$dom = [
0 => \array_replace($root, ['value' => 'table', 'elkey' => 0, 'parent' => 0, 'thead' => '']),
1 => \array_replace($root, [
'value' => 'tr',
'elkey' => 1,
'parent' => 0,
'thead' => 'true',
'attribute' => [],
]),
2 => \array_replace($root, ['value' => 'tr', 'elkey' => 2, 'parent' => 1]),
];
/** @var array<int, THTMLAttrib> $dom */
$elm = ['<table>', '<tr>', '</tr>'];
$obj->exposeProcessHTMLDOMClosingTag($dom, $elm, 2, 1, '<cssarray>x</cssarray>');
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertSame('true', $this->getHtmlNodeAttrString($dom[1], 'nobr'));
assert(isset($dom[0]), "\$dom[0] must be set");
$this->assertStringContainsString('<cssarray>x</cssarray><table>', $dom[0]['thead']);
}
/**
* @throws \Throwable
*/
public function testGetHTMLliBulletReturnsEmptyForCaretType(): void
{
$obj = $this->getInternalTestObject();
$out = $obj->exposeGetHTMLliBullet(1, 1, 0, 0, '^');
$this->assertSame('', $out);
}
/**
* @throws \Throwable
*/
public function testGetHTMLliBulletSupportsDefaultUnorderedAndOrderedTypes(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$defaultUnordered = $obj->exposeGetHTMLliBullet(1, 1, 0, 0, '!');
$defaultOrdered = $obj->exposeGetHTMLliBullet(2, 12, 0, 0, '#');
$this->assertNotSame('', $defaultUnordered);
$this->assertNotSame('', $defaultOrdered);
$this->assertNotSame($defaultUnordered, $defaultOrdered);
}
/**
* @throws \Throwable
*/
public function testGetHTMLliBulletSupportsOrderedFormatVariants(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$decimalLeadingZero = $obj->exposeGetHTMLliBullet(1, 7, 0, 0, 'decimal-leading-zero');
$upperRoman = $obj->exposeGetHTMLliBullet(1, 7, 0, 0, 'upper-roman');
$upperAlpha = $obj->exposeGetHTMLliBullet(1, 7, 0, 0, 'upper-alpha');
$this->assertNotSame('', $decimalLeadingZero);
$this->assertNotSame('', $upperRoman);
$this->assertNotSame('', $upperAlpha);
$this->assertNotSame($decimalLeadingZero, $upperRoman);
$this->assertNotSame($upperRoman, $upperAlpha);
}
/**
* @throws \Throwable
*/
public function testGetHTMLliBulletRomanTypesDoNotFallbackToDecimal(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$decimal = $obj->exposeGetHTMLliBullet(1, 5, 0, 0, 'decimal');
$lowerRoman = $obj->exposeGetHTMLliBullet(1, 5, 0, 0, 'lower-roman');
$upperRoman = $obj->exposeGetHTMLliBullet(1, 5, 0, 0, 'upper-roman');
$this->assertNotSame('', $decimal);
$this->assertNotSame('', $lowerRoman);
$this->assertNotSame('', $upperRoman);
$this->assertNotSame($decimal, $lowerRoman);
$this->assertNotSame($decimal, $upperRoman);
$this->assertNotSame($lowerRoman, $upperRoman);
}
/**
* @throws \Throwable
*/
public function testPageBreakReturnsCurrentOrNextPageId(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$before = $page->getPageId();
$after = $obj->exposePageBreak();
$this->assertGreaterThanOrEqual($before, $after);
}
/**
* @throws \Throwable
*/
public function testProcessHTMLDOMTextAppliesTransformAndDecodesEntities(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['text-transform' => 'uppercase']),
1 => $this->makeHtmlNode(['value' => '']),
];
$obj->exposeProcessHTMLDOMText($dom, 'a&b', 1, 0);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertSame('A&B', $dom[1]['value']);
}
/**
* @throws \Throwable
*/
public function testProcessHTMLDOMTextAppliesMappedCaseTransform(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['text-transform' => 'lowercase']),
1 => $this->makeHtmlNode(['value' => '']),
];
$obj->exposeProcessHTMLDOMText($dom, 'AB&NBSP;C', 1, 0);
assert(isset($dom[1]), "\$dom[1] must be set");
$value = $dom[1]['value'];
$this->assertStringStartsWith('ab', $value);
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMCSSDataSkipsInheritedAndInvalidSelectors(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root', 'csssel' => [' p.x']]),
1 => $this->makeHtmlNode([
'value' => 'p',
'parent' => 0,
'attribute' => ['class' => 'x'],
]),
];
$css = [
'0010 p.x' => 'color:red;',
'badselector' => 'color:blue;',
];
$obj->getHTMLDOMCSSData($dom, $css, 1);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertEmpty($dom[1]['cssdata']);
}
/**
* @throws \Throwable
*/
public function testIsValidCSSSelectorForTagCoversAttributeOperatorsAndCombinators(): 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' => 'main', 'class' => 'container'],
]),
2 => $this->makeHtmlNode([
'value' => 'p',
'parent' => 1,
'tag' => true,
'opening' => true,
'attribute' => ['class' => 'sib'],
]),
3 => $this->makeHtmlNode([
'value' => 'span',
'parent' => 1,
'tag' => true,
'opening' => true,
'attribute' => [
'class' => 'x y',
'id' => 'node',
'words' => 'foo bar',
'data' => 'prefix-mid-suffix',
'lang' => 'en-us',
],
]),
];
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span.x'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span[words~=foo]'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span[data^=prefix]'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span[data$=suffix]'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span[data*=mid]'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span[lang|=en]'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span[id=node]'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' div > span.x'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' p + span.x'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' p ~ span.x'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 3, ' span:hover'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 3, '['));
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesCoversExtendedCssBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = [
0 => $this->makeHtmlNode([
'line-height' => 1.2,
'fontsize' => 10.0,
'listtype' => 'disc',
'font-stretch' => 100.0,
'letter-spacing' => 0.0,
'text-indent' => 2.0,
]),
1 => $this->makeHtmlNode([
'parent' => 0,
'value' => 'a',
'fontsize' => 10.0,
'fontstyle' => 'B',
'attribute' => [
'style' =>
'direction:rtl;display:none;font-family:helvetica;list-style-type:inherit;'
. 'text-indent:3mm;text-transform:capitalize;font-size:12;font-stretch:120;'
. 'letter-spacing:0.2;line-height:2;font-weight:normal;font-style:italic;'
. 'color:red;background-color:#00ff00;text-decoration:underline line-through overline;'
. 'width:20;height:10;text-align:right;padding:1 2 3 4;margin:1 2 3 4;'
. 'border:1 solid black;border-color:red green blue black;border-width:1 2 3 4;'
. 'border-style:solid dashed dotted double;padding-left:1;padding-right:2;'
. 'padding-top:3;padding-bottom:4;margin-left:auto;margin-right:2;'
. 'margin-top:1;margin-bottom:3;border-left:1 solid #111;border-right:2 dashed #222;'
. 'border-top:3 dotted #333;border-bottom:4 double #444;border-spacing:2;'
. 'page-break-inside:avoid;page-break-before:left;page-break-after:right;',
],
]),
];
$obj->parseHTMLStyleAttributes($dom, 1, 0);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertSame('rtl', $dom[1]['dir']);
$this->assertSame('none', $dom[1]['display']);
$this->assertFalse($dom[1]['block']);
$this->assertTrue($dom[1]['hide']);
$this->assertSame('disc', $dom[1]['listtype']);
$this->assertNotSame('', $dom[1]['text-transform']);
$this->assertGreaterThan(0.0, $dom[1]['fontsize']);
$this->assertGreaterThan(0.0, $dom[1]['line-height']);
$this->assertNotSame('', $dom[1]['fgcolor']);
$this->assertNotSame('', $dom[1]['bgcolor']);
$this->assertSame('R', $dom[1]['align']);
$this->assertSame('true', $this->getHtmlNodeAttrString($dom[1], 'nobr'));
$this->assertSame('left', $this->getHtmlNodeAttrString($dom[1], 'pagebreak'));
$this->assertSame('right', $this->getHtmlNodeAttrString($dom[1], 'pagebreakafter'));
/** @var array{H: float, V: float} $borderSpacing */
$borderSpacing = \array_replace(['H' => 0.0, 'V' => 0.0], $dom[1]['border-spacing'] ?? []);
$this->assertGreaterThan(0.0, $borderSpacing['H']);
$this->assertSame($borderSpacing['H'], $borderSpacing['V']);
$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']);
$this->assertNotEmpty($dom[1]['border']);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesParsesBorderSpacingAxes(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = [
0 => $this->makeHtmlNode(),
1 => $this->makeHtmlNode([
'parent' => 0,
'attribute' => ['style' => 'border-spacing:3 6;'],
]),
];
$obj->parseHTMLStyleAttributes($dom, 1, 0);
/** @var array{H: float, V: float} $borderSpacing */
$borderSpacing = \array_replace(['H' => 0.0, 'V' => 0.0], $dom[1]['border-spacing'] ?? []);
$this->assertGreaterThan(0.0, $borderSpacing['H']);
$this->assertEqualsWithDelta($borderSpacing['H'] * 2.0, $borderSpacing['V'], 0.0001);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesBorderSpacingInheritApplied(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = [
0 => $this->makeHtmlNode(),
1 => $this->makeHtmlNode([
'parent' => 0,
'attribute' => ['style' => 'border-spacing:3 6;'],
]),
2 => $this->makeHtmlNode([
'parent' => 1,
'attribute' => ['style' => 'border-spacing:InHeRiT;'],
]),
];
$obj->parseHTMLStyleAttributes($dom, 1, 0);
$obj->parseHTMLStyleAttributes($dom, 2, 1);
/** @var array{H: float, V: float} $parentSpacing */
$parentSpacing = \array_replace(['H' => 0.0, 'V' => 0.0], $dom[1]['border-spacing'] ?? []);
/** @var array{H: float, V: float} $childSpacing */
$childSpacing = \array_replace(['H' => 0.0, 'V' => 0.0], $dom[2]['border-spacing'] ?? []);
$this->assertSame($parentSpacing, $childSpacing);
}
/**
* @throws \Throwable
*/
public function testInheritHTMLPropertiesMergesParentDefaults(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['align' => 'L', 'fontname' => 'helvetica']),
1 => $this->makeHtmlNode(['align' => 'R']),
];
$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']);
}
/**
* @throws \Throwable
*/
public function testProcessHTMLDOMOpeningTagMarksNodeAsOpening(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$root = $obj->exposeGetHTMLRootProperties();
$root['parent'] = 0;
$root['value'] = 'root';
$dom = [
0 => $root,
1 => $root,
];
$dom[1]['parent'] = 0;
$dom[1]['value'] = 'p';
$obj->exposeProcessHTMLDOMOpeningTag($dom, [], [0], 'p', 1, false);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertTrue($dom[1]['opening']);
}
/**
* @throws \Throwable
*/
public function testProcessHTMLDOMClosingTagSetsParentContent(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$root = $obj->exposeGetHTMLRootProperties();
$root['parent'] = 0;
$dom = [
0 => $root,
1 => $root,
];
$dom[1]['value'] = 'p';
$dom[1]['parent'] = 0;
$elm = ['<p>', '</p>'];
$obj->exposeProcessHTMLDOMClosingTag($dom, $elm, 1, 0, '');
assert(isset($dom[0]), "\$dom[0] must be set");
$this->assertArrayHasKey('content', $dom[0]);
}
/**
* @throws \Throwable
*/
public function testProcessHTMLDOMClosingTagHandlesTdContentAndNestedTableHeaderCleanup(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$root = $obj->exposeGetHTMLRootProperties();
$dom = [
0 => \array_replace($root, ['value' => 'table', 'elkey' => 0, 'parent' => 0]),
1 => \array_replace($root, ['value' => 'tr', 'elkey' => 1, 'parent' => 0]),
2 => \array_replace($root, ['value' => 'td', 'elkey' => 2, 'parent' => 1, 'content' => '']),
3 => \array_replace($root, ['value' => '', 'elkey' => 3, 'parent' => 2, 'tag' => false]),
4 => \array_replace($root, ['value' => 'td', 'elkey' => 4, 'parent' => 2]),
];
/** @var array<int, THTMLAttrib> $dom */
$elm = [
'<table>',
'<tr>',
'<td>',
'<table><thead>A</thead></table>',
'</td>',
];
$obj->exposeProcessHTMLDOMClosingTag($dom, $elm, 4, 2, '<cssarray>x</cssarray>');
assert(isset($dom[2]), "\$dom[2] must be set");
$this->assertStringContainsString('<table nested="true">', $dom[2]['content']);
$this->assertStringNotContainsString('<thead>', $dom[2]['content']);
$this->assertStringNotContainsString('</thead>', $dom[2]['content']);
$dom2 = [
0 => \array_replace($root, [
'value' => 'root',
'elkey' => 0,
'parent' => 0,
'thead' => '<tr nobr="true"></tr>',
]),
1 => \array_replace($root, ['value' => 'table', 'elkey' => 1, 'parent' => 0]),
];
/** @var array<int, THTMLAttrib> $dom2 */
$elm = ['<root>', '</table>'];
$obj->exposeProcessHTMLDOMClosingTag($dom2, $elm, 1, 0, '');
assert(isset($dom2[0]), "\$dom2[0] must be set");
$this->assertStringNotContainsString(' nobr="true"', $dom2[0]['thead']);
$this->assertStringEndsWith('</tablehead>', $dom2[0]['thead']);
}
/**
* @throws \Throwable
*/
public function testProcessHTMLDOMOpeningTagMergesCssAndDetectsSelfClosingTags(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$root = $obj->exposeGetHTMLRootProperties();
$dom = [
0 => \array_replace($root, ['parent' => 0, 'value' => 'root']),
1 => \array_replace($root, ['parent' => 0, 'value' => 'img']),
];
/** @var array<int, THTMLAttrib> $typedDom */
$typedDom = $dom;
$obj->exposeProcessHTMLDOMOpeningTag($typedDom, ['0010 *' => 'color:red;'], [0], '<img src="x" />', 1, false);
assert(isset($typedDom[1]), "\$typedDom[1] must be set");
$this->assertTrue($typedDom[1]['self']);
$attr = $typedDom[1]['attribute'];
$src = $attr['src'] ?? null;
$this->assertIsString($src);
$this->assertSame('x', $src);
}
/**
* @throws \Throwable
*/
public function testParseHTMLTextRendersTextAndAdvancesCursor(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$elm = $this->makeHtmlNode(['value' => 'x']);
$tpx = 1.0;
$tpy = 2.0;
$tpw = 3.0;
$tph = 4.0;
$out = $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph);
$this->assertNotSame('', $out);
$this->assertStringContainsString('BT', $out);
$this->assertGreaterThan(1.0, $tpx);
}
/**
* @throws \Throwable
*/
public function testParseHTMLTextWrapsInlineContentFromLineOrigin(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->exposeInitHTMLCellContext(0.0, 0.0, 30.0, 60.0);
$elm = $this->makeHtmlNode([
'fontstyle' => 'UD',
'value' => 'underline and line-trough',
]);
$tpx = 10.0;
$tpy = 0.0;
$tpw = 20.0;
$tph = 60.0;
$out = $obj->exposeParseHTMLText($elm, $tpx, $tpy, $tpw, $tph);
$this->assertNotSame('', $out);
$matches = [];
$numMatches = \preg_match_all('/([0-9]+\.[0-9]+) ([0-9]+\.[0-9]+) Td /', $out, $matches);
$this->assertIsInt($numMatches);
$this->assertGreaterThanOrEqual(2, $numMatches);
assert(isset($matches[1]), "\$matches[1] must be set");
$matchX = $matches[1][0] ?? null;
$this->assertIsString($matchX);
$this->assertGreaterThan(0.0, \floatval($matchX));
assert(isset($matches[2]), "\$matches[2] must be set");
$matchY = $matches[2][0] ?? null;
$this->assertIsString($matchY);
$this->assertGreaterThan(0.0, \floatval($matchY));
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMTextNodesInheritParentFormatting(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = $obj->exposeGetHTMLDOM('<span style="color:red;font-weight:bold">Hello</span>');
assert(isset($dom[1]), "\$dom[1] must be set");
assert(isset($dom[2]), "\$dom[2] must be set");
$this->assertSame($dom[1]['fgcolor'], $dom[2]['fgcolor']);
$this->assertStringContainsString('B', $dom[2]['fontstyle']);
$this->assertSame('Hello', $dom[2]['value']);
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMRecomputePreservesInlineStyleWithoutMatchingSelector(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = $obj->exposeGetHTMLDOM('<style>.other{color:red;}</style><div style="font-weight:bold">Hello</div>');
assert(isset($dom[1]), "\$dom[1] must be set");
$style = $this->getHtmlNodeAttrString($dom[1], 'style');
$this->assertIsString($style);
$this->assertStringContainsString('font-weight:bold', $style);
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMRecomputeMergesMatchedCssWithInlineStyle(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = $obj->exposeGetHTMLDOM('<style>.demo{color:red;text-align:center;}</style>'
. '<div class="demo" style="font-weight:bold">Hello</div>');
assert(isset($dom[1]), "\$dom[1] must be set");
$style = $this->getHtmlNodeAttrString($dom[1], 'style');
$this->assertIsString($style);
$this->assertStringContainsString('color:red', $style);
$this->assertStringContainsString('text-align:center', $style);
$this->assertStringContainsString('font-weight:bold', $style);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesKeepsRawFontFamilyValue(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$dom = [
0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontname' => 'helvetica']),
1 => $this->makeHtmlNode([
'parent' => 0,
'fontsize' => 10.0,
'attribute' => ['style' => 'font-family:times, serif;'],
]),
];
$obj->parseHTMLStyleAttributes($dom, 1, 0);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertSame('times, serif', $dom[1]['fontname']);
}
/**
* @throws \Throwable
*/
public function testParseHTMLAttributesKeepsRawFontFaceValue(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$dom = [
0 => $this->makeHtmlNode(['fontsize' => 10.0, 'fontname' => 'helvetica']),
1 => $this->makeHtmlNode([
'value' => 'font',
'parent' => 0,
'attribute' => ['face' => 'times, serif'],
'style' => [],
'fontstyle' => '',
'fontsize' => 10.0,
]),
];
$obj->parseHTMLAttributes($dom, 1, false);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertSame('times, serif', $dom[1]['fontname']);
}
/**
* @throws \Throwable
*/
public function testGetHTMLliBulletNoneAndCustomImageTypeBranches(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$this->assertSame('', $obj->exposeGetHTMLliBullet(1, 1, 0, 0, 'none'));
$this->expectException(\Throwable::class);
$obj->exposeGetHTMLliBullet(1, 1, 0, 0, 'img|png|4|4|missing.png');
}
/**
* @throws \Throwable
*/
public function testGetHTMLliBulletCoversUnicodeAndAdditionalOrderedTypes(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'isunicode', false);
$this->setObjectProperty($obj, 'rtl', false);
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'disc'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'circle'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'square'));
$this->setObjectProperty($obj, 'isunicode', true);
$this->setObjectProperty($obj, 'rtl', true);
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'disc'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'circle'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'square'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'lower-greek'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'hebrew'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'armenian'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'georgian'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'hiragana'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'hiragana-iroha'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'katakana'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'katakana-iroha'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'lower-latin'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'upper-latin'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, '1'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'decimal'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'decimal-leading-zero'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'lower-roman'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'upper-roman'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'lower-alpha'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'upper-alpha'));
$this->assertNotSame('', $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'fallback-type'));
$svg = (string) \realpath(__DIR__ . '/../examples/images/testsvg.svg.bak');
if ($svg !== '') {
try {
$out = $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'img|svg|4|4|' . $svg);
$this->assertNotSame('', $out);
} catch (\Throwable $e) {
$this->assertInstanceOf(\Throwable::class, $e);
}
}
}
/**
* @throws \Throwable
*/
public function testGetHTMLliBulletFallbackShapesAlignToFontBoxWithBaselineInput(): void
{
$obj = $this->getInternalTestObject();
$page = $this->initFontAndPage($obj);
$this->setObjectProperty($obj, 'isunicode', false);
$this->setObjectProperty($obj, 'rtl', false);
/** @var \Com\Tecnick\Pdf\Font\Stack $fontstack */
$fontstack = $this->getObjectProperty($obj, 'font');
/** @var array<string, mixed> $font */
$font = $fontstack->getCurrentFont();
$ascent = \is_numeric($font['ascent'] ?? null) ? (float) $font['ascent'] : 0.0;
$pageHeightRaw = $page['height'];
$fontHeight = \is_numeric($font['height'] ?? null) ? (float) $font['height'] : 0.0;
$fontSizeRaw = \is_numeric($font['usize'] ?? null) ? (float) $font['usize'] : 0.0;
$baseline = $obj->toUnit($ascent);
$pageHeight = $obj->toPoints($pageHeightRaw);
$sizePt = $obj->toPoints($fontSizeRaw);
$discOut = $obj->exposeGetHTMLliBullet(1, 2, 0, $baseline, 'disc');
$discMatch = [];
$this->assertMatchesRegularExpression('/\\n-?\\d+\\.\\d+\\s+(-?\\d+\\.\\d+)\\s+m\\n/', $discOut);
$this->assertSame(1, \preg_match('/\\n-?\\d+\\.\\d+\\s+(-?\\d+\\.\\d+)\\s+m\\n/', $discOut, $discMatch));
assert(isset($discMatch[1]), "\$discMatch[1] must be set");
$this->assertEqualsWithDelta($pageHeight - ($fontHeight / 2), \floatval($discMatch[1]), 0.001);
$circleOut = $obj->exposeGetHTMLliBullet(1, 2, 0, $baseline, 'circle');
$circleMatch = [];
$this->assertSame(1, \preg_match('/\\n-?\\d+\\.\\d+\\s+(-?\\d+\\.\\d+)\\s+m\\n/', $circleOut, $circleMatch));
assert(isset($circleMatch[1]), "\$circleMatch[1] must be set");
$this->assertEqualsWithDelta($pageHeight - ($fontHeight / 2), \floatval($circleMatch[1]), 0.001);
$squareOut = $obj->exposeGetHTMLliBullet(1, 2, 0, $baseline, 'square');
$squarePattern = '/\\n-?\\d+\\.\\d+\\s+(-?\\d+\\.\\d+)\\s+-?\\d+\\.\\d+\\s+-?\\d+\\.\\d+\\s+re\\n/';
$squareMatch = [];
$this->assertSame(1, \preg_match($squarePattern, $squareOut, $squareMatch));
$squareTop = ($fontHeight - ($sizePt / 2)) / 2;
assert(isset($squareMatch[1]), "\$squareMatch[1] must be set");
$this->assertEqualsWithDelta($pageHeight - $squareTop, \floatval($squareMatch[1]), 0.001);
}
/**
* @throws \Throwable
*/
public function testGetHTMLliBulletRendersSvgImageBullet(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$svg = (string) realpath(__DIR__ . '/../examples/images/testsvg.svg');
$this->assertNotSame('', $svg);
try {
$svgOut = $obj->exposeGetHTMLliBullet(1, 2, 0, 0, 'img|svg|4|4|' . $svg);
$this->assertNotSame('', $svgOut);
} catch (\Throwable $e) {
$this->assertInstanceOf(\Throwable::class, $e);
}
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellCoversHiddenNodesAndPageBreakModes(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$html =
'<img src="x" style="display:none" />'
. '<div style="display:none"><span>skip</span></div>'
. '<p style="page-break-before:right">R</p>'
. '<p style="page-break-before:always">A</p>';
$out = $obj->getHTMLCell($html, 0, 0, 20, 6);
$this->assertNotSame('', $out);
$this->assertStringContainsString('(R)', $out);
$this->assertStringContainsString('(A)', $out);
$this->assertStringNotContainsString('skip', $out);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellCoversPageBreakAfterModes(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$html = '<p style="page-break-after:right">R</p><p style="page-break-after:always">A</p><p>Z</p>';
$out = $obj->getHTMLCell($html, 0, 0, 20, 6);
$this->assertNotSame('', $out);
$this->assertStringContainsString('(R)', $out);
$this->assertStringContainsString('(A)', $out);
$this->assertStringContainsString('(Z)', $out);
}
/**
* @throws \Throwable
*/
public function testAddHTMLCellNamedPageAtTopDoesNotInsertExtraBreak(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$beforePages = \count($page->getPages());
$html = '<div style="page:chapter">Chapter Start</div><p>Body text</p>';
$obj->addHTMLCell($html, 20, 10, 150, 0);
$afterPages = \count($page->getPages());
$this->assertSame($beforePages, $afterPages);
}
/**
* @throws \Throwable
*/
public function testAddHTMLCellNamedPageChangeMidFlowBreaksBeforeBlock(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Page\Page $page */
$page = $this->getObjectProperty($obj, 'page');
$beforePages = \count($page->getPages());
$html =
'<p>Lead section on default page.</p>'
. '<div style="page:chapter">Chapter section</div>'
. '<p>Chapter tail.</p>';
$obj->addHTMLCell($html, 20, 10, 150, 0);
$afterPages = \count($page->getPages());
$this->assertGreaterThan($beforePages, $afterPages);
}
/**
* @throws \Throwable
*/
public function testAddHTMLCellNamedPageSwitchPreservesContentOrder(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$html =
'<div style="border:0.3pt solid #999;padding:2pt;">'
. '<div style="page:auto">PAGE_AUTO</div>'
. '<div style="page:chapter">PAGE_CHAPTER</div>'
. '<div style="page:appendix">PAGE_APPENDIX</div>'
. '<p>PAGE_FOOT</p>'
. '</div>';
$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('PAGE_AUTO', $content);
$this->assertStringContainsString('PAGE_CHAPTER', $content);
$this->assertStringContainsString('PAGE_APPENDIX', $content);
$posAuto = \strpos($content, 'PAGE_AUTO');
$posChapter = \strpos($content, 'PAGE_CHAPTER');
$posAppendix = \strpos($content, 'PAGE_APPENDIX');
$this->assertNotFalse($posAuto);
$this->assertNotFalse($posChapter);
$this->assertNotFalse($posAppendix);
$this->assertLessThan($posChapter, $posAuto);
$this->assertLessThan($posAppendix, $posChapter);
}
/** @throws \Throwable */
public function testAddHTMLCellBlocksTempImagesByDefault(): void
{
$tmpImage = (string) \tempnam(\sys_get_temp_dir(), 'tc-html-img-');
\unlink($tmpImage);
$tmpImage .= '.png';
\copy(__DIR__ . '/../examples/images/tcpdf_cell.png', $tmpImage);
try {
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$obj->addHTMLCell('<img src="file://' . $tmpImage . '" width="10" height="10"/>', 10, 10, 40, 20);
/** @var \Com\Tecnick\Pdf\Image\Import $image */
$image = $this->getObjectProperty($obj, 'image');
/** @var array<int, mixed> $images */
$images = $this->getObjectProperty($image, 'image');
$this->assertSame([], $images);
} finally {
if (\file_exists($tmpImage)) {
\unlink($tmpImage);
}
}
}
/** @throws \Throwable */
public function testAddHTMLCellAllowsTempImagesWhenMarkupAllowedPathsConfigured(): void
{
$tmpImage = (string) \tempnam(\sys_get_temp_dir(), 'tc-html-img-');
\unlink($tmpImage);
$tmpImage .= '.png';
\copy(__DIR__ . '/../examples/images/tcpdf_cell.png', $tmpImage);
try {
$tmpRoot = \realpath(\sys_get_temp_dir());
$this->assertIsString($tmpRoot);
$obj = new \Com\Tecnick\Pdf\Tcpdf(fileOptions: [
'markupAllowedPaths' => [$tmpRoot],
]);
$this->initFontAndPage($obj);
$obj->addHTMLCell('<img src="file://' . $tmpImage . '" width="10" height="10"/>', 10, 10, 40, 20);
/** @var \Com\Tecnick\Pdf\Image\Import $image */
$image = $this->getObjectProperty($obj, 'image');
/** @var array<int, mixed> $images */
$images = $this->getObjectProperty($image, 'image');
$this->assertCount(1, $images);
} finally {
if (\file_exists($tmpImage)) {
\unlink($tmpImage);
}
}
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellCoversSelfClosingPageBreakAfterMode(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$before = $obj->exposePageBreak();
$html = '<img alt="x" style="page-break-after:always" /><p>AfterBreak</p>';
$out = $obj->getHTMLCell($html, 0, 0, 20, 6);
$after = $obj->exposePageBreak();
$this->assertNotSame('', $out);
$this->assertStringContainsString('(AfterBreak)', $out);
$this->assertGreaterThan($before + 1, $after);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellCoversTcpdfPageBreakMethod(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$html = '<tcpdf method="pagebreak" /><p>AfterBreak</p>';
$out = $obj->getHTMLCell($html, 0, 0, 20, 6);
$this->assertNotSame('', $out);
$this->assertStringContainsString('(AfterBreak)', $out);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellCoversTcpdfSerializedPageBreakData(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$before = $obj->exposePageBreak();
$payload = \urlencode((string) \json_encode(['m' => 'AddPage', 'p' => []]));
$hash = \str_repeat('a', 64);
$data = '64+' . $hash . '+' . $payload;
$html = '<tcpdf data="' . $data . '" /><p>AfterBreak</p>';
$out = $obj->getHTMLCell($html, 0, 0, 20, 6);
$after = $obj->exposePageBreak();
$this->assertNotSame('', $out);
$this->assertStringContainsString('(AfterBreak)', $out);
$this->assertGreaterThan($before, $after);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellIgnoresDisallowedTcpdfSerializedMethod(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
/** @var array<string, array<string, int|float>> $beforeDests */
$beforeDests = $this->getObjectProperty($obj, 'dests');
$payload = \urlencode((string) \json_encode([
'm' => 'setNamedDestination',
'p' => ['blocked-dest', -1, 1.0, 1.0],
]));
$hash = \str_repeat('a', 64);
$data = '64+' . $hash . '+' . $payload;
$obj->getHTMLCell('<tcpdf data="' . $data . '" /><p>X</p>', 0, 0, 20, 6);
/** @var array<string, array<string, int|float>> $afterDests */
$afterDests = $this->getObjectProperty($obj, 'dests');
$this->assertCount(\count($beforeDests), $afterDests);
}
/**
* @throws \Throwable
*/
public function testIsValidCSSSelectorForTagSupportsPseudoClassSubsetAndRejectsOthers(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode([
'value' => 'div',
'parent' => 0,
'tag' => true,
'opening' => true,
]),
2 => $this->makeHtmlNode([
'value' => 'a',
'parent' => 0,
'tag' => true,
'opening' => true,
'attribute' => [
'href' => 'https://example.com',
'lang' => 'en-US',
],
]),
3 => $this->makeHtmlNode([
'value' => 'span',
'parent' => 0,
'tag' => true,
'opening' => true,
'attribute' => ['lang' => 'fr'],
]),
4 => $this->makeHtmlNode([
'value' => 'span',
'parent' => 3,
'tag' => true,
'opening' => true,
]),
5 => $this->makeHtmlNode([
'value' => 'span',
'parent' => 0,
'tag' => true,
'opening' => true,
]),
6 => $this->makeHtmlNode([
'value' => 'div',
'parent' => 0,
'tag' => true,
'opening' => true,
]),
7 => $this->makeHtmlNode([
'value' => 'input',
'parent' => 6,
'tag' => true,
'opening' => true,
'attribute' => [
'type' => 'checkbox',
'checked' => 'checked',
],
]),
8 => $this->makeHtmlNode([
'value' => 'input',
'parent' => 6,
'tag' => true,
'opening' => true,
'attribute' => [
'type' => 'text',
'disabled' => 'disabled',
],
]),
9 => $this->makeHtmlNode([
'value' => 'option',
'parent' => 6,
'tag' => true,
'opening' => true,
'attribute' => [
'selected' => 'selected',
],
]),
];
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' div:first-child'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' div:last-child'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' a:nth-child(2)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' div:nth-child(odd)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' div:nth-child(even)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' a:nth-child(even)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 2, ' a:nth-child(odd)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span:nth-child(2n+1)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 2, ' a:nth-child(2n+1)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' a:nth-child(-n+2)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 3, ' span:nth-child(-n+2)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' a:nth-child(n)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' div:empty'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 3, ' span:empty'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' a:link'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 3, ' span:link'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 2, ' a:nth-child(foo)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 4, ' span:only-child'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 3, ' span:only-child'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 5, ' span:last-of-type'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span:first-of-type'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span:nth-of-type(2n+1)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' a:nth-last-child(4)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' a:lang(en)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' a:lang(en-US)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 2, ' a:lang(fr)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' span:lang(fr)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 3, ' span:lang(en)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 4, ' span:lang(fr)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' div:hover'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' div:focus'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 7, ' input:hover'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 7, ' input:focus'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 7, ' input:active'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 7, ' input:checked'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 7, ' input:enabled'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 8, ' input:disabled'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 8, ' input:enabled'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 9, ' option:checked'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' a:visited'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' div::before'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' div::after'));
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMRecomputesTextInheritanceAfterStructuralPseudoResolution(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$html =
'<style>#selectors li:first-child{color:#0a7a0a;}#selectors li:last-child{color:#aa2222;}</style>'
. '<div id="selectors"><ul>'
. '<li>First item styled by :first-child</li>'
. '<li>Middle item</li>'
. '<li>Last item styled by :last-child</li>'
. '</ul></div>';
$dom = $obj->exposeGetHTMLDOM($html);
$firstTextColor = null;
$middleTextColor = null;
$lastTextColor = null;
foreach ($dom as $node) {
if (!empty($node['tag'])) {
continue;
}
$text = \trim($node['value']);
if ($text === 'First item styled by :first-child') {
$firstTextColor = $node['fgcolor'];
} elseif ($text === 'Middle item') {
$middleTextColor = $node['fgcolor'];
} elseif ($text === 'Last item styled by :last-child') {
$lastTextColor = $node['fgcolor'];
}
}
$this->assertSame('rgb(4%,48%,4%)', $firstTextColor);
$this->assertSame('black', $middleTextColor);
$this->assertSame('rgb(67%,13%,13%)', $lastTextColor);
}
/**
* @throws \Throwable
*/
public function testIsValidCSSSelectorForTagSupportsEscapedIdentifiers(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode([
'value' => 'x:tag',
'parent' => 0,
'tag' => true,
'opening' => true,
'attribute' => [
'class' => 'foo:bar cafe foobar',
'id' => 'id:main',
'data:name' => 'v:1',
'lang' => 'en-US',
],
]),
];
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' x\\:tag'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' .foo\\:bar'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' #id\\:main'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' x\\:tag.foo\\:bar#id\\:main'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' [data\\:name="v\\:1"]'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' x\\00003atag'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' .foo\\00003abar'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' #id\\00003amain'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' x\\:tag:lang(en)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' .caf\\65 '));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, " .foo\\\nbar"));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' x\\:tag:lang(fr)'));
}
/**
* @throws \Throwable
*/
#[DataProvider('selectorAttributePseudoEdgeCaseProvider')]
public function testIsValidCSSSelectorForTagFixtureAttributeAndPseudoEdgeCases(
string $name,
string $selector,
int $node,
bool $expected,
): void {
$obj = $this->getInternalTestObject();
$dom = $this->getSelectorAttributePseudoEdgeCaseDom();
$result = $obj->isValidCSSSelectorForTag($dom, $node, $selector);
$this->assertSame($expected, $result, $name);
}
/** @return array<string, array{0: string, 1: string, 2: int, 3: bool}> */
public static function selectorAttributePseudoEdgeCaseProvider(): array
{
$json = (string) \file_get_contents(__DIR__ . '/fixtures/css/selectors/attribute_pseudo_edge_cases.json');
/** @var array<int, array{name: string, selector: string, node: int, expected: bool}>|null $rows */
$rows = \json_decode($json, true);
if (!\is_array($rows)) {
return [];
}
$out = [];
foreach ($rows as $row) {
$out[$row['name']] = [
$row['name'],
$row['selector'],
$row['node'],
$row['expected'],
];
}
return $out;
}
/** @phpstan-return array<int, THTMLAttrib> */
private function getSelectorAttributePseudoEdgeCaseDom(): array
{
return [
0 => $this->makeHtmlNode(['value' => 'root', 'opening' => true]),
1 => $this->makeHtmlNode([
'value' => 'article',
'parent' => 0,
'opening' => true,
'attribute' => ['lang' => 'en-US'],
]),
2 => $this->makeHtmlNode([
'value' => 'a',
'parent' => 1,
'opening' => true,
'attribute' => [
'id' => 'promo-link',
'class' => 'btn primary',
'data-role' => 'cta main',
'data-lang' => 'en-US',
'title' => 'Hello World',
'href' => 'https://ex.com?a=1&b=2',
],
]),
3 => $this->makeHtmlNode([
'value' => 'span',
'parent' => 1,
'opening' => true,
'attribute' => ['class' => 'badge'],
]),
];
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMCSSDataStoresPseudoElementStyles(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode([
'value' => 'span',
'parent' => 0,
'attribute' => ['class' => 'demo'],
]),
];
$css = [
'0010 span.demo::before' => 'content:"[B]";color:red;',
'0010 span.demo::after' => 'content:"[A]";font-weight:bold;',
];
$obj->getHTMLDOMCSSData($dom, $css, 1);
assert(isset($dom[1]), "\$dom[1] must be set");
$this->assertArrayHasKey('pseudo-before-style', $dom[1]['attribute']);
$this->assertArrayHasKey('pseudo-after-style', $dom[1]['attribute']);
$beforeStyle = $this->getHtmlNodeAttrString($dom[1], 'pseudo-before-style');
$afterStyle = $this->getHtmlNodeAttrString($dom[1], 'pseudo-after-style');
$this->assertIsString($beforeStyle);
$this->assertIsString($afterStyle);
$this->assertStringContainsString('content:"[B]"', $beforeStyle);
$this->assertStringContainsString('content:"[A]"', $afterStyle);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellRendersTextOnlyPseudoElementsBeforeAndAfter(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$out = $obj->getHTMLCell(
'<style>span.demo::before{content:"[B]";color:#ff0000;}'
. 'span.demo::after{content:"[A]";font-weight:bold;}</style>'
. '<span class="demo">X</span>',
0,
0,
40,
12,
);
$this->assertNotSame('', $out);
$this->assertStringContainsString('[B]', $out);
$this->assertStringContainsString('X', $out);
$this->assertStringContainsString('[A]', $out);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellRendersSingleQuotedPseudoElementContent(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$out = $obj->getHTMLCell(
"<style>span.demo::before{content:'[B]';}span.demo::after{content:'[A]';}</style>"
. '<span class="demo">X</span>',
0,
0,
40,
12,
);
$this->assertNotSame('', $out);
$this->assertStringContainsString('[B]', $out);
$this->assertStringContainsString('X', $out);
$this->assertStringContainsString('[A]', $out);
}
/**
* @throws \Throwable
*/
public function testIsValidCSSSelectorForTagHandlesInvalidSyntax(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode(['value' => 'div', 'parent' => 0]),
];
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ''));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, '['));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ']'));
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLHandlesConsecutivePreTags(): void
{
$obj = $this->getInternalTestObject();
$html = '<pre>line1</pre><pre>line2</pre>';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<pre>line1</pre>', $out);
$this->assertStringContainsString('<pre>line2</pre>', $out);
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLHandlesTextareaWithNewlineCharacters(): void
{
$obj = $this->getInternalTestObject();
$html = '<textarea>line1' . "\n" . 'line2</textarea>';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<textarea', $out);
$this->assertStringContainsString('line1', $out);
$this->assertStringContainsString('line2', $out);
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLHandlesImagesWithoutSrc(): void
{
$obj = $this->getInternalTestObject();
$html = '<img alt="test"><p>after</p>';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<img', $out);
$this->assertStringContainsString('<p>', $out);
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLHandlesEmptySelectAndOption(): void
{
$obj = $this->getInternalTestObject();
$html = '<select></select>';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<select', $out);
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLFlattensOptgroupOptionsIntoSelectOptAttribute(): void
{
$obj = $this->getInternalTestObject();
$html = '<select><optgroup label="Group A"><option value="x">X</option></optgroup></select>';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<select', $out);
$this->assertStringContainsString('opt="x#!TaB!#Group A - X"', $out);
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLAcceptsSingleQuotedAndUnquotedSelectOptionAttributes(): void
{
$obj = $this->getInternalTestObject();
$html =
"<select><optgroup label='Group A'><option value=v1 selected>Alpha</option>"
. "<option value='v2'>Beta</option></optgroup></select>";
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<select', $out);
$this->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 = ['<div>', '</div>'];
$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('<table><caption>Cap</caption><colgroup><col span="1"></colgroup>'
. '<tfoot><tr><td>Foot</td></tr></tfoot></table>');
$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('<table><colgroup span="2" width="80"></colgroup>'
. '<tr><td>A</td><td>B</td></tr></table>');
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(
'<table><colgroup><col span="2" width="60"><col width="20"></colgroup>'
. '<tr><td>A</td><td>B</td><td>C</td></tr></table>',
);
$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('<table><colgroup><col width="20"><col width="20"></colgroup>'
. '<tr><td width="60">A</td><td>B</td></tr></table>');
$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 = '<h1>Title</h1><h2>Subtitle</h2>';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<h1>', $out);
$this->assertStringContainsString('<h2>', $out);
$this->assertStringContainsString('Title', $out);
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLHandlesDivWrappers(): void
{
$obj = $this->getInternalTestObject();
$html = '<div class="container"><p>Content</p></div>';
$out = $obj->exposeSanitizeHTML($html);
$this->assertStringContainsString('<div', $out);
$this->assertStringContainsString('<p>', $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<string, array{0: string}> */
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<string, array{0: int, 1: string}> */
public static function htmlLiBulletCountProvider(): array
{
return [
'count-one' => [1, '1.'],
'count-large' => [999, '999'],
];
}
/** @return array<string, array{0: string, 1: int, 2: string}> */
public static function htmlLiBulletNumericFormatProvider(): array
{
return [
'decimal' => ['decimal', 42, '42.'],
'short-decimal' => ['1', 15, '15.'],
'leading-zero' => ['decimal-leading-zero', 5, '05'],
];
}
/** @return array<string, array{0: bool, 1: string}> */
public static function htmlLiBulletTextDirectionProvider(): array
{
return [
'rtl' => [true, '.10'],
'ltr' => [false, '10.'],
];
}
/** @return array<string, array{0: string, 1: string, 2: string}> */
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: <br> 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 <br> — second word is on a new (lower) line
$obj2 = $this->getTestObject();
$this->initFontAndPage($obj2);
$outNewLine = $obj2->getHTMLCell('Hello<br />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 — <br> 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 = '<font size="10">A</font><font size="22">B</font><font size="10">C</font>';
$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: <hr> width/height ---
/**
* @throws \Throwable
*/
public function testGetHTMLCellHrRespectsWidthAttribute(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
// Full-width HR (no attribute)
$outFull = $obj->getHTMLCell('<hr />', 0, 0, 40, 5);
// HR with width="50" (50px ≈ 13mm — narrower than the 40mm cell)
$obj2 = $this->getTestObject();
$this->initFontAndPage($obj2);
$outShort = $obj2->getHTMLCell('<hr width="50" />', 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('<hr />', 0, 0, 40, 5);
// HR with height="5" (5px ≈ 1.32mm stroke)
$obj2 = $this->getTestObject();
$this->initFontAndPage($obj2);
$outThick = $obj2->getHTMLCell('<hr height="5" />', 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(
'<img src="' . $b64src . '" width="4" height="4" align="bottom" />',
0,
0,
40,
20,
);
$obj2 = $this->getTestObject();
$this->initFontAndPage($obj2);
$outTop = $obj2->getHTMLCell('<img src="' . $b64src . '" width="4" height="4" align="top" />', 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('<img src="' . $b64src . '" width="20" />', 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('<img src="' . $b64src . '" height="12" />', 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('<img src="' . $b64src . '" style="width:50%;" />', 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 =
'<table style="width:60mm;"><tr>'
. '<td style="width:30mm;"><img src="'
. $b64src
. '" style="width:100%;" /></td>'
. '<td>second cell</td>'
. '</tr></table>';
$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('<hr style="width:50%;" />', 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('<input type="text" name="fname" style="width:50%;" value="x" />', 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 <img src="' . $b64src . '" width="4" height="30" /> 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 <img src="' . $b64src . '" width="4" height="30" /> 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 =
'<div style="text-align:center">'
. '<img src="'
. $src
. '" width="4" height="4" />'
. '<img src="'
. $src
. '" width="4" height="4" />'
. '</div>';
$htmlLeft =
'<div style="text-align:left">'
. '<img src="'
. $src
. '" width="4" height="4" />'
. '<img src="'
. $src
. '" width="4" height="4" />'
. '</div>';
$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 =
'<table border="1" cellspacing="0" cellpadding="4">'
. '<tr><td align="center"><img src="'
. $src
. '" width="8" height="8" /></td></tr>'
. '</table>';
$htmlLeft =
'<table border="1" cellspacing="0" cellpadding="4">'
. '<tr><td align="left"><img src="'
. $src
. '" width="8" height="8" /></td></tr>'
. '</table>';
$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 =
'<div style="text-align:justify;">'
. 'Alfa <i>Bravo</i> Charlie <i>Delta</i> Echo <i>Foxtrot</i> Golf <i>Hotel</i> '
. 'India <i>Juliett</i> Kilo <i>Lima</i> Mike <i>November</i>'
. '</div>';
$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 =
'<div style="text-align:justify;">'
. 'Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett '
. 'Kilo Lima Mike November Oscar Papa Quebec Romeo Sierra Tango'
. '</div>';
$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 =
'<div style="text-align:justify;">'
. 'Alfa <i>Bravo</i> Charlie <i>Delta</i> Echo <i>Foxtrot</i> Golf <i>Hotel</i> '
. 'India <i>Juliett</i> Kilo <i>Lima</i> Mike <i>November</i> Oscar <i>Papa</i> '
. 'Quebec <i>Romeo</i> Sierra <i>Tango</i> Uniform <i>Victor</i> Whiskey <i>Xray</i> '
. 'Yankee <i>Zulu</i>'
. '</div>';
$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 =
'<div style="text-align:justify;">'
. 'JUSTIFY: Alfa <i>Bravo</i> Charlie <i>Delta</i> Echo '
. '<img src="'
. $logo
. '" alt="TCPDF logo" width="89" height="30" border="0" />'
. '<img src="'
. $box
. '" alt="TCPDF box" width="100" height="67" border="0" /> '
. '<i>Foxtrot</i> Golf <i>Hotel</i> India <i>Juliett</i> Kilo <i>Lima</i> Mike <i>November</i> '
. 'Oscar <i>Papa</i> Quebec <i>Romeo</i> Sierra <i>Tango</i> Uniform <i>Victor</i> '
. 'Whiskey <i>Xray</i> Yankee <i>Zulu</i>'
. '</div>';
$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 =
'<div><span style="text-align:justify;">'
. 'a <u>abc</u> abcdefghijkl (abcdef) abcdefg <b>abcdefghi</b> a ((abc)) abcd '
. '<img src="'
. $logo
. '" alt="TCPDF logo" width="89" height="30" border="0" /> '
. 'abcdef abcdefg <b>abcdefghi</b> a abc abcd abcdef abcdefg <b>abcdefghi</b> '
. 'a abc abcd abcdef abcdefg <b>abcdefghi</b> a <u>abc</u> abcd abcdef abcdefg <b>abcdefghi</b> '
. 'a abc \(abcd\) abcdef abcdefg <b>abcdefghi</b> a abc \\\(abcd\\\) abcdef abcdefg <b>abcdefghi</b> '
. 'a abc abcd abcdef abcdefg <b>abcdefghi</b> a abc abcd abcdef abcdefg <b>abcdefghi</b> '
. 'a abc abcd abcdef abcdefg abcdefghi a abc abcd '
. '<a href="https://tcpdf.org">abcdef abcdefg</a> start a abc before '
. '<span style="background-color:yellow">yellow color</span> 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'
. '<br />abcd abcdef abcdefg abcdefghi<br />abcd abcde abcdef'
. '</span></div>';
$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 =
'<div style="text-align:right;">'
. 'RIGHT: Alfa <i>Bravo</i> Charlie <i>Delta</i> Echo '
. '<img src="'
. $logo
. '" alt="TCPDF logo" width="89" height="30" border="0" />'
. '<img src="'
. $box
. '" alt="TCPDF box" width="100" height="67" border="0" /> '
. '<i>Foxtrot</i> Golf <i>Hotel</i> India <i>Juliett</i> Kilo <i>Lima</i> Mike <i>November</i> '
. 'Oscar <i>Papa</i> Quebec <i>Romeo</i> Sierra <i>Tango</i> Uniform <i>Victor</i> '
. 'Whiskey <i>Xray</i> Yankee <i>Zulu</i>'
. '</div>';
$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 =
'<div style="text-align:right;">'
. 'RIGHT: Alfa <i>Bravo</i> Charlie <i>Delta</i> Echo <i>Foxtrot</i> Golf <i>Hotel</i> '
. 'India <i>Juliett</i> Kilo <i>Lima</i> Mike <i>November</i> '
. 'Oscar <i>Papa</i> Quebec <i>Romeo</i> Sierra <i>Tango</i> Uniform <i>Victor</i> '
. 'Whiskey <i>Xray</i> Yankee <i>Zulu</i>'
. '</div>';
$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('<img src="' . $src . '" width="4" height="4" />', 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(
'<img src="data:image/png;base64,!!!notvalidbase64!!!" alt="err" width="4" height="4" />',
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(
'<img src="images/tcpdf_logo.jpg" alt="should-not-fallback" width="60" height="20" />',
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 <p> opens after the first, so openHTMLBlock will apply vspace
$outDefault = $obj->getHTMLCell('<p>A</p><p>B</p>', 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('<p>A</p><p>B</p>', 0, 0, 40, 30);
$this->assertNotSame('', $outDefault);
$this->assertNotSame('', $outSpaced);
// Extra 2-line top spacing before the second <p> 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('<p>A</p><p>B</p>', 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('<p>A</p><p>B</p>', 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('<p>spacing test</p>', 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(
'@<svg xmlns="http://www.w3.org/2000/svg"></svg>',
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<int, THTMLAttrib> $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(
'<table cellpadding="0" cellspacing="0"><tr><th>H</th></tr></table>',
);
$padded = $obj->exposeEstimateHTMLTableHeadHeight(
'<table cellpadding="6" cellspacing="0"><tr><th>H</th></tr></table>',
);
$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(
'<table cellpadding="0" cellspacing="0"><tr><th>H</th></tr></table>',
);
$spaced = $obj->exposeEstimateHTMLTableHeadHeight(
'<table cellpadding="0" cellspacing="6"><tr><th>H</th></tr></table>',
);
$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(
'<table cellpadding="0" style="border-spacing:0 0"><tr><th>H</th></tr></table>',
);
$spaced = $obj->exposeEstimateHTMLTableHeadHeight(
'<table cellpadding="0" style="border-spacing:0 6"><tr><th>H</th></tr></table>',
);
$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('<tr></tr><tr><td height="8">Head</td></tr>'),
);
$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('<p>Plain <b>bold</b> <s>strike</s></p>', 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<int, THTMLAttrib> $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('<input type="submit" value="Go" />', 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(
'<form action="https://example.test/form" method="get">'
. '<input type="submit" name="submit" value="Go" /></form>',
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('<input type="reset" name="reset" value="Reset" />', 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('<input type="text" name="fname" value="John" />', 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('<textarea name="notes" rows="4">hello</textarea>', 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('<textarea name="notes_auto" rows="3">hello</textarea>', 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('<textarea name="notes_cols" rows="3" cols="5">hello</textarea>', 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(
'<select name="color"><option value="r">Red</option><option value="g">Green</option></select>',
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(
'<select name="choices" size="2" multiple="multiple">'
. '<option value="a">Alpha</option><option value="b">Beta</option></select>',
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(
'<select name="choices" size="2" multiple="0" value="missing,b">'
. '<option value="a">Alpha</option><option value="b">Beta</option></select>',
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(
'<select name="choices" size="3" multiple="multiple">'
. '<option value="a" selected>Alpha</option><option value="b">Beta</option>'
. '<option value="c" selected>Gamma</option></select>',
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(
'<select name="choices" size="3" multiple="multiple" value="missing,c,b">'
. '<option value="a">Alpha</option><option value="b">Beta</option>'
. '<option value="c">Gamma</option></select>',
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(
'<select name="choices" size="3" multiple="multiple" value="b,c">'
. '<option value="a" selected>Alpha</option><option value="b">Beta</option>'
. '<option value="c" selected>Gamma</option></select>',
0,
0,
40,
10,
);
$opt = $this->getLastAnnotationOptFromObject($obj);
$this->assertSame('Ch', $this->getMapString($opt, 'ft'));
// selected <option> entries must win over select[value] fallback.
$this->assertSame('a', $this->getMapString($opt, 'v'));
$this->assertSame([0, 2], $this->getMapIntList($opt, 'i'));
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellSelectSizeControlsListBoxHeight(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$obj->getHTMLCell(
'<select name="one"><option value="a">Alpha</option><option value="b">Beta</option></select>',
0,
0,
40,
10,
);
$annotation = $this->getObjectArrayProperty($obj, 'annotation');
$this->assertIsArray($annotation);
$this->assertNotEmpty($annotation);
/** @var array{h: float} $combo */
$combo = \end($annotation);
$obj2 = $this->getTestObject();
$this->initFontAndPage($obj2);
$obj2->getHTMLCell(
'<select name="many" size="3"><option value="a">Alpha</option><option value="b">Beta</option></select>',
0,
0,
40,
10,
);
$annotation2 = $this->getObjectArrayProperty($obj2, 'annotation');
$this->assertIsArray($annotation2);
$this->assertNotEmpty($annotation2);
/** @var array{h: float} $list */
$list = \end($annotation2);
$this->assertGreaterThan($combo['h'], $list['h']);
}
/**
* @throws \Throwable
*/
public function testNestedInlineTagsRenderOnSameLine(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$obj->exposeInitHTMLCellContext(0, 0, 150, 60);
$tpx = 0.0;
$tpy = 0.0;
$tpw = 150.0;
$tph = 60.0;
$html = '<p><b>b<i>bi<u>biu</u>bi</i>b</b></p>';
$dom = $obj->exposeGetHTMLDOM($html);
// Collect the y-position of each text fragment to ensure they are all on the same line.
$yPositions = [];
foreach ($dom as $node) {
if (!empty($node['tag'])) {
continue;
}
if (\trim($node['value']) === '') {
continue;
}
$yBefore = $tpy;
$obj->exposeParseHTMLText($node, $tpx, $tpy, $tpw, $tph);
$yPositions[] = $yBefore;
}
// All text fragments must start at the same y position (same line).
$this->assertNotEmpty($yPositions);
assert(isset($yPositions[0]), "\$yPositions[0] must be set");
$firstY = $yPositions[0];
foreach ($yPositions as $y) {
$this->assertEqualsWithDelta(
$firstY,
$y,
0.001,
'All nested inline text fragments must be on the same line',
);
}
// Also verify tpx advanced after each fragment (fragments are side by side, not stacked).
$tpx = 0.0;
$tpy = 0.0;
$tpw = 150.0;
$obj->exposeInitHTMLCellContext(0, 0, 150, 60);
$xPositions = [];
foreach ($dom as $node) {
if (!empty($node['tag'])) {
continue;
}
if (\trim($node['value']) === '') {
continue;
}
$xBefore = $tpx;
$obj->exposeParseHTMLText($node, $tpx, $tpy, $tpw, $tph);
$xPositions[] = ['before' => $xBefore, 'after' => $tpx];
}
// Each text fragment must advance the x cursor.
foreach ($xPositions as $xp) {
$this->assertGreaterThan($xp['before'], $xp['after'], 'Each inline fragment must advance the x cursor');
}
}
/** @return array<string, array{0: string, 1: ?string}> */
public static function htmlLiBulletNamedTypeProvider(): array
{
return [
'lower-alpha' => ['lower-alpha', null],
'lower-latin' => ['lower-latin', null],
'upper-alpha' => ['upper-alpha', null],
'upper-latin' => ['upper-latin', null],
'roman-lower-short' => ['i', 'v'],
'roman-lower-name' => ['lower-roman', 'v'],
'roman-upper-name' => ['upper-roman', 'V'],
'roman-upper-short' => ['I', 'V'],
];
}
/** @return array<string, array{0: string, 1: string}> */
public static function htmlLiBulletShortAlphaProvider(): array
{
return [
'short-lower-a' => ['a', 'e'],
'short-upper-a' => ['A', 'E'],
];
}
/** @return array<string, array{0: string, 1: bool, 2: bool, 3: float, 4: float}> */
public static function htmlLiBulletShapeProvider(): array
{
return [
'unicode-disc' => ['disc', true, false, 0.0, 0.0],
'unicode-circle' => ['circle', true, false, 0.0, 0.0],
'unicode-square' => ['square', true, false, 0.0, 0.0],
'non-unicode-disc' => ['disc', false, false, 0.0, 0.0],
'non-unicode-circle' => ['circle', false, false, 0.0, 0.0],
'non-unicode-square' => ['square', false, false, 0.0, 0.0],
'rtl-disc' => ['disc', false, true, 10.0, 5.0],
'rtl-circle' => ['circle', false, true, 10.0, 5.0],
'rtl-square' => ['square', false, true, 10.0, 5.0],
];
}
/**
* @throws \Throwable
*/
public function testSanitizeHTMLWithOptgroupProcessesClosingAndOpeningTags(): void
{
$obj = $this->getInternalTestObject();
$html =
'<select name="x"><optgroup label="Group A">'
. '<option value="a1">Alpha 1</option>'
. '</optgroup><optgroup label="Group B">'
. '<option value="b1">Beta 1</option>'
. '</optgroup></select>';
$result = $obj->exposeSanitizeHTML($html);
// Options should be packed with group-label prefix.
$this->assertStringContainsString('opt=', $result);
$this->assertStringContainsString('Group A - Alpha 1', $result);
$this->assertStringContainsString('Group B - Beta 1', $result);
}
/**
* @throws \Throwable
*/
public function testGetHTMLDOMHandlesUnquotedAttributeValues(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
// Build DOM from HTML with an unquoted attribute value.
$dom = $obj->exposeGetHTMLDOM('<div data-count=42>text</div>');
// Find the div node and check the unquoted attribute was parsed.
$divNode = null;
foreach ($dom as $node) {
if (!($node['value'] === 'div')) {
continue;
}
$divNode = $node;
break;
}
$this->assertNotNull($divNode);
$this->assertSame('42', $divNode['attribute']['data-count'] ?? null);
}
/**
* @throws \Throwable
*/
public function testIsValidCSSSelectorMatchingClassAndIdTokensContinueToNextToken(): void
{
$obj = $this->getTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode([
'value' => 'div',
'attribute' => ['id' => 'main', 'class' => 'hero card'],
'parent' => 0,
'tag' => true,
'opening' => true,
]),
];
// Selector with both class AND id suffix tokens — both must match and continue.
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' div.hero#main'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' div.card#main'));
// Class present but id wrong → false.
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' div.hero#other'));
}
/**
* @throws \Throwable
*/
public function testHtmlSelectorAttributeAndOpeningSiblingHelpersCoverBranches(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode([
'value' => 'root',
'tag' => true,
'opening' => true,
'parent' => -1,
]),
1 => $this->makeHtmlNode([
'value' => 'span',
'tag' => true,
'opening' => true,
'parent' => 0,
'attribute' => [
'data-role' => 'alpha beta',
'class' => 'hero card',
'lang' => 'en-US',
],
]),
2 => $this->makeHtmlNode([
'value' => 'span',
'tag' => true,
'opening' => true,
'parent' => 0,
'attribute' => [
'data-role' => 'gamma-delta',
'class' => 'card main',
],
]),
3 => $this->makeHtmlNode([
'value' => 'span',
'tag' => false,
'opening' => false,
'parent' => 0,
'content' => 'text',
]),
];
$this->assertTrue($obj->exposeMatchesHTMLSelectorAttribute($dom, 1, '[data-role]'));
$this->assertTrue($obj->exposeMatchesHTMLSelectorAttribute($dom, 1, '[data-role="alpha beta"]'));
$this->assertTrue($obj->exposeMatchesHTMLSelectorAttribute($dom, 1, '[data-role^=alpha]'));
$this->assertTrue($obj->exposeMatchesHTMLSelectorAttribute($dom, 1, '[data-role$=beta]'));
$this->assertTrue($obj->exposeMatchesHTMLSelectorAttribute($dom, 1, '[data-role*=pha b]'));
$this->assertTrue($obj->exposeMatchesHTMLSelectorAttribute($dom, 1, '[data-role~=beta]'));
$this->assertTrue($obj->exposeMatchesHTMLSelectorAttribute($dom, 1, '[lang|=en]'));
$this->assertFalse($obj->exposeMatchesHTMLSelectorAttribute($dom, 1, '[data-role^=beta]'));
$this->assertFalse($obj->exposeMatchesHTMLSelectorAttribute($dom, 1, 'not-an-attribute-selector'));
$this->assertSame([1, 2], $obj->exposeGetHTMLOpeningSiblingKeys($dom, 2));
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleDeclarationMapHandlesQuotesAndParens(): void
{
$obj = $this->getInternalTestObject();
// Quoted value with semicolons inside should not be split.
$style = "background-image: url('data:image/png;base64,abc'); color: red;";
$result = $obj->exposeParseHTMLStyleDeclarationMap($style);
$this->assertArrayHasKey('background-image', $result);
$this->assertArrayHasKey('color', $result);
$backgroundImage = $result['background-image'] ?? null;
$this->assertIsString($backgroundImage);
$this->assertStringContainsString('url(', $backgroundImage);
$this->assertSame('red', $result['color'] ?? null);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleDeclarationMapWithDoubleQuotes(): void
{
$obj = $this->getInternalTestObject();
// Declaration with double-quoted content.
$style = 'content: "hello; world"; font-weight: bold';
$result = $obj->exposeParseHTMLStyleDeclarationMap($style);
$this->assertArrayHasKey('content', $result);
$this->assertArrayHasKey('font-weight', $result);
$content = $result['content'] ?? null;
$this->assertIsString($content);
$this->assertStringContainsString('hello; world', $content);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleDeclarationMapSkipsDeclarationWithNoColon(): void
{
$obj = $this->getInternalTestObject();
// A declaration without a colon must be silently skipped.
$style = 'invalid-no-colon; color: blue';
$result = $obj->exposeParseHTMLStyleDeclarationMap($style);
$this->assertArrayNotHasKey('invalid-no-colon', $result);
$this->assertSame('blue', $result['color'] ?? '');
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleDeclarationMapStripsImportantSuffix(): void
{
$obj = $this->getInternalTestObject();
$style = 'color:#0055aa !important; border:1px solid #333 !important; font-weight:bold';
$result = $obj->exposeParseHTMLStyleDeclarationMap($style);
$this->assertSame('#0055aa', $result['color'] ?? '');
$this->assertSame('1px solid #333', $result['border'] ?? '');
$this->assertSame('bold', $result['font-weight'] ?? '');
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesHandlesImportantColorWithoutFatal(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode([
'value' => 'span',
'parent' => 0,
'attribute' => [
'style' => 'color: #0055aa !important;',
],
]),
];
$obj->exposeParseHTMLStyleAttributesWithDom($dom, 1, 0);
$node = $dom[1] ?? [];
$this->assertSame('rgb(0%,33%,67%)', $node['fgcolor'] ?? '');
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesPreservesSpotColorToken(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode([
'value' => 'span',
'parent' => 0,
'attribute' => [
'style' => 'color: spot(cyan, 0.5);',
],
]),
];
$obj->exposeParseHTMLStyleAttributesWithDom($dom, 1, 0);
$node = $dom[1] ?? [];
$this->assertSame('spot(cyan,0.500000)', $node['fgcolor'] ?? '');
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesSkipsNodeWithNoStyleAttribute(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode(['value' => 'div']),
];
// Should not throw or modify dom when no style attribute.
$obj->exposeParseHTMLStyleAttributesWithDom($dom, 1, 0);
$node = $dom[1] ?? [];
$this->assertEmpty($node['style'] ?? []);
}
/**
* @throws \Throwable
*/
public function testParseHTMLStyleAttributesSkipsNodeWithEmptyParsedStyles(): void
{
$obj = $this->getInternalTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode(['value' => 'div', 'attribute' => ['style' => 'bad-declaration-only']]),
];
// A style with no valid colon-delimited declarations must result in empty styles.
$obj->exposeParseHTMLStyleAttributesWithDom($dom, 1, 0);
$node = $dom[1] ?? [];
$this->assertEmpty($node['style'] ?? []);
}
/**
* @throws \Throwable
*/
public function testIsValidCSSSelectorNthChildNegativeFactorFormula(): void
{
$obj = $this->getTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode(['value' => 'li', 'parent' => 0, 'tag' => true, 'opening' => true]),
2 => $this->makeHtmlNode(['value' => 'li', 'parent' => 0, 'tag' => true, 'opening' => true]),
3 => $this->makeHtmlNode(['value' => 'li', 'parent' => 0, 'tag' => true, 'opening' => true]),
4 => $this->makeHtmlNode(['value' => 'li', 'parent' => 0, 'tag' => true, 'opening' => true]),
];
// -n+3 selects first 3 elements (positions 1, 2, 3).
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' li:nth-child(-n+3)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' li:nth-child(-n+3)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' li:nth-child(-n+3)'));
// 4th element is not selected by -n+3.
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 4, ' li:nth-child(-n+3)'));
// -2n+4 selects elements at positions 4, 2.
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 4, ' li:nth-child(-2n+4)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' li:nth-child(-2n+4)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' li:nth-child(-2n+4)'));
}
/**
* @throws \Throwable
*/
public function testIsValidCSSSelectorPseudoClassWithExactPositionArg(): void
{
$obj = $this->getTestObject();
$dom = [
0 => $this->makeHtmlNode(['value' => 'root']),
1 => $this->makeHtmlNode(['value' => 'li', 'parent' => 0, 'tag' => true, 'opening' => true]),
2 => $this->makeHtmlNode(['value' => 'li', 'parent' => 0, 'tag' => true, 'opening' => true]),
3 => $this->makeHtmlNode(['value' => 'li', 'parent' => 0, 'tag' => true, 'opening' => true]),
];
// :nth-child(0) is never true.
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' li:nth-child(0)'));
// :nth-child(1) is the first child only.
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' li:nth-child(1)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 2, ' li:nth-child(1)'));
// :nth-child(+n) selects all.
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 1, ' li:nth-child(+n)'));
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 3, ' li:nth-child(+n)'));
// :nth-child with zero factor: 0n+2 selects exactly position 2.
$this->assertTrue($obj->isValidCSSSelectorForTag($dom, 2, ' li:nth-child(0n+2)'));
$this->assertFalse($obj->isValidCSSSelectorForTag($dom, 1, ' li:nth-child(0n+2)'));
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellEmdashInOrderedListDoesNotOverlapFollowingStrongFragment(): void
{
// Regression: em-dash (U+2014) and other WinAnsi high-range glyphs (curly
// quotes, bullet, en-dash, ellipsis, etc.) were measured using the font's
// default width (dw = 278 units) because Import\Core keyed widths by
// StandardEncoding code point instead of WinAnsi byte. The result was that
// inline text following a fragment containing an em-dash appeared too far
// to the left — visually overlapping the preceding word.
//
// Expected layout (10 pt Helvetica, ~180 mm wide list item):
// plain text: "Ordered item \x97 the number is auto-generated as the "
// strong text: "Lbl"
// After the plain fragment, the strong fragment must start at least as far
// right as the plain fragment's measured end-x, with a small tolerance.
$obj = $this->getBBoxProbeTestObject();
$this->initFontAndPage($obj);
// pdfua mode is used to match the E015 example that surfaced the bug.
$rfn = new \ReflectionProperty($obj, 'pdfuaMode');
$rfn->setValue($obj, 'pdfua');
$html = '<ol><li>Ordered item — the number is auto-generated as the <strong>Lbl</strong></li></ol>';
$obj->exposeResetBBoxTrace();
$obj->getHTMLCell($html, 15, 20, 180);
$trace = $obj->exposeGetBBoxTrace();
$this->assertNotSame([], $trace);
// Find the plain-text fragment ending in "...as the " and the "Lbl" strong fragment.
$plainIdx = null;
$lblIdx = null;
foreach ($trace as $idx => $entry) {
$txt = $entry['txt'];
if ($plainIdx === null && \str_contains($txt, 'auto-generated')) {
$plainIdx = $idx;
}
if ($lblIdx === null && $txt === 'Lbl') {
$lblIdx = $idx;
}
}
$this->assertNotNull($plainIdx, 'Plain-text fragment containing "auto-generated" must be present');
$this->assertNotNull($lblIdx, '"Lbl" strong fragment must be present');
$plainEntry = $this->getTraceRow($trace, (int) $plainIdx);
$lblEntry = $this->getTraceRow($trace, (int) $lblIdx);
// Both fragments must sit on the same visual line.
// A delta of 1 mm is used because the bold "Lbl" and the regular-weight
// prefix are baseline-aligned: the bold font's larger ascent shifts its
// bbox_y slightly upward relative to the regular fragment while they still
// occupy the same visual line.
$this->assertEqualsWithDelta(
$plainEntry['bbox_y'],
$lblEntry['bbox_y'],
1.0,
'"Lbl" must be on the same line as the preceding plain-text fragment',
);
// "Lbl" must start no earlier than where the plain-text fragment ends.
// Before the metrics fix this assertion failed: Lbl started ~7 mm to
// the left of the plain fragment's end_x (em-dash measured as dw=278
// instead of its actual 1000 units).
$this->assertGreaterThanOrEqual(
$plainEntry['bbox_end_x'] - 0.1,
$lblEntry['bbox_x'],
'"Lbl" must not overlap the preceding plain-text fragment — em-dash width regression',
);
}
/**
* @throws \Throwable
*/
public function testStripHTMLFontKeyStyleSuffix(): void
{
$obj = $this->getInternalTestObject();
// The uppercase 'B' (bold) and/or 'I' (italic) suffix is stripped.
$this->assertSame('helvetica', $obj->exposeStripHTMLFontKeyStyleSuffix('helveticaB'));
$this->assertSame('helvetica', $obj->exposeStripHTMLFontKeyStyleSuffix('helveticaI'));
$this->assertSame('helvetica', $obj->exposeStripHTMLFontKeyStyleSuffix('helveticaBI'));
// Any run of trailing style letters is removed, so style suffixes
// accumulated by repeated captures do not survive.
$this->assertSame('helvetica', $obj->exposeStripHTMLFontKeyStyleSuffix('helveticaBIB'));
$this->assertSame('helvetica', $obj->exposeStripHTMLFontKeyStyleSuffix('helveticaIBBI'));
// Lowercase trailing letters are part of the family name: they must
// be preserved, including 'b', 'i', 'u', 'd' and 'o'.
$this->assertSame('helveticab', $obj->exposeStripHTMLFontKeyStyleSuffix('helveticab'));
$this->assertSame('dejavusanscondensed', $obj->exposeStripHTMLFontKeyStyleSuffix('dejavusanscondensed'));
$this->assertSame('courierbi', $obj->exposeStripHTMLFontKeyStyleSuffix('courierbi'));
// A bare style suffix or an empty key strips to the empty string.
$this->assertSame('', $obj->exposeStripHTMLFontKeyStyleSuffix('BI'));
$this->assertSame('', $obj->exposeStripHTMLFontKeyStyleSuffix(''));
}
/**
* @throws \Throwable
*/
public function testNewHTMLRenderContext(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$dom = $obj->exposeGetHTMLDOM('<p>hello</p>');
$hrc = $obj->exposeNewHTMLRenderContext($dom);
$this->assertSame($dom, $hrc['dom']);
$this->assertSame(0.0, $hrc['cellctx']['originx']);
$this->assertSame(0.0, $hrc['cellctx']['originy']);
$this->assertSame(0.0, $hrc['cellctx']['maxwidth']);
$this->assertSame(0.0, $hrc['cellctx']['maxheight']);
$this->assertSame('', $hrc['cellctx']['basefont']);
$this->assertSame([], $hrc['fontcache']);
$this->assertSame([], $hrc['liststack']);
$this->assertSame([], $hrc['tablestack']);
$this->assertSame([], $hrc['bcellctx']);
$this->assertSame([], $hrc['blockbuf']);
$this->assertSame([], $hrc['linkstack']);
$this->assertSame([], $hrc['listack']);
$this->assertSame(0, $hrc['prelevel']);
$this->assertSame(0, $hrc['quotelevel']);
$this->assertArrayNotHasKey('currentkey', $hrc);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellContentBoxWithExplicitSize(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
// Margins and paddings are expressed in internal points.
$cellctx = [
'margin' => ['T' => 2.0, 'R' => 3.0, 'B' => 4.0, 'L' => 5.0],
'padding' => ['T' => 1.0, 'R' => 2.0, 'B' => 3.0, 'L' => 4.0],
'borderpos' => 0.0,
];
$cbox = $obj->exposeGetHTMLCellContentBox(10.0, 20.0, 100.0, 50.0, $cellctx);
$this->assertSame(100.0, $cbox['cellwidth']);
$this->bcAssertEqualsWithDelta($obj->toUnit(2.0 + 4.0 + 1.0 + 3.0), $cbox['offseth'], 0.000001);
$this->bcAssertEqualsWithDelta(10.0 + $obj->toUnit(5.0 + 4.0), $cbox['contentx'], 0.000001);
$this->bcAssertEqualsWithDelta(20.0 + $obj->toUnit(2.0 + 1.0), $cbox['contenty'], 0.000001);
$this->bcAssertEqualsWithDelta(100.0 - $obj->toUnit(5.0 + 3.0 + 4.0 + 2.0), $cbox['contentw'], 0.000001);
$this->bcAssertEqualsWithDelta(50.0 - $obj->toUnit(2.0 + 4.0 + 1.0 + 3.0), $cbox['contenth'], 0.000001);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellContentBoxAutoWidthAndHeight(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
$cellctx = [
'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0],
'padding' => ['T' => 2.0, 'R' => 2.0, 'B' => 2.0, 'L' => 2.0],
'borderpos' => 0.0,
];
$cbox = $obj->exposeGetHTMLCellContentBox(10.0, 20.0, 0.0, 0.0, $cellctx);
// Width not positive: fall back to the maximum available width.
$this->assertGreaterThan(0.0, $cbox['cellwidth']);
$this->bcAssertEqualsWithDelta($cbox['cellwidth'] - $obj->toUnit(4.0), $cbox['contentw'], 0.000001);
// Height not positive: automatic (no fixed content height).
$this->assertSame(0.0, $cbox['contenth']);
$this->bcAssertEqualsWithDelta(10.0 + $obj->toUnit(2.0), $cbox['contentx'], 0.000001);
$this->bcAssertEqualsWithDelta(20.0 + $obj->toUnit(2.0), $cbox['contenty'], 0.000001);
}
/**
* @throws \Throwable
*/
public function testGetHTMLCellContentBoxClampsContentSize(): void
{
$obj = $this->getInternalTestObject();
$this->initFontAndPage($obj);
// Paddings larger than the cell box must clamp the content size to zero.
$cellctx = [
'margin' => ['T' => 0.0, 'R' => 0.0, 'B' => 0.0, 'L' => 0.0],
'padding' => ['T' => 50.0, 'R' => 50.0, 'B' => 50.0, 'L' => 50.0],
'borderpos' => 0.0,
];
$cbox = $obj->exposeGetHTMLCellContentBox(0.0, 0.0, 1.0, 1.0, $cellctx);
$this->assertSame(1.0, $cbox['cellwidth']);
$this->assertSame(0.0, $cbox['contentw']);
$this->assertSame(0.0, $cbox['contenth']);
}
/**
* Regression: an opaque table-cell border (e.g. "border-bottom:1px solid
* red") must be stroked at full opacity even when the same cell uses a
* translucent RGBA text color. Previously the border inherited the text's
* alpha (0.4) because the stroke did not reset the graphics-state alpha.
*
* @throws \Throwable
*/
public function testHTMLTableCellBorderIgnoresTranslucentTextAlpha(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$html =
'<table border="1" cellpadding="3" cellspacing="0">'
. '<tr>'
. '<td style="border-bottom:1px solid red;">A</td>'
. '<td style="color:rgba(0,170,255,0.4);border-bottom:1px solid red;">B</td>'
. '</tr>'
. '</table>';
$frag = $obj->getHTMLCell($html, 0, 0, 80, 30);
$this->assertNotSame('', $frag);
// The alpha ExtGState (/GSn gs) most recently activated before $needle.
$lastAlphaBefore = static function (string $needle) use ($frag): ?string {
$pos = \strpos($frag, $needle);
if ($pos === false) {
return null;
}
$matches = [];
\preg_match_all('~/GS\d+ gs~', \substr($frag, 0, $pos), $matches);
$found = $matches[0] ?? [];
return $found === [] ? null : \end($found);
};
// The opaque cell text and the translucent RGBA cell text run under
// distinct alpha states, so the leak (if any) is observable.
$opaqueGs = $lastAlphaBefore('(A) Tj');
$translucentGs = $lastAlphaBefore('(B) Tj');
$this->assertNotNull($opaqueGs);
$this->assertNotNull($translucentGs);
$this->assertNotSame($opaqueGs, $translucentGs);
// Every red border stroke must be drawn under the opaque alpha state,
// never the translucent one carried over from the RGBA text color.
$redStroke = '1.000000 0.000000 0.000000 RG';
$offset = 0;
$strokes = 0;
while (($pos = \strpos($frag, $redStroke, $offset)) !== false) {
++$strokes;
$matches = [];
\preg_match_all('~/GS\d+ gs~', \substr($frag, 0, $pos), $matches);
$found = $matches[0] ?? [];
$this->assertNotSame([], $found, 'Border stroke is missing an alpha reset.');
$activeGs = (string) \end($found);
$this->assertSame($opaqueGs, $activeGs);
$this->assertNotSame($translucentGs, $activeGs);
$offset = $pos + \strlen($redStroke);
}
$this->assertGreaterThanOrEqual(2, $strokes);
}
/**
* Text using a translucent RGBA color activates an alpha < 1 ExtGState.
* The cell must restore an opaque alpha at the end so the transparency
* does not leak into operations appended afterwards (e.g. raw graph
* primitives that do not reset the graphics-state alpha themselves).
*
* @throws \Throwable
*/
public function testHTMLCellRestoresOpaqueAlphaAfterTranslucentText(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$cell = $obj->getHTMLCell('<span style="color:rgba(255,0,0,0.5);">TEST</span>', 0, 0, 80, 20);
$this->assertNotSame('', $cell);
// The alpha ExtGState (/GSn gs) commands emitted while rendering the cell.
$matches = [];
\preg_match_all('~/GS\d+ gs~', $cell, $matches);
$cellGs = $matches[0] ?? [];
// At least the translucent text alpha and the trailing opaque reset.
$this->assertGreaterThanOrEqual(2, \count($cellGs));
$translucentGs = $cellGs[0] ?? '';
$resetGs = $cellGs === [] ? '' : \end($cellGs);
$this->assertNotSame($translucentGs, $resetGs);
// A raw graph line drawn right after the cell does not manage alpha on
// its own, so it must inherit the opaque reset, never the translucent
// state carried over from the RGBA text color.
$line = $obj->graph->getLine(0, 10, 80, 10, ['lineWidth' => 1, 'lineColor' => 'rgb(255,0,0)']);
$frag = $cell . $line;
$redStroke = '1.000000 0.000000 0.000000 RG';
$pos = \strpos($frag, $redStroke);
$this->assertNotFalse($pos);
$before = [];
\preg_match_all('~/GS\d+ gs~', \substr($frag, 0, (int) $pos), $before);
$found = $before[0] ?? [];
$activeGs = $found === [] ? null : \end($found);
$this->assertSame($resetGs, $activeGs);
$this->assertNotSame($translucentGs, $activeGs);
}
/** @throws \Throwable */
public function testGetHTMLCellAppliesCssFontStretchAndLetterSpacing(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$out = $obj->getHTMLCell('<span style="font-stretch:90%;letter-spacing:-0.254mm;">LEFT</span>', 10, 20, 120, 0);
// CSS font-stretch:90% must reach the page as a 90% Tz; before the fix the
// HTML engine never forwarded stretching to font->insert() so it was dropped.
$this->assertStringContainsString('90.000000 Tz', $out);
// CSS letter-spacing must reach the page as a non-zero, negative Tc.
$this->assertMatchesRegularExpression('/-\d+\.\d+ Tc/', $out);
}
/** @throws \Throwable */
public function testGetHTMLCellRestoresStretchedAndSpacedCallerFontState(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
$font = $this->getObjectProperty($obj, 'font');
/** @var int $pon */
$pon = $this->getObjectProperty($obj, 'pon');
// Caller font carries non-neutral spacing (0.5pt) and stretching (0.8 ratio).
$font->insert($pon, 'helvetica', '', 12, 0.5, 0.8);
$obj->getHTMLCell('<p>Hello <b>bold</b></p>', 10, 20, 120, 0);
// After the HTML cell, the caller font is restored with its spacing and
// stretching, not silently reset to 0/1.0.
$after = $font->getCurrentFont();
$this->assertEqualsWithDelta(0.8, $after['stretching'], 1e-9);
$this->assertEqualsWithDelta(0.5, $after['spacing'], 1e-9);
}
/**
* Stage 2a: the HTML inline head-extraction sites must return the LOGICALLY-first
* chunk for an RTL fragment, not the front of the post-Bidi visual array.
* splitHTMLJustifyFirstLine() splits a wrapping fragment at its first visual line:
* for an RTL base the head is the logically-first words (rendered on the top line)
* and the tail the logically-last words. The LTR control keeps the leading words in
* the head, proving the change is gated by base direction.
*
* @throws \Throwable
*/
public function testSplitHTMLJustifyFirstLineExtractsLogicalHeadForRtl(): void
{
$obj = $this->getInternalTestObject();
$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']);
$this->setObjectProperty($obj, 'isunicode', true);
// Twelve distinct Hebrew "words" (each three identical letters) in logical
// order. Pure RTL + spaces, so the Bidi visual array is the exact reverse of
// the logical one; the first word belongs on the top line, the last on a
// later line.
$word = static fn(int $cp): string => \str_repeat((string) \mb_chr($cp), 3);
$firstWord = $word(0x05D0);
$lastWord = $word(0x05DB);
$rtlText = \implode(' ', \array_map($word, \range(0x05D0, 0x05DB)));
// Narrow enough (relative to the ~90mm paragraph) to force several lines.
$split = $obj->exposeSplitHTMLJustifyFirstLine($rtlText, 'R', 30.0);
$this->assertIsArray($split, 'A wrapping RTL paragraph must split into head and tail.');
[$head, $tail] = $split;
// Head holds the logically-FIRST word and not the logically-last; the tail
// holds the logically-last word and not the first. With the old front-of-array
// slice the head would have captured the logically-LAST words instead.
$this->assertStringContainsString($firstWord, $head);
$this->assertStringNotContainsString($lastWord, $head);
$this->assertStringContainsString($lastWord, $tail);
$this->assertStringNotContainsString($firstWord, $tail);
// LTR control: head is still the leading words, tail the trailing ones.
$ltrWords = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india', 'juliett'];
$ltrSplit = $obj->exposeSplitHTMLJustifyFirstLine(\implode(' ', $ltrWords), '', 30.0);
$this->assertIsArray($ltrSplit);
[$ltrHead, $ltrTail] = $ltrSplit;
$this->assertStringStartsWith('alpha', $ltrHead);
$this->assertStringContainsString('juliett', $ltrTail);
$this->assertStringNotContainsString('juliett', $ltrHead);
}
/**
* RTL inter-fragment engine: a uniform-RTL multi-fragment inline run that fits
* on one line lays out right-to-left — the first logical fragment hugs the right
* edge and each subsequent fragment is placed to its left. Verified end-to-end
* through getHTMLCell() by the strictly-decreasing text-placement abscissas, and
* the colored span keeps its own fill color.
*
* @throws \Throwable
*/
public function testRtlInlineRunRendersFragmentsRightToLeft(): 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']);
// Three short Hebrew "words" (each three identical letters); the middle one in
// a red span. Short enough to fit one line at width 180, so the engine engages.
$word = static fn(int $cp): string => \str_repeat((string) \mb_chr($cp), 3);
$html =
'<p dir="rtl" style="direction:rtl;text-align:right">'
. $word(0x05D0)
. ' '
. '<span color="#ff0000">'
. $word(0x05D1)
. '</span>'
. ' '
. $word(0x05D2)
. '</p>';
$out = $obj->getHTMLCell($html, 15, 20, 180);
// Text-placement abscissas in render (logical) order: one per fragment.
$matches = [];
\preg_match_all('/(-?\d+\.?\d*) -?\d+\.?\d* Td/', $out, $matches);
$xs = \array_map(static fn(string $v): float => \is_numeric($v) ? (float) $v : 0.0, $matches[1] ?? []);
$this->assertCount(3, $xs, 'the three RTL fragments are each placed once');
// Right-to-left: the logical render order is strictly descending in abscissa,
// i.e. each subsequent fragment sits to the left of the previous one.
$descending = $xs;
\rsort($descending);
$this->assertSame($descending, $xs, 'RTL fragments must be placed right-to-left');
$this->assertCount(3, \array_unique($xs), 'fragments must not overlap');
// The middle fragment is emitted with the span's red fill color.
/** @var \Com\Tecnick\Color\Pdf $color */
$color = $this->getObjectProperty($obj, 'color');
$this->assertStringContainsString(\trim($color->getPdfFillColor('#ff0000')), $out);
}
/**
* RTL inter-fragment engine, wrapping case: a long multi-fragment RTL paragraph
* (base text + colored span) breaks across fragment boundaries into several
* lines, each laid out right-to-left and stacked top-down. Verified through
* getHTMLCell() by grouping the text placements per baseline: there are multiple
* lines, and within each line the abscissas strictly decrease (right-to-left).
*
* @throws \Throwable
*/
public function testRtlInlineRunWrapsTopDownRightToLeft(): 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']);
// A long Hebrew paragraph with a red span in the middle; the narrow cell
// forces wrapping across the fragment boundary.
$word = static fn(int $cp): string => \str_repeat((string) \mb_chr($cp), 4);
$body = '';
for ($i = 0; $i < 12; ++$i) {
$body .= $word(0x05D0 + ($i % 10)) . ' ';
}
$body = \rtrim($body);
$html =
'<p dir="rtl" style="direction:rtl;text-align:right">'
. $body
. ' <span color="#ff0000">'
. $word(0x05DC)
. '</span> '
. $body
. '</p>';
$out = $obj->getHTMLCell($html, 15, 20, 70);
$matches = [];
\preg_match_all('/(-?\d+\.?\d*) (-?\d+\.?\d*) Td/', $out, $matches);
$xs = \array_map(static fn(string $v): float => \is_numeric($v) ? (float) $v : 0.0, $matches[1] ?? []);
$ys = \array_map(static fn(string $v): float => \is_numeric($v) ? (float) $v : 0.0, $matches[2] ?? []);
$this->assertNotEmpty($ys);
// Group placements by baseline; each baseline is a visual line.
/** @var array<string, array<int, float>> $byLine */
$byLine = [];
foreach ($ys as $i => $y) {
$byLine[(string) \round($y, 1)][] = $xs[$i] ?? 0.0;
}
$this->assertGreaterThanOrEqual(2, \count($byLine), 'the run must wrap to multiple lines');
// Each line is laid out right-to-left: abscissas strictly descending.
foreach ($byLine as $lineXs) {
$descending = $lineXs;
\rsort($descending);
$this->assertSame($descending, $lineXs, 'each wrapped line must be right-to-left');
}
/** @var \Com\Tecnick\Color\Pdf $color */
$color = $this->getObjectProperty($obj, 'color');
$this->assertStringContainsString(\trim($color->getPdfFillColor('#ff0000')), $out);
}
/**
* RTL inter-fragment engine, inline image (Stage 2c): an <img> embedded between
* two RTL text fragments is placed as an atomic box on the right-to-left line —
* the first logical fragment hugs the right edge, the image sits to its left and
* the trailing fragment to the image's left. Verified through getHTMLCell() by
* the descending text abscissas and the image's translate-x falling strictly
* between the two text fragments.
*
* @throws \Throwable
*/
public function testRtlInlineRunPlacesInlineImageRightToLeft(): 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']);
// Minimal valid in-memory PNG used as the inline image source.
$img = \imagecreate(8, 8);
\imagecolorallocate($img, 255, 0, 0);
\ob_start();
\imagepng($img);
$raw = \ob_get_clean();
$b64src = 'data:image/png;base64,' . \base64_encode((string) $raw);
// Two short Hebrew "words" with an inline image between them; short enough to
// fit one line at width 180, so the RTL inter-fragment engine engages.
$word = static fn(int $cp): string => \str_repeat((string) \mb_chr($cp), 3);
$html =
'<p dir="rtl" style="direction:rtl;text-align:right">'
. $word(0x05D0)
. ' <img src="'
. $b64src
. '" width="8" height="8" /> '
. $word(0x05D1)
. '</p>';
$out = $obj->getHTMLCell($html, 15, 20, 180);
// The two text fragments are each placed once, right-to-left (descending x):
// the logically-first fragment is rendered first and sits to the right.
$matches = [];
\preg_match_all('/(-?\d+\.?\d*) -?\d+\.?\d* Td/', $out, $matches);
$xs = \array_map(static fn(string $v): float => \is_numeric($v) ? (float) $v : 0.0, $matches[1] ?? []);
$this->assertCount(2, $xs, 'the two RTL text fragments are each placed once');
$firstX = $xs[0] ?? 0.0;
$secondX = $xs[1] ?? 0.0;
$this->assertGreaterThan($secondX, $firstX, 'RTL text fragments must be placed right-to-left');
// The inline image is drawn exactly once; capture its translate-x (5th cm arg).
$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_all($imgPattern, $out, $imgMatch), 'the inline image is drawn once');
$imgxRaw = $imgMatch[1][0] ?? null;
$imgx = \is_numeric($imgxRaw) ? (float) $imgxRaw : 0.0;
// Logical order [text1, image, text2] lays out right-to-left: text1 rightmost,
// image to its left, text2 leftmost. So text2.x < image.x < text1.x.
$this->assertGreaterThan($secondX, $imgx, 'image must sit left of the first (rightmost) fragment');
$this->assertLessThan($firstX, $imgx, 'image must sit right of the last (leftmost) fragment');
}
/**
* RTL inter-fragment engine, broken inline image: when the image source cannot be
* drawn the engine degrades like the forward renderer — it draws the alt text in
* the reserved slot rather than crashing — and the surrounding text fragments
* still lay out right-to-left. Verified by the absence of any image XObject and a
* third (alt-text) placement between the two fragments, all right-to-left.
*
* @throws \Throwable
*/
public function testRtlInlineRunBrokenImageFallsBackToAltText(): 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']);
$word = static fn(int $cp): string => \str_repeat((string) \mb_chr($cp), 3);
$html =
'<p dir="rtl" style="direction:rtl;text-align:right">'
. $word(0x05D0)
. ' <img src="/tmp/__tc_lib_pdf_missing_rtl_image__.png" width="8" height="8" alt="altimg" /> '
. $word(0x05D1)
. '</p>';
$out = $obj->getHTMLCell($html, 15, 20, 180);
// No image XObject is drawn (the source is unreadable).
$imgDraws = [];
$this->assertSame(0, \preg_match_all('/\/IMG\d+\s+Do/', $out, $imgDraws), 'a broken image must not be drawn');
// Three placements (text1, alt text, text2), all right-to-left (descending x).
$matches = [];
\preg_match_all('/(-?\d+\.?\d*) -?\d+\.?\d* Td/', $out, $matches);
$xs = \array_map(static fn(string $v): float => \is_numeric($v) ? (float) $v : 0.0, $matches[1] ?? []);
$this->assertCount(3, $xs, 'the two fragments plus the alt-text fallback are each placed once');
$descending = $xs;
\rsort($descending);
$this->assertSame($descending, $xs, 'the run (incl. alt fallback) must read right-to-left');
}
/**
* RTL inter-fragment engine, leading inline image (Stage 2c): an RTL paragraph
* that begins with an <img> places that logically-first image at the right edge
* of the line, with the following text fragments flowing to its left. The engine
* is dispatched on the leading image (before the forward cursor would place it),
* so the whole run is captured and laid out right-to-left. Verified through
* getHTMLCell() by the image's translate-x being greater than every text abscissa.
*
* @throws \Throwable
*/
public function testRtlInlineRunLeadingImageHugsRightEdge(): 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']);
$img = \imagecreate(8, 8);
\imagecolorallocate($img, 0, 0, 255);
\ob_start();
\imagepng($img);
$raw = \ob_get_clean();
$b64src = 'data:image/png;base64,' . \base64_encode((string) $raw);
// Paragraph starts with the image, then two RTL text fragments.
$word = static fn(int $cp): string => \str_repeat((string) \mb_chr($cp), 3);
$html =
'<p dir="rtl" style="direction:rtl;text-align:right">'
. '<img src="'
. $b64src
. '" width="8" height="8" /> '
. $word(0x05D0)
. ' <span color="#ff0000">'
. $word(0x05D1)
. '</span>'
. '</p>';
$out = $obj->getHTMLCell($html, 15, 20, 180);
// The leading image is drawn once; capture its translate-x (5th cm arg).
$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_all($imgPattern, $out, $imgMatch), 'the leading image is drawn once');
$imgxRaw = $imgMatch[1][0] ?? null;
$imgx = \is_numeric($imgxRaw) ? (float) $imgxRaw : 0.0;
// The two text fragments are placed once each, right-to-left (descending x).
$matches = [];
\preg_match_all('/(-?\d+\.?\d*) -?\d+\.?\d* Td/', $out, $matches);
$xs = \array_map(static fn(string $v): float => \is_numeric($v) ? (float) $v : 0.0, $matches[1] ?? []);
$this->assertCount(2, $xs, 'the two RTL text fragments are each placed once');
$maxTextX = $xs === [] ? 0.0 : \max($xs);
// The logically-first (leading) image hugs the right edge: its abscissa is to
// the right of every text fragment.
$this->assertGreaterThan($maxTextX, $imgx, 'the leading image must sit to the right of all text');
}
/**
* RTL inter-fragment engine, region/page continuation (Stage 2e): a tall
* multi-fragment RTL paragraph added with addHTMLCell() low on the page overflows
* onto the next page, and the engine flows the remaining right-to-left lines into
* the new page instead of falling back to the forward-cursor renderer. Verified by
* inspecting each spanned page's content stream: every visual line reads
* right-to-left (descending abscissas) and multi-fragment lines remain so on the
* continued page (the forward fallback would order fragments left-to-right).
*
* @throws \Throwable
*/
public function testRtlInlineRunFlowsAcrossPageBreak(): 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']);
$obj->page->enableAutoPageBreak(true);
$startpid = (int) $obj->page->getPageId();
// A tall multi-fragment Hebrew paragraph (base text + colored span + base
// text) placed near the page bottom so it cannot fit and must continue.
$word = static fn(int $cp): string => \str_repeat((string) \mb_chr($cp), 4);
$body = '';
for ($i = 0; $i < 80; ++$i) {
$body .= $word(0x05D0 + ($i % 10)) . ' ';
}
$body = \rtrim($body);
$html =
'<p dir="rtl" style="direction:rtl;text-align:right">'
. $body
. ' <span color="#ff0000">'
. $word(0x05DC)
. '</span> '
. $body
. '</p>';
$obj->addHTMLCell($html, 15, 250, 90);
$endpid = (int) $obj->page->getPageId();
$this->assertGreaterThan($startpid, $endpid, 'the tall RTL paragraph must span more than one page');
$multiFragLines = 0;
for ($pid = $startpid; $pid <= $endpid; ++$pid) {
$stream = \implode('', $obj->page->getPage($pid)['content']);
$m = [];
\preg_match_all('/(-?\d+\.?\d*) (-?\d+\.?\d*) Td/', $stream, $m);
$xsAll = $m[1] ?? [];
$ysAll = $m[2] ?? [];
if ($xsAll === []) {
continue;
}
/** @var array<string, array<int, float>> $byLine */
$byLine = [];
foreach ($xsAll as $i => $x) {
$yRaw = $ysAll[$i] ?? null;
$yKey = (string) \round(\is_numeric($yRaw) ? (float) $yRaw : 0.0, 1);
$byLine[$yKey][] = \is_numeric($x) ? (float) $x : 0.0;
}
foreach ($byLine as $lineXs) {
if (\count($lineXs) >= 2) {
++$multiFragLines;
}
$descending = $lineXs;
\rsort($descending);
$this->assertSame($descending, $lineXs, "page {$pid}: each line must read right-to-left");
}
}
$this->assertGreaterThan(
0,
$multiFragLines,
'the continued RTL run must keep its multi-fragment lines right-to-left',
);
}
/**
* A <thead> row narrower than the first body row sets the table column
* count from the header; the wider body row then drives the column index
* past that count. The column-width computation must not divide by a zero
* colspan in that case (regression: DivisionByZeroError).
*
* @throws \Throwable
*/
public function testAddHTMLCellTheadNarrowerThanBodyDoesNotDivideByZero(): void
{
$obj = $this->getTestObject();
$this->initFontAndPage($obj);
$html =
'<table border="1">'
. '<thead><tr><th colspan="2">Header</th></tr></thead>'
. '<tbody><tr><td>a</td><td>b</td><td>c</td><td>d</td></tr></tbody>'
. '</table>';
$obj->addHTMLCell($html, 10, 10, 180, 0);
$this->assertNotSame('', $obj->getOutPDFString());
}
/**
* Build an uncompressed PDF/UA-1 document so the structure tree and page
* content streams can be inspected as plain text.
*
* @throws \Throwable
*/
private function getPdfUaUncompressedObject(): \Com\Tecnick\Pdf\Tcpdf
{
return new \Com\Tecnick\Pdf\Tcpdf(
unit: 'mm',
isunicode: true,
subsetfont: false,
compress: false,
mode: 'pdfua1',
);
}
/**
* A cell colspan must be recorded on the TD/TH structure element and
* serialised as a PDF integer (/ColSpan 2), not a name (/ColSpan /2),
* so assistive technology can reconstruct the table grid (PDF/UA-1 7.2).
*
* @throws \Throwable
*/
public function testAddHTMLCellTableColSpanSerializedAsPdfInteger(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$html =
'<table border="1">'
. '<tr><th>A</th><th>B</th></tr>'
. '<tr><td colspan="2">Spans two columns</td></tr>'
. '</table>';
$obj->addHTMLCell($html, 10, 10, 180, 0);
$out = $obj->getOutPDFString();
$this->assertStringContainsString('/ColSpan 2', $out);
$this->assertStringNotContainsString('/ColSpan /2', $out);
$this->assertStringContainsString('/O /Table', $out);
}
/**
* A cell rowspan must be recorded and serialised as a PDF integer.
*
* @throws \Throwable
*/
public function testAddHTMLCellTableRowSpanSerializedAsPdfInteger(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$html =
'<table border="1">'
. '<tr><td rowspan="2">Spans two rows</td><td>a</td></tr>'
. '<tr><td>b</td></tr>'
. '</table>';
$obj->addHTMLCell($html, 10, 10, 180, 0);
$out = $obj->getOutPDFString();
$this->assertStringContainsString('/RowSpan 2', $out);
$this->assertStringNotContainsString('/RowSpan /2', $out);
}
/**
* An empty table cell must remain in the structure tree so the row keeps
* its full column count and the table matrix stays regular (PDF/UA-1 7.2).
* Before the fix, endStructElem() dropped the empty TD.
*
* @throws \Throwable
*/
public function testAddHTMLCellEmptyTableCellKeptInStructureTree(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$html =
'<table border="1">'
. '<tr><th>A</th><th>B</th><th>C</th></tr>'
. '<tr><td>x</td><td></td><td>y</td></tr>'
. '</table>';
$obj->addHTMLCell($html, 10, 10, 180, 0);
$out = $obj->getOutPDFString();
// The body row must contribute three TD structure elements even though
// the middle cell is empty.
$tdCount = \preg_match_all('~/S /TD\b~', $out);
$this->assertSame(3, $tdCount, 'Empty middle cell must remain a TD structure element');
}
/**
* A horizontal rule carries no semantics, so in PDF/UA mode its stroke must
* be marked as an Artifact rather than left as untagged real content
* (PDF/UA-1 7.1).
*
* @throws \Throwable
*/
public function testAddHTMLCellHorizontalRuleTaggedAsArtifact(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$obj->addHTMLCell('<p>Above</p><hr><p>Below</p>', 10, 10, 180, 0);
$out = $obj->getOutPDFString();
$this->assertStringContainsString('/Artifact', $out);
// The rule must not create a structure element of its own.
$this->assertStringNotContainsString('/S /HR', $out);
}
/**
* A table that breaks across pages must keep exactly one Table structure
* element with all its rows attached; the header is tagged once and the
* repeated headers on continuation pages are Artifacts, not new TH
* structure elements (PDF/UA-1 7.2). This is the regression for the nested,
* never-closed Table subtree produced by the header replay.
*
* @throws \Throwable
*/
public function testAddHTMLCellPageSpanningTableTagsHeaderOnceAndKeepsSingleTable(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$rowCount = 120;
$rows = '';
for ($i = 1; $i <= $rowCount; ++$i) {
$rows .= '<tr><td>R' . $i . 'A</td><td>R' . $i . 'B</td></tr>';
}
$html = '<table border="1"><thead><tr><th>H1</th><th>H2</th></tr></thead>' . $rows . '</table>';
$obj->addHTMLCell($html, 10, 10, 180, 0);
$out = $obj->getOutPDFString();
$pageCount = \preg_match_all('~/Type\s*/Page\b~', $out);
$this->assertGreaterThanOrEqual(2, $pageCount, 'Table must span at least two pages');
$this->assertSame(1, \preg_match_all('~/S /Table\b~', $out), 'Exactly one Table structure element');
$this->assertSame(2, \preg_match_all('~/S /TH\b~', $out), 'Header cells are tagged once only');
$this->assertSame(
$rowCount + 1,
\preg_match_all('~/S /TR\b~', $out),
'All rows (one header + body rows) attach to the single Table',
);
$this->assertSame(2 * $rowCount, \preg_match_all('~/S /TD\b~', $out), 'Every body cell is tagged once');
}
/**
* The measurement and header-replay passes must not advance the per-page
* MCID counter or append phantom structure elements: every marked-content
* reference (MCR) in the structure tree must point at a marked-content
* sequence (BDC) that actually exists in a page content stream.
*
* @throws \Throwable
*/
public function testAddHTMLCellPageSpanningTableHasConsistentMcidReferences(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$rows = '';
for ($i = 1; $i <= 120; ++$i) {
$rows .= '<tr><td>R' . $i . 'A</td><td>R' . $i . 'B</td></tr>';
}
$html = '<table border="1"><thead><tr><th>H1</th><th>H2</th></tr></thead>' . $rows . '</table>';
$obj->addHTMLCell($html, 10, 10, 180, 0);
$out = $obj->getOutPDFString();
$declaredBdc = \preg_match_all('~<</MCID\s+\d+~', $out);
$referencedMcr = \preg_match_all('~/Type\s*/MCR\b[^>]*?/MCID\s+\d+~', $out);
$this->assertGreaterThan(0, $referencedMcr);
$this->assertSame(
$declaredBdc,
$referencedMcr,
'Each structure-tree MCID reference must match exactly one content-stream BDC',
);
}
/**
* A header cell with scope="row" must serialise as /Scope /Row, while the
* absent default and scope="col" stay /Scope /Column.
*
* @throws \Throwable
*/
public function testAddHTMLCellTableHeaderScopeIsHonored(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$html =
'<table border="1">'
. '<tr><th scope="col">Col head</th><th>Plain head</th></tr>'
. '<tr><th scope="row">Row head</th><td>data</td></tr>'
. '</table>';
$obj->addHTMLCell($html, 10, 10, 180, 0);
$out = $obj->getOutPDFString();
$this->assertSame(1, \preg_match_all('~/Scope /Row\b~', $out), 'scope="row" must map to /Scope /Row');
// Two column headers: the explicit scope="col" and the default header cell.
$this->assertSame(
2,
\preg_match_all('~/Scope /Column\b~', $out),
'col and default headers map to /Scope /Column',
);
}
/**
* Explicit id/headers markup on cells must be passed through to the
* structure element as /ID and /Headers so complex tables can declare
* header associations from HTML.
*
* @throws \Throwable
*/
public function testAddHTMLCellTableHeaderAssociationPassedThrough(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$html =
'<table border="1">'
. '<tr><th id="hc1">A</th><th id="hc2">B</th></tr>'
. '<tr><td headers="hc1">1</td><td headers="hc2">2</td></tr>'
. '</table>';
$obj->addHTMLCell($html, 10, 10, 180, 0);
$out = $obj->getOutPDFString();
// The header ids are promoted to /ID entries and the data cells to /Headers.
$this->assertSame(2, \preg_match_all('~/Type /StructElem /S /TH[^>]*?/ID ~s', $out), 'Both TH carry an /ID');
$this->assertStringContainsString('/Headers [', $out);
// ID and Headers must not leak into the /A attribute dictionary as names.
$this->assertStringNotContainsString('/ID /hc1', $out);
}
/**
* An inline background fill carries no semantics, so in PDF/UA mode it must
* be marked as an Artifact rather than emitted as untagged content
* (PDF/UA-1 7.1).
*
* @throws \Throwable
*/
public function testAddHTMLCellInlineBackgroundTaggedAsArtifact(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$obj->addHTMLCell(
'<p>before <span style="background-color:#ffff00;">highlighted</span> after</p>',
10,
10,
180,
0,
);
$out = $obj->getOutPDFString();
$this->assertStringContainsString('/Artifact', $out);
// Marked content must stay balanced once the background is wrapped.
$this->assertSame(
\preg_match_all('~\bB(DC|MC)\b~', $out),
\preg_match_all('~\bEMC\b~', $out),
'BDC/BMC and EMC operators must balance',
);
}
/**
* Text-decoration lines (underline, line-through) and link underlines must be
* emitted inside the text run's marked content, not as untagged path painting
* after the EMC (PDF/UA-1 7.1).
*
* @throws \Throwable
*/
public function testAddHTMLCellUnderlineDecorationIsInsideMarkedContent(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$obj->addHTMLCell('<p>plain <u>underlined</u> word.</p>', 10, 10, 180, 0);
$out = $obj->getOutPDFString();
// The decoration must paint a fill rectangle...
$this->assertMatchesRegularExpression('~re\s+f~', $out, 'underline must paint a fill rectangle');
// ...but never as the first painting after an EMC (the pre-fix bug signature).
$this->assertDoesNotMatchRegularExpression(
'~\bEMC\b\s+[-\d.]+\s+[-\d.]+\s+[-\d.]+\s+[-\d.]+\s+re\s+f~',
$out,
'decoration fill must not be emitted as untagged content after EMC',
);
}
/**
* By default <code>/<pre> use the standard-14 Courier font.
*
* @throws \Throwable
*/
public function testHTMLCodeUsesCourierByDefault(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$obj->addHTMLCell('<p><code>mono()</code></p>', 10, 10, 180, 0);
$this->assertStringContainsString('Courier', $obj->getOutPDFString());
}
/**
* setHTMLMonospaceFont() replaces the non-embedded Courier default for
* <code>/<pre> with the chosen (embeddable) font, as required for PDF/UA.
*
* @throws \Throwable
*/
public function testSetHTMLMonospaceFontReplacesCourierForCode(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$obj->font->insert($obj->pon, 'dejavusansmono', '', 10);
$obj->setHTMLMonospaceFont('dejavusansmono');
$obj->addHTMLCell('<p><code>mono()</code></p>', 10, 10, 180, 0);
$this->assertStringNotContainsString('Courier', $obj->getOutPDFString());
}
/**
* An explicit CSS font-family on a code element wins over the monospace
* default, so authors can pick an embedded font without the setter.
*
* @throws \Throwable
*/
public function testHTMLCodeFontFamilyCssOverridesMonospaceDefault(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
$obj->font->insert($obj->pon, 'dejavusansmono', '', 10);
$obj->addHTMLCell('<style>code{font-family:dejavusansmono;}</style><p><code>mono()</code></p>', 10, 10, 180, 0);
$this->assertStringNotContainsString('Courier', $obj->getOutPDFString());
}
/**
* When a tagged element's text wraps across a page break, each marked-content
* reference must be mapped to the page it was actually emitted on. Otherwise
* the MCIDs on the continuation page are orphaned in the ParentTree
* (PDF/UA-1 7.1). Every MCR's /Pg must match the page whose content stream
* declares that MCID.
*
* @throws \Throwable
*/
public function testPageSpanningTextMapsMcidsToCorrectPage(): void
{
$obj = $this->getPdfUaUncompressedObject();
$this->initFontAndPage($obj);
// Enough paragraphs to force a page break mid-flow.
$html = '';
for ($i = 1; $i <= 90; ++$i) {
$html .= '<p>Paragraph ' . $i . ' with enough words to fill the line and flow down the page.</p>';
}
$obj->addHTMLCell($html, 10, 10, 180, 0);
$out = $obj->getOutPDFString();
// The document must span more than one page.
$this->assertGreaterThanOrEqual(2, \preg_match_all('~/Type\s*/Page\b~', $out));
// Map each page object to its content-stream object (/Contents N 0 R).
$pageContents = [];
$pm = [];
\preg_match_all('~(\d+) 0 obj\s*<<\s*/Type /Page\b.*?/Contents (\d+) 0 R~s', $out, $pm, \PREG_SET_ORDER);
foreach ($pm as $row) {
$pageContents[(int) ($row[1] ?? '0')] = (int) ($row[2] ?? '0');
}
// Collect the MCIDs each content stream declares via BDC.
$streamMcids = [];
$stm = [];
\preg_match_all(
'~(\d+) 0 obj\s*<< /Length \d+ >>\s*stream\r?\n(.*?)\r?\nendstream~s',
$out,
$stm,
\PREG_SET_ORDER,
);
foreach ($stm as $row) {
$mm = [];
\preg_match_all('~<</MCID (\d+)~', $row[2] ?? '', $mm);
$ids = [];
foreach ($mm[1] ?? [] as $id) {
$ids[] = (int) $id;
}
$streamMcids[(int) ($row[1] ?? '0')] = $ids;
}
// Every MCR's (page, mcid) must be backed by a BDC on that page's stream.
$orphans = 0;
$rm = [];
\preg_match_all('~/Type /MCR /Pg (\d+) 0 R /MCID (\d+)~', $out, $rm, \PREG_SET_ORDER);
foreach ($rm as $row) {
$stream = $pageContents[(int) ($row[1] ?? '0')] ?? 0;
if (!\in_array((int) ($row[2] ?? '0'), $streamMcids[$stream] ?? [], true)) {
++$orphans;
}
}
$this->assertGreaterThan(0, \count($rm), 'sanity: MCRs must be present');
$this->assertSame(0, $orphans, 'every MCR must reference an MCID declared on its own page');
}
}