2969 lines
105 KiB
PHP
2969 lines
105 KiB
PHP
<?php
|
|
|
|
/**
|
|
* TextTest.php
|
|
*
|
|
* @since 2002-08-03
|
|
* @category Library
|
|
* @package Pdf
|
|
* @author Nicola Asuni <info@tecnick.com>
|
|
* @copyright 2002-2026 Nicola Asuni - Tecnick.com LTD
|
|
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
|
|
* @link https://github.com/tecnickcom/tc-lib-pdf
|
|
*
|
|
* This file is part of tc-lib-pdf software library.
|
|
*/
|
|
|
|
namespace Test;
|
|
|
|
/**
|
|
* @phpstan-import-type TTextDims from \Com\Tecnick\Pdf\Font\Stack
|
|
*/
|
|
class TextTest extends TestUtil
|
|
{
|
|
public static function setUpBeforeClass(): void
|
|
{
|
|
if (!\defined('K_PATH_FONTS')) {
|
|
$fonts = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts');
|
|
\define('K_PATH_FONTS', $fonts);
|
|
}
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
protected function getTestObject(): \Com\Tecnick\Pdf\Tcpdf
|
|
{
|
|
return new \Com\Tecnick\Pdf\Tcpdf();
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
protected function getInternalTestObject(): TestableText
|
|
{
|
|
return new TestableText();
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
private function initUnicodeFont(\Com\Tecnick\Pdf\Tcpdf $obj): void
|
|
{
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
/** @var int $pon */
|
|
$pon = $this->getObjectProperty($obj, 'pon');
|
|
$fontfile = (string) \realpath(__DIR__
|
|
. '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/dejavu/dejavusans.json');
|
|
$font->insert($pon, 'dejavusans', '', 10, null, null, $fontfile);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $page
|
|
*/
|
|
private function requirePageId(array $page): int
|
|
{
|
|
if (!isset($page['pid']) || !\is_int($page['pid'])) {
|
|
$this->fail('Expected addPage to return an integer pid');
|
|
}
|
|
|
|
return $page['pid'];
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetLastBBoxDefaultsToZeroBox(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
|
|
$this->assertSame(
|
|
[
|
|
'x' => 0.0,
|
|
'y' => 0.0,
|
|
'w' => 0.0,
|
|
'h' => 0.0,
|
|
],
|
|
$obj->getLastBBox(),
|
|
);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetLastTextBBoxDefaultsToZeroBox(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
|
|
$this->assertSame(
|
|
[
|
|
'x' => 0.0,
|
|
'y' => 0.0,
|
|
'w' => 0.0,
|
|
'h' => 0.0,
|
|
],
|
|
$obj->getLastTextBBox(),
|
|
);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetLastCellBBoxDefaultsToZeroBox(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
|
|
$this->assertSame(
|
|
[
|
|
'x' => 0.0,
|
|
'y' => 0.0,
|
|
'w' => 0.0,
|
|
'h' => 0.0,
|
|
],
|
|
$obj->getLastCellBBox(),
|
|
);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testLoadTexHyphenPatternsParsesFixture(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$file = __DIR__ . '/fixtures/hyphen-test.tex';
|
|
|
|
$patterns = $obj->loadTexHyphenPatterns($file);
|
|
|
|
$this->assertArrayHasKey('hyphen', $patterns);
|
|
$this->assertArrayHasKey('testing', $patterns);
|
|
$this->assertArrayHasKey('abc', $patterns);
|
|
$this->assertSame('hy4phen', $patterns['hyphen'] ?? null);
|
|
$this->assertSame('test1ing', $patterns['testing'] ?? null);
|
|
$this->assertSame('a1bc', $patterns['abc'] ?? null);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSetTexHyphenPatternsStoresPatterns(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$patterns = ['hyphen' => 'hy4phen'];
|
|
$obj->setTexHyphenPatterns($patterns);
|
|
|
|
$this->assertSame($patterns, $this->getObjectProperty($obj, 'hyphen_patterns'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testEnableZeroWidthBreakPointsTogglesFlag(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$obj->enableZeroWidthBreakPoints(true);
|
|
$this->assertTrue($this->getObjectProperty($obj, 'autozerowidthbreaks'));
|
|
|
|
$obj->enableZeroWidthBreakPoints(false);
|
|
$this->assertFalse($this->getObjectProperty($obj, 'autozerowidthbreaks'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddPageReturnsPageData(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
|
|
$page = $obj->addPage();
|
|
|
|
$this->assertArrayHasKey('pid', $page);
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
$this->assertSame($page['pid'] ?? null, $pageObj->getPageId());
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSetCurrentPageMovesToRequestedPageAndReturnsPageData(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
|
|
$first = $obj->addPage([
|
|
'orientation' => 'P',
|
|
'format' => 'A4',
|
|
]);
|
|
$second = $obj->addPage([
|
|
'orientation' => 'L',
|
|
'format' => 'A4',
|
|
]);
|
|
|
|
$firstPid = $this->requirePageId($first);
|
|
$secondPid = $this->requirePageId($second);
|
|
$this->assertNotSame($firstPid, $secondPid);
|
|
|
|
$page = $obj->setCurrentPage($firstPid);
|
|
|
|
$this->assertSame($firstPid, $page['pid']);
|
|
if (!isset($first['width'], $first['height'], $page['width'], $page['height'])) {
|
|
$this->fail('Expected page width/height to be available after setCurrentPage().');
|
|
}
|
|
|
|
$this->assertSame($first['width'], $page['width']);
|
|
$this->assertSame($first['height'], $page['height']);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
$this->assertSame($firstPid, $pageObj->getPageId());
|
|
|
|
$current = $obj->setCurrentPage();
|
|
$this->assertSame($firstPid, $current['pid']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testDefaultPageContentReturnsPdfCommands(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
$out = $obj->defaultPageContent($pid);
|
|
|
|
$this->assertNotSame('', $out);
|
|
$this->assertStringContainsString('BT', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testDefaultPageContentUsesFooterArtifactInPdfUa(): void
|
|
{
|
|
$obj = new \Com\Tecnick\Pdf\Tcpdf(mode: 'pdfua');
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
$out = $obj->defaultPageContent($pid);
|
|
|
|
$this->assertStringContainsString('/Artifact << /Type /Pagination /Subtype /Footer >> BDC', $out);
|
|
$this->assertStringContainsString('EMC', $out);
|
|
$this->assertStringNotContainsString('/MCID', $out);
|
|
}
|
|
|
|
/**
|
|
* Regression test:
|
|
*
|
|
* A custom defaultPageContent() that draws vector graphics through the graph
|
|
* component (here a line) must render correctly on the first page. The graph
|
|
* flips the Y axis against the page height; previously that height was still
|
|
* zero when the first page context was generated (it was only set after
|
|
* setPageContext()), so the first page emitted negative, off-page coordinates
|
|
* while every subsequent page reused the prior page height and rendered fine.
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
public function testCustomDefaultPageContentDrawsGraphicsOnFirstPage(): void
|
|
{
|
|
$obj = new class extends \Com\Tecnick\Pdf\Tcpdf {
|
|
public function defaultPageContent(int $pid = -1): string
|
|
{
|
|
if ($pid < 0) {
|
|
$pid = $this->page->getPageId();
|
|
}
|
|
|
|
// A horizontal line 18 user-units from the top of the page.
|
|
return $this->graph->getLine(10.0, 18.0, 100.0, 18.0, ['lineColor' => 'rgb(192,192,192)']);
|
|
}
|
|
};
|
|
|
|
$this->initFont($obj);
|
|
$obj->enableDefaultPageContent();
|
|
|
|
$firstPid = $this->requirePageId($obj->addPage());
|
|
$secondPid = $this->requirePageId($obj->addPage());
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
|
|
$firstY = $this->extractFirstMoveToY($pageObj->getPage($firstPid)['content']);
|
|
$secondY = $this->extractFirstMoveToY($pageObj->getPage($secondPid)['content']);
|
|
|
|
// The line must be drawn inside the page (positive Y), not above the top edge.
|
|
$this->assertGreaterThan(0.0, $firstY, 'First page header graphics must not be drawn off-page (issue #246).');
|
|
|
|
// The first page must match every subsequent page.
|
|
$this->assertEqualsWithDelta($secondY, $firstY, 0.0001);
|
|
}
|
|
|
|
/**
|
|
* Returns the Y coordinate of the first "moveto" (m) operator in a page content stream.
|
|
*
|
|
* @param array<array-key, string> $content
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
private function extractFirstMoveToY(array $content): float
|
|
{
|
|
$match = [];
|
|
if (\preg_match('/(?:^|\s)(\S+) (-?\d+\.\d+) m(?:\s|$)/', \implode('', $content), $match) !== 1) {
|
|
$this->fail('Expected a moveto (m) operator in the page content stream.');
|
|
}
|
|
|
|
$value = $match[2] ?? '0';
|
|
|
|
return \is_numeric($value) ? (float) $value : 0.0;
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testArtifactHelpersAndAddArtifactContent(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->assertSame('', $obj->beginArtifact('Pagination', 'Header'));
|
|
$this->assertSame('', $obj->endArtifact());
|
|
|
|
$pdfua = new \Com\Tecnick\Pdf\Tcpdf(mode: 'pdfua');
|
|
$this->initFont($pdfua);
|
|
$page = $pdfua->addPage();
|
|
|
|
$open = $pdfua->beginArtifact('Pagi nation', 'Head/er');
|
|
$this->assertSame('/Artifact << /Type /Pagination /Subtype /Header >> BDC' . "\n", $open);
|
|
$this->assertSame('EMC' . "\n", $pdfua->endArtifact());
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($pdfua, 'page');
|
|
$pid = $this->requirePageId($page);
|
|
$pdfua->addArtifactContent("q\nQ\n", $pid, 'Pagination', 'Header');
|
|
/** @var array<int, string> $content */
|
|
$content = $pageObj->getPage($pid)['content'];
|
|
|
|
$lastIdx = \count($content) - 1;
|
|
$this->assertGreaterThanOrEqual(0, $lastIdx);
|
|
$lastContent = $content[$lastIdx] ?? null;
|
|
$this->assertIsString($lastContent);
|
|
$this->assertStringContainsString('/Artifact << /Type /Pagination /Subtype /Header >> BDC', $lastContent);
|
|
$this->assertStringContainsString("q\nQ\n", $lastContent);
|
|
$this->assertStringContainsString('EMC', $lastContent);
|
|
$this->assertStringNotContainsString('/MCID', $lastContent);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testDefaultPageContentPreservesCurrentUnicodeFont(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$obj->enableDefaultPageContent();
|
|
$this->initUnicodeFont($obj);
|
|
$obj->addPage();
|
|
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
|
|
$this->assertSame('dejavusans', $font->getCurrentFontKey());
|
|
$this->assertTrue($font->isCurrentUnicodeFont());
|
|
|
|
$out = $obj->getTextCell('The quick brown fox', 1, 2, 20, 6, 0, 0, 'T', 'L');
|
|
|
|
$this->assertStringContainsString("\000T\000h\000e", $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextLineAndGetTextCellHandleBasicInput(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$this->assertSame('', $obj->getTextLine(''));
|
|
$this->assertSame('', $obj->getTextCell(''));
|
|
|
|
$line = $obj->getTextLine('Hello', 1, 2);
|
|
$cell = $obj->getTextCell('Hello', 1, 2, 20, 6, 0, 0, 'T', 'L');
|
|
|
|
$this->assertNotSame('', $line);
|
|
$this->assertNotSame('', $cell);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
private function textCellHeight(\Com\Tecnick\Pdf\Tcpdf $obj, string $txt): float
|
|
{
|
|
$obj->getTextCell($txt, 10, 20, 60, 0, 0, 0, 'T', 'L');
|
|
|
|
return $obj->getLastTextBBox()['h'];
|
|
}
|
|
|
|
/**
|
|
* Regression: a blank line inside a multi-line text cell must occupy a full
|
|
* line of vertical space. An empty line renders no glyphs, so getLastBBox()
|
|
* used to return the stale box of the previously rendered line and the blank
|
|
* line collapsed (the following line jumped up to overlap it). outTextLines()
|
|
* now synthesises a zero-width box at the blank line's own position so the
|
|
* text bounding box grows by one line height per blank line.
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
public function testGetTextCellBlankLinesOccupyFullLineHeight(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$oneLine = $this->textCellHeight($obj, 'A');
|
|
$twoLines = $this->textCellHeight($obj, "A\nB");
|
|
$threeLines = $this->textCellHeight($obj, "A\nC\nB");
|
|
$blankMiddle = $this->textCellHeight($obj, "A\n\nB");
|
|
$twoBlanks = $this->textCellHeight($obj, "A\n\n\nB");
|
|
|
|
// Each additional rendered line adds one constant line height.
|
|
$lineHeight = $twoLines - $oneLine;
|
|
$this->assertGreaterThan(0.0, $lineHeight);
|
|
|
|
// A blank middle line counts as a full line: "A\n\nB" spans three lines,
|
|
// exactly like "A\nC\nB". Before the fix it collapsed to two lines.
|
|
$this->assertEqualsWithDelta($threeLines, $blankMiddle, 1.0e-6);
|
|
$this->assertGreaterThan($twoLines, $blankMiddle);
|
|
|
|
// Two consecutive blank lines add two full line heights.
|
|
$this->assertEqualsWithDelta($twoLines + (2.0 * $lineHeight), $twoBlanks, 1.0e-6);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextCellAcceptsNamedAndNumericBorderStyleSides(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$top = ['lineWidth' => 0.4, 'lineColor' => '#ff0000'];
|
|
$right = ['lineWidth' => 0.5, 'lineColor' => '#00aa00'];
|
|
$bottom = ['lineWidth' => 0.6, 'lineColor' => '#0000ff'];
|
|
$left = ['lineWidth' => 0.7, 'lineColor' => '#222222'];
|
|
|
|
$namedStyles = [
|
|
'T' => $top,
|
|
'R' => $right,
|
|
'B' => $bottom,
|
|
'L' => $left,
|
|
];
|
|
|
|
$numericStyles = [
|
|
0 => $top,
|
|
1 => $right,
|
|
2 => $bottom,
|
|
3 => $left,
|
|
];
|
|
|
|
$namedOut = $obj->getTextCell('Hello', 10, 20, 40, 12, 0, 0, 'T', 'L', null, $namedStyles);
|
|
$numericOut = $obj->getTextCell('Hello', 10, 20, 40, 12, 0, 0, 'T', 'L', null, $numericStyles);
|
|
|
|
$this->assertNotSame('', $namedOut);
|
|
$this->assertSame($numericOut, $namedOut);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextCellFitUnknownModeFallsBackToDisabled(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$txt = 'This is a long sentence that should overflow in a small fixed-height box.';
|
|
$base = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
40,
|
|
6,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'',
|
|
);
|
|
$fallback = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
40,
|
|
6,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'x',
|
|
);
|
|
|
|
$this->assertSame($base, $fallback);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextCellFitDisabledWhenCellSizePreconditionsFail(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$txt = 'Overflow text should keep baseline behavior when width or height is automatic.';
|
|
|
|
$baseWidthAuto = $obj->getTextCell($txt, 10, 20, 0, 6, 0, 0, 'T', 'L');
|
|
$fitWidthAuto = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
0,
|
|
6,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'F',
|
|
);
|
|
$this->assertSame($baseWidthAuto, $fitWidthAuto);
|
|
|
|
$baseHeightAuto = $obj->getTextCell($txt, 10, 20, 40, 0, 0, 0, 'T', 'L');
|
|
$fitHeightAuto = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
40,
|
|
0,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'T',
|
|
);
|
|
$this->assertSame($baseHeightAuto, $fitHeightAuto);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextCellFitFontSizeUsesHeuristicSearchForHyphenatedLongWord(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
$obj->setTexHyphenPatterns([
|
|
'a' => 'a1',
|
|
'b' => 'b1',
|
|
'c' => 'c1',
|
|
'd' => 'd1',
|
|
'e' => 'e1',
|
|
'f' => 'f1',
|
|
'g' => 'g1',
|
|
'h' => 'h1',
|
|
'i' => 'i1',
|
|
'j' => 'j1',
|
|
'k' => 'k1',
|
|
'l' => 'l1',
|
|
'm' => 'm1',
|
|
'n' => 'n1',
|
|
'o' => 'o1',
|
|
'p' => 'p1',
|
|
'q' => 'q1',
|
|
'r' => 'r1',
|
|
's' => 's1',
|
|
't' => 't1',
|
|
'u' => 'u1',
|
|
'v' => 'v1',
|
|
'w' => 'w1',
|
|
'x' => 'x1',
|
|
'y' => 'y1',
|
|
'z' => 'z1',
|
|
]);
|
|
|
|
$prepared = $obj->exposePrepareText(
|
|
'Loremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtemporincididuntutlaboreetdoloremagnaaliqua.',
|
|
);
|
|
$ordarr = $prepared[1];
|
|
$fit = $obj->exposeFitTextCellByFontSize($ordarr, 160.0, 5.0, 0.0, 0.0);
|
|
|
|
$this->assertTrue($fit['fontchanged']);
|
|
$this->assertGreaterThan(150.0, $fit['layout']['maxwidth']);
|
|
$this->assertStringContainsString('4.000000 Tf', $fit['fontout']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testFitTextCellByFontSizeReturnsBaseWhenLayoutAlreadyFits(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
[, $ordarr] = $obj->exposePrepareText('fit');
|
|
$fit = $obj->exposeFitTextCellByFontSize($ordarr, 200.0, 200.0, 0.0, 0.0);
|
|
|
|
$this->assertFalse($fit['fontchanged']);
|
|
$this->assertSame('', $fit['fontout']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testFitTextCellByFontSizeStopsWhenCurrentSizeIsMinimum(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
/** @var int $pon */
|
|
$pon = $this->getObjectProperty($obj, 'pon');
|
|
$curfont = $font->getCurrentFont();
|
|
$font->cloneFont($pon, $curfont['idx'], null, 4.0);
|
|
|
|
[, $ordarr] = $obj->exposePrepareText('A long word that cannot fit inside a tiny box');
|
|
$fit = $obj->exposeFitTextCellByFontSize($ordarr, 5.0, 2.0, 0.0, 0.0);
|
|
|
|
$this->assertFalse($fit['fontchanged']);
|
|
$this->assertSame('', $fit['fontout']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testFitTextCellByStretchReturnsBaseForNoOverflowAndHeightOnlyOverflow(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
[, $ordarr, $dim] = $obj->exposePrepareText('short text');
|
|
|
|
$noOverflow = $obj->exposeFitTextCellByStretch($ordarr, $dim, 300.0, 300.0, 0.0, 0.0);
|
|
$heightOnlyOverflow = $obj->exposeFitTextCellByStretch($ordarr, $dim, 300.0, 0.1, 0.0, 0.0);
|
|
|
|
$this->assertFalse($noOverflow['fontchanged']);
|
|
$this->assertFalse($heightOnlyOverflow['fontchanged']);
|
|
$this->assertSame(300.0, $noOverflow['linewidth']);
|
|
$this->assertSame(300.0, $heightOnlyOverflow['linewidth']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testFitTextCellByFontSizeFallbackProbeBranchWithTestDouble(): void
|
|
{
|
|
$obj = new class extends TestableText {
|
|
private int $probeCalls = 0;
|
|
|
|
/** @phpstan-return array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float} */
|
|
protected function getTextCellLayout(
|
|
array $ordarr,
|
|
array $dim,
|
|
float $splitWidth,
|
|
float $displayWidth,
|
|
float $scale,
|
|
float $offsetPoints,
|
|
float $lineSpacePoints,
|
|
bool $rtl = false,
|
|
): array {
|
|
return [
|
|
'lines' => [[
|
|
'pos' => 0,
|
|
'chars' => 1,
|
|
'spaces' => 0,
|
|
'septype' => 'BN',
|
|
'totwidth' => 100.0,
|
|
'totspacewidth' => 0.0,
|
|
'words' => 1,
|
|
]],
|
|
'maxwidth' => 100.0,
|
|
'txtheight' => 100.0,
|
|
];
|
|
}
|
|
|
|
/** @phpstan-return array{size: float, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array<int, array{pos: int, ord: int, septype: string, spaces: int, totwidth: float, totspacewidth: float, wordwidth: float}>}, layout: array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float}, fits: bool} */
|
|
protected function probeTextCellFontSizeFit(
|
|
array $ordarr,
|
|
float $size,
|
|
float $maxWidth,
|
|
float $maxHeight,
|
|
float $offsetPoints,
|
|
float $lineSpacePoints,
|
|
bool $rtl = false,
|
|
): array {
|
|
++$this->probeCalls;
|
|
if ($this->probeCalls === 1) {
|
|
return [
|
|
'size' => $size,
|
|
'dim' => self::DIM_DEFAULT,
|
|
'layout' => ['lines' => [], 'maxwidth' => 100.0, 'txtheight' => 100.0],
|
|
'fits' => false,
|
|
];
|
|
}
|
|
|
|
if ($this->probeCalls === 2) {
|
|
return [
|
|
'size' => $size,
|
|
'dim' => self::DIM_DEFAULT,
|
|
'layout' => ['lines' => [], 'maxwidth' => 1.0, 'txtheight' => 1.0],
|
|
'fits' => true,
|
|
];
|
|
}
|
|
|
|
$fits = ($this->probeCalls % 2) === 0;
|
|
return [
|
|
'size' => $size,
|
|
'dim' => self::DIM_DEFAULT,
|
|
'layout' => ['lines' => [], 'maxwidth' => $fits ? 1.0 : 100.0, 'txtheight' => $fits ? 1.0 : 100.0],
|
|
'fits' => $fits,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @phpstan-param array<int, int> $ordarr
|
|
* @phpstan-return array{fontchanged: bool, fontout: string, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array<int, array{pos: int, ord: int, septype: string, spaces: int, totwidth: float, totspacewidth: float, wordwidth: float}>}, layout: array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float}}
|
|
* @throws \Com\Tecnick\Pdf\Font\Exception
|
|
*/
|
|
public function runFit(array $ordarr): array
|
|
{
|
|
return $this->fitTextCellByFontSize($ordarr, 10.0, 10.0, 0.0, 0.0);
|
|
}
|
|
};
|
|
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
$result = $obj->runFit([65]);
|
|
$this->assertIsArray($result);
|
|
$this->assertArrayHasKey('fontchanged', $result);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testFitTextCellByFontSizeNoChangeBranchWithTestDouble(): void
|
|
{
|
|
$obj = new class extends TestableText {
|
|
/** @phpstan-return array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float} */
|
|
protected function getTextCellLayout(
|
|
array $ordarr,
|
|
array $dim,
|
|
float $splitWidth,
|
|
float $displayWidth,
|
|
float $scale,
|
|
float $offsetPoints,
|
|
float $lineSpacePoints,
|
|
bool $rtl = false,
|
|
): array {
|
|
return [
|
|
'lines' => [[
|
|
'pos' => 0,
|
|
'chars' => 1,
|
|
'spaces' => 0,
|
|
'septype' => 'BN',
|
|
'totwidth' => 100.0,
|
|
'totspacewidth' => 0.0,
|
|
'words' => 1,
|
|
]],
|
|
'maxwidth' => 100.0,
|
|
'txtheight' => 100.0,
|
|
];
|
|
}
|
|
|
|
/** @phpstan-return array{size: float, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array<int, array{pos: int, ord: int, septype: string, spaces: int, totwidth: float, totspacewidth: float, wordwidth: float}>}, layout: array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float}, fits: bool} */
|
|
protected function probeTextCellFontSizeFit(
|
|
array $ordarr,
|
|
float $size,
|
|
float $maxWidth,
|
|
float $maxHeight,
|
|
float $offsetPoints,
|
|
float $lineSpacePoints,
|
|
bool $rtl = false,
|
|
): array {
|
|
return [
|
|
'size' => 10.0,
|
|
'dim' => self::DIM_DEFAULT,
|
|
'layout' => ['lines' => [], 'maxwidth' => 100.0, 'txtheight' => 100.0],
|
|
'fits' => true,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @phpstan-param array<int, int> $ordarr
|
|
* @phpstan-return array{fontchanged: bool, fontout: string, dim: array{chars: int, spaces: int, words: int, totwidth: float, totspacewidth: float, split: array<int, array{pos: int, ord: int, septype: string, spaces: int, totwidth: float, totspacewidth: float, wordwidth: float}>}, layout: array{lines: array<int, array{pos:int, chars:int, spaces:int, septype:string, totwidth:float, totspacewidth:float, words:int}>, maxwidth: float, txtheight: float}}
|
|
* @throws \Com\Tecnick\Pdf\Font\Exception
|
|
*/
|
|
public function runFit(array $ordarr): array
|
|
{
|
|
return $this->fitTextCellByFontSize($ordarr, 10.0, 10.0, 0.0, 0.0);
|
|
}
|
|
};
|
|
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
$result = $obj->runFit([65]);
|
|
$this->assertFalse($result['fontchanged']);
|
|
$this->assertSame('', $result['fontout']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextCellFitTruncateKeepsTextInsideCellHeight(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$txt = 'Truncation mode must cut content to fit both width and height constraints.';
|
|
$base = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
35,
|
|
5,
|
|
0,
|
|
0.5,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'',
|
|
);
|
|
$fit = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
35,
|
|
5,
|
|
0,
|
|
0.5,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'T',
|
|
);
|
|
$bbox = $obj->getLastTextBBox();
|
|
|
|
$this->assertNotSame($base, $fit);
|
|
$this->assertLessThanOrEqual(5.0 + 0.01, $bbox['h']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextCellFitStretchRestoresFontState(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
$before = $font->getCurrentFont();
|
|
|
|
$txt = 'UNBREAKABLETOKENUNBREAKABLETOKENUNBREAKABLETOKENUNBREAKABLETOKEN';
|
|
$out = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
30,
|
|
5,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'S',
|
|
);
|
|
|
|
$after = $font->getCurrentFont();
|
|
$this->assertSame($before['stretching'], $after['stretching']);
|
|
$this->assertStringContainsString(' Tz ', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddTextCellFitModesMatchGetTextCellAndRestoreFontState(): void
|
|
{
|
|
$txt = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
|
|
$styles = [
|
|
'all' => [
|
|
'lineWidth' => 0.3,
|
|
'lineCap' => 'butt',
|
|
'lineJoin' => 'miter',
|
|
'dashArray' => [],
|
|
'dashPhase' => 0,
|
|
'lineColor' => '#4a5b70',
|
|
'fillColor' => '#ffffff',
|
|
],
|
|
];
|
|
|
|
foreach (['', 'T', 'S', 'F'] as $fit) {
|
|
$getObj = $this->getTestObject();
|
|
$this->initFont($getObj);
|
|
$getObj->addPage();
|
|
|
|
$expected = $getObj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
80,
|
|
10,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
$styles,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
'',
|
|
null,
|
|
$fit,
|
|
);
|
|
|
|
$addObj = $this->getTestObject();
|
|
$this->initFont($addObj);
|
|
$page = $addObj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($addObj, 'font');
|
|
$before = $font->getCurrentFont();
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($addObj, 'page');
|
|
$beforeContent = $pageObj->getPage($pid)['content'];
|
|
$beforeCount = \count($beforeContent);
|
|
|
|
$addObj->addTextCell(
|
|
$txt,
|
|
$pid,
|
|
10,
|
|
20,
|
|
80,
|
|
10,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
$styles,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
'',
|
|
null,
|
|
$fit,
|
|
);
|
|
|
|
$after = $font->getCurrentFont();
|
|
$content = $pageObj->getPage($pid)['content'];
|
|
$this->assertGreaterThanOrEqual($beforeCount, \count($content));
|
|
$actual = \implode('', \array_slice($content, $beforeCount));
|
|
|
|
$this->assertSame($before['size'], $after['size']);
|
|
$this->assertSame($before['stretching'], $after['stretching']);
|
|
$this->assertSame($expected, $actual);
|
|
}
|
|
|
|
$seqObj = $this->getTestObject();
|
|
$this->initFont($seqObj);
|
|
$seqPage = $seqObj->addPage();
|
|
$seqPid = $this->requirePageId($seqPage);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $seqPageObj */
|
|
$seqPageObj = $this->getObjectProperty($seqObj, 'page');
|
|
|
|
$seqObj->addTextCell(
|
|
$txt,
|
|
$seqPid,
|
|
10,
|
|
20,
|
|
80,
|
|
10,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
$styles,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
'',
|
|
null,
|
|
'F',
|
|
);
|
|
|
|
$contentBeforeDisabled = $seqPageObj->getPage($seqPid)['content'];
|
|
$beforeDisabledCount = \count($contentBeforeDisabled);
|
|
|
|
$disabledTxt = 'Disabled mode should use base font.';
|
|
$seqObj->addTextCell(
|
|
$disabledTxt,
|
|
$seqPid,
|
|
10,
|
|
40,
|
|
80,
|
|
10,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
$styles,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
'',
|
|
null,
|
|
'',
|
|
);
|
|
|
|
$contentAfterDisabled = $seqPageObj->getPage($seqPid)['content'];
|
|
$this->assertGreaterThan($beforeDisabledCount, \count($contentAfterDisabled));
|
|
$disabledChunk = \implode('', \array_slice($contentAfterDisabled, $beforeDisabledCount));
|
|
|
|
$expectedObj = $this->getTestObject();
|
|
$this->initFont($expectedObj);
|
|
$expectedObj->addPage();
|
|
$expectedDisabled = $expectedObj->getTextCell(
|
|
$disabledTxt,
|
|
10,
|
|
40,
|
|
80,
|
|
10,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
$styles,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
'',
|
|
null,
|
|
'',
|
|
);
|
|
|
|
$this->assertSame($expectedDisabled, $disabledChunk);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextCellFitStretchIgnoredWhenCompressionCannotReduceOverflow(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
// "Short" easily fits in 80mm width, but the 2mm height is too small even for a single
|
|
// line. Horizontal compression cannot reduce the line count, so fit=S must be a no-op.
|
|
$txt = 'Short';
|
|
$out = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
80,
|
|
2,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'S',
|
|
);
|
|
|
|
$this->assertStringNotContainsString(' Tz ', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextCellFitStretchAppliesCompressionOnHeightOverflowFromWrapping(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
$before = $font->getCurrentFont();
|
|
|
|
// Long text that wraps to multiple lines within 160mm but overflows the 6mm height.
|
|
// fit=S should apply horizontal compression to reduce line count.
|
|
$txt = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
|
|
$out = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
160,
|
|
6,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'S',
|
|
);
|
|
|
|
$after = $font->getCurrentFont();
|
|
$this->assertSame($before['stretching'], $after['stretching']);
|
|
$this->assertStringContainsString(' Tz ', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextCellFitFontSizeRestoresFontState(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
$before = $font->getCurrentFont();
|
|
|
|
$txt = 'Font-size fitting should reduce the size when needed and restore it after rendering.';
|
|
$out = $obj->getTextCell(
|
|
$txt,
|
|
10,
|
|
20,
|
|
40,
|
|
6,
|
|
0,
|
|
0.5,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'F',
|
|
);
|
|
$bbox = $obj->getLastTextBBox();
|
|
|
|
$after = $font->getCurrentFont();
|
|
$this->assertSame($before['size'], $after['size']);
|
|
$this->assertStringContainsString(' Tf', $out);
|
|
$this->assertGreaterThanOrEqual(2, \substr_count($out, ' Tf'));
|
|
$this->assertLessThanOrEqual(6.0 + 0.01, $bbox['h']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddTextCellAppendsContentToPage(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
/** @var array<int, string> $before */
|
|
$before = $pageObj->getPage($pid)['content'];
|
|
|
|
$obj->addTextCell('Hello', $pid, 1, 2, 20, 6, 0, 0, 'T', 'L');
|
|
|
|
/** @var array<int, string> $after */
|
|
$after = $pageObj->getPage($pid)['content'];
|
|
$this->assertGreaterThan(\count($before), \count($after));
|
|
$lastKey = \array_key_last($after);
|
|
$this->assertIsInt($lastKey);
|
|
$lastValue = $after[$lastKey] ?? null;
|
|
$this->assertIsString($lastValue);
|
|
$this->assertNotSame('', $lastValue);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddTextCellImplicitCurrentPageTracksLastAutoBrokenPage(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$firstPage = $obj->addPage([
|
|
'region' => [
|
|
['RX' => 0.0, 'RY' => 0.0, 'RW' => 80.0, 'RH' => 6.0],
|
|
],
|
|
]);
|
|
$firstPid = $this->requirePageId($firstPage);
|
|
|
|
$txt = \str_repeat("Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n", 40);
|
|
$obj->addTextCell(
|
|
$txt,
|
|
-1,
|
|
1,
|
|
1,
|
|
78,
|
|
0,
|
|
0,
|
|
0,
|
|
'T',
|
|
'J',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
$lastPid = $pageObj->getPageId();
|
|
|
|
$this->assertGreaterThan($firstPid, $lastPid);
|
|
$this->assertSame($lastPid, $pageObj->getPage()['pid']);
|
|
$this->assertSame($lastPid, $obj->setCurrentPage()['pid']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddTextCellXYForwardsRegionRelativeCoordinates(): void
|
|
{
|
|
$obj = new class extends TestableText {
|
|
/** @var array<string, mixed> */
|
|
public array $captured = [];
|
|
|
|
public function addTextCell(
|
|
string $txt,
|
|
int $pid = -1,
|
|
float $posx = 0,
|
|
float $posy = 0,
|
|
float $width = 0,
|
|
float $height = 0,
|
|
float $offset = 0,
|
|
float $linespace = 0,
|
|
string|\Com\Tecnick\Pdf\TextVAlign $valign = 'T',
|
|
string|\Com\Tecnick\Pdf\TextHAlign $halign = '',
|
|
?array $cell = null,
|
|
array $styles = [],
|
|
float $strokewidth = 0,
|
|
float $wordspacing = 0,
|
|
float $leading = 0,
|
|
float $rise = 0,
|
|
bool $jlast = true,
|
|
bool $fill = true,
|
|
bool $stroke = false,
|
|
bool $underline = false,
|
|
bool $linethrough = false,
|
|
bool $overline = false,
|
|
bool $clip = false,
|
|
bool $drawcell = true,
|
|
string|\Com\Tecnick\Unicode\TextDirection $forcedir = '',
|
|
?array $shadow = null,
|
|
string|\Com\Tecnick\Pdf\TextFitMode $fit = '',
|
|
): void {
|
|
$this->captured = [
|
|
'txt' => $txt,
|
|
'pid' => $pid,
|
|
'posx' => $posx,
|
|
'posy' => $posy,
|
|
'width' => $width,
|
|
'height' => $height,
|
|
'offset' => $offset,
|
|
'linespace' => $linespace,
|
|
'valign' => $valign,
|
|
'halign' => $halign,
|
|
'cell' => $cell,
|
|
'styles' => $styles,
|
|
'strokewidth' => $strokewidth,
|
|
'wordspacing' => $wordspacing,
|
|
'leading' => $leading,
|
|
'rise' => $rise,
|
|
'jlast' => $jlast,
|
|
'fill' => $fill,
|
|
'stroke' => $stroke,
|
|
'underline' => $underline,
|
|
'linethrough' => $linethrough,
|
|
'overline' => $overline,
|
|
'clip' => $clip,
|
|
'drawcell' => $drawcell,
|
|
'forcedir' => $forcedir,
|
|
'shadow' => $shadow,
|
|
'fit' => $fit,
|
|
];
|
|
}
|
|
};
|
|
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage([
|
|
'region' => [
|
|
[
|
|
'RX' => 15.0,
|
|
'RY' => 20.0,
|
|
'RW' => 120.0,
|
|
'RH' => 80.0,
|
|
],
|
|
],
|
|
]);
|
|
$pid = $this->requirePageId($page);
|
|
|
|
$obj->addTextCellXY('Hello', $pid, 40.0, 55.0, 20.0, 6.0, 1.5, 2.5, 'B', 'R');
|
|
|
|
$this->assertSame(
|
|
[
|
|
'txt' => 'Hello',
|
|
'pid' => $pid,
|
|
'posx' => 25.0,
|
|
'posy' => 35.0,
|
|
'width' => 20.0,
|
|
'height' => 6.0,
|
|
'offset' => 1.5,
|
|
'linespace' => 2.5,
|
|
'valign' => 'B',
|
|
'halign' => 'R',
|
|
'cell' => null,
|
|
'styles' => [],
|
|
'strokewidth' => 0.0,
|
|
'wordspacing' => 0.0,
|
|
'leading' => 0.0,
|
|
'rise' => 0.0,
|
|
'jlast' => true,
|
|
'fill' => true,
|
|
'stroke' => false,
|
|
'underline' => false,
|
|
'linethrough' => false,
|
|
'overline' => false,
|
|
'clip' => false,
|
|
'drawcell' => true,
|
|
'forcedir' => '',
|
|
'shadow' => null,
|
|
'fit' => '',
|
|
],
|
|
$obj->captured,
|
|
);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetLastTextBBoxAndCellBBoxUpdatedByGetTextCell(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$obj->getTextCell('Hello world', 10, 20, 40, 12, 0, 0, 'T', 'L');
|
|
|
|
$textbbox = $obj->getLastTextBBox();
|
|
$cellbbox = $obj->getLastCellBBox();
|
|
|
|
$this->assertGreaterThan(0.0, $textbbox['w']);
|
|
$this->assertGreaterThan(0.0, $textbbox['h']);
|
|
$this->assertGreaterThan(0.0, $cellbbox['w']);
|
|
$this->assertGreaterThan(0.0, $cellbbox['h']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetLastCellBBoxUpdatedByAddTextCell(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
$obj->addTextCell('Hello world', $pid, 10, 20, 40, 12, 0, 0, 'T', 'L');
|
|
|
|
$cellbbox = $obj->getLastCellBBox();
|
|
$textbbox = $obj->getLastTextBBox();
|
|
$this->assertGreaterThan(0.0, $cellbbox['w']);
|
|
$this->assertGreaterThan(0.0, $cellbbox['h']);
|
|
$this->assertGreaterThan(0.0, $textbbox['w']);
|
|
$this->assertGreaterThan(0.0, $textbbox['h']);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTextOperatorHelpersCoverModesAndFormatting(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$this->assertSame('1.000000 2.000000 3.000000 0.500000 re f' . "\n", $obj->exposeGetOutUTOLine(1, 2, 3, 0.5));
|
|
$this->assertSame(0, $obj->exposeGetTextRenderingMode(true, false, false));
|
|
$this->assertSame(3, $obj->exposeGetTextRenderingMode(false, false, false));
|
|
$this->assertSame(7, $obj->exposeGetTextRenderingMode(false, false, true));
|
|
|
|
$this->assertStringContainsString('Td raw', $obj->exposeGetOutTextPosXY('raw', 1, 2, 'Td'));
|
|
$this->assertStringContainsString('TD raw', $obj->exposeGetOutTextPosXY('raw', 1, 2, 'TD'));
|
|
$this->assertSame('T* raw', $obj->exposeGetOutTextPosXY('raw', 0, 0, 'T*'));
|
|
$this->assertSame('', $obj->exposeGetOutTextPosXY('raw', 0, 0, 'NOPE'));
|
|
|
|
/** @var array<int, array{0: callable(TestableText): string, 1: callable(TestableText): string, 2: string}>
|
|
* $stateOperatorCases
|
|
*/
|
|
$stateOperatorCases = [
|
|
[
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTc('raw', 0),
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTc('raw', 1.5),
|
|
' Tc raw 0 Tc',
|
|
],
|
|
[
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTw('raw', 0),
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTw('raw', 2),
|
|
' Tw raw 0 Tw',
|
|
],
|
|
[
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTz('raw', 1),
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTz('raw', 80),
|
|
' Tz raw 100 Tz',
|
|
],
|
|
[
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTL('raw', 0),
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTL('raw', 10),
|
|
' TL raw 0 TL',
|
|
],
|
|
[
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTs('raw', 0),
|
|
static fn(TestableText $txtObj): string => $txtObj->exposeGetOutTextStateOperatorTs('raw', 3),
|
|
' Ts raw 0 Ts',
|
|
],
|
|
];
|
|
foreach ($stateOperatorCases as [$defaultCase, $customCase, $expectedFragment]) {
|
|
$this->assertSame('raw', $defaultCase($obj));
|
|
$this->assertStringContainsString($expectedFragment, $customCase($obj));
|
|
}
|
|
|
|
$this->assertSame('raw', $obj->exposeGetOutTextStateOperatorTr('raw', 99));
|
|
$this->assertStringContainsString('2 Tr raw', $obj->exposeGetOutTextStateOperatorTr('raw', 2));
|
|
$this->assertStringContainsString('0.000000 w raw', $obj->exposeGetOutTextStateOperatorw('raw', -1));
|
|
|
|
$this->assertSame('', $obj->exposeGetOutTextPosMatrix('raw', [1, 2]));
|
|
$this->assertStringContainsString(' Tm raw', $obj->exposeGetOutTextPosMatrix('raw', [1, 0, 0, 1, 10, 20]));
|
|
$this->assertSame('(abc) Tj', $obj->exposeGetOutTextShowing('abc', 'Tj'));
|
|
$this->assertSame('[(abc)] TJ', $obj->exposeGetOutTextShowing('abc', 'TJ'));
|
|
$this->assertSame("(abc) '", $obj->exposeGetOutTextShowing('abc', "'"));
|
|
$this->assertSame('', $obj->exposeGetOutTextShowing('abc', 'X'));
|
|
$this->assertSame("BT xyz ET\n", $obj->exposeGetOutTextObject('xyz'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTextCleanupHyphenationAndEscapingHelpers(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$this->assertSame('A B C', $obj->exposeCleanupText("A\rB\u{00A0}C\u{00AD}"));
|
|
$this->assertSame([65, 173], \array_values($obj->exposeRemoveOrdArrSoftHyphens([65, 173, 8203, 173])));
|
|
$this->assertSame([36, 8203, 65], $obj->exposeAddOrdArrBreakPoints([36, 65]));
|
|
$this->assertSame([65, 66], $obj->exposeReplaceUnicodeChars([65, 66]));
|
|
$this->assertSame('100%% ready', $obj->exposeEscapePerc('100% ready'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSetPageContextAndStringWidthHelpers(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
/** @var array<int, string> $before */
|
|
$before = $pageObj->getPage($pid)['content'];
|
|
$obj->exposeSetPageContext($pid);
|
|
/** @var array<int, string> $after */
|
|
$after = $pageObj->getPage($pid)['content'];
|
|
|
|
$this->assertGreaterThan(\count($before), \count($after));
|
|
$this->assertSame(0.0, $obj->exposeGetStringWidth(''));
|
|
$this->assertGreaterThan(0, $obj->exposeGetStringWidth('Hello'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testPrepareTextAndSplitLinesCoverEmptyAndMultiLineCases(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
[$txt, $ordarr, $dim] = $obj->exposePrepareText("Hello\r world");
|
|
$linesWide = $obj->exposeSplitLines($ordarr, $dim, 1000);
|
|
$linesNarrow = $obj->exposeSplitLines($ordarr, $dim, 10);
|
|
|
|
$this->assertSame('Hello world', $txt);
|
|
$this->assertNotEmpty($ordarr);
|
|
$this->assertGreaterThan(0, $dim['totwidth']);
|
|
$this->assertCount(1, $linesWide);
|
|
$this->assertGreaterThan(1, \count($linesNarrow));
|
|
$this->assertSame([], $obj->exposeSplitLines([], $dim, 10));
|
|
|
|
[$textWithNewline, $ordarrWithNewline, $dimWithNewline] = $obj->exposePrepareText("Cell Borders\nnextline");
|
|
$newlineLines = $obj->exposeSplitLines($ordarrWithNewline, $dimWithNewline, 1000);
|
|
$this->assertSame("Cell Borders\nnextline", $textWithNewline);
|
|
$this->assertCount(2, $newlineLines);
|
|
}
|
|
|
|
/**
|
|
* Stage 1 RTL fix: a wrapped RTL paragraph must stack the logically-first
|
|
* words on the TOP line and the logically-last words on the BOTTOM line.
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
public function testSplitLinesReversesLineOrderForRtlBaseDirection(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initUnicodeFont($obj);
|
|
$obj->addPage();
|
|
$this->setObjectProperty($obj, 'isunicode', true);
|
|
|
|
// Six distinct Hebrew "words" (each three identical letters) in logical order.
|
|
// Pure RTL + spaces, so the Bidi visual array is the exact reverse of the logical one.
|
|
$firstLetter = 0x05D0; // first logical character (top of the page when correct)
|
|
$lastLetter = 0x05D5; // last logical character (bottom of the page when correct)
|
|
$word = static fn(int $cp): string => (string) \mb_chr($cp) . (string) \mb_chr($cp) . (string) \mb_chr($cp);
|
|
$txt = \implode(' ', \array_map($word, [0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5]));
|
|
|
|
[, $ordarr, $dim] = $obj->exposePrepareText($txt, 'R');
|
|
$logical = \array_reverse($ordarr); // pure-RTL: visual is the exact reverse of logical
|
|
$this->assertSame($firstLetter, $logical[0] ?? null);
|
|
$this->assertSame($lastLetter, $logical[\count($logical) - 1] ?? null);
|
|
|
|
// Force a wrap into several lines (roughly a third of the paragraph per line).
|
|
$wrapWidth = $dim['totwidth'] / 3.0;
|
|
|
|
$visualLines = $obj->exposeSplitLines($ordarr, $dim, $wrapWidth, 0, false);
|
|
$rtlLines = $obj->exposeSplitLines($ordarr, $dim, $wrapWidth, 0, true);
|
|
|
|
// The reversal must not change how many lines the text wraps to.
|
|
$this->assertGreaterThan(1, \count($rtlLines));
|
|
$this->assertSameSize($visualLines, $rtlLines);
|
|
|
|
$visualTop = $visualLines[0] ?? null;
|
|
$visualBottom = $visualLines[\count($visualLines) - 1] ?? null;
|
|
$rtlTop = $rtlLines[0] ?? null;
|
|
$rtlBottom = $rtlLines[\count($rtlLines) - 1] ?? null;
|
|
$this->assertIsArray($visualTop);
|
|
$this->assertIsArray($visualBottom);
|
|
$this->assertIsArray($rtlTop);
|
|
$this->assertIsArray($rtlBottom);
|
|
|
|
// Slice of the visual array rendered on each line.
|
|
$topVisual = \array_slice($ordarr, $visualTop['pos'], $visualTop['chars']);
|
|
$bottomVisual = \array_slice($ordarr, $visualBottom['pos'], $visualBottom['chars']);
|
|
$topRtl = \array_slice($ordarr, $rtlTop['pos'], $rtlTop['chars']);
|
|
$bottomRtl = \array_slice($ordarr, $rtlBottom['pos'], $rtlBottom['chars']);
|
|
|
|
// Default (rtl=false) walks the visual array forward and stacks bottom-up: the
|
|
// logically-LAST word ends up on the top line and the logically-FIRST on the bottom.
|
|
$this->assertContains($lastLetter, $topVisual);
|
|
$this->assertNotContains($firstLetter, $topVisual);
|
|
$this->assertContains($firstLetter, $bottomVisual);
|
|
|
|
// With the RTL flag the order is corrected: logically-FIRST word on top,
|
|
// logically-LAST word on the bottom.
|
|
$this->assertContains($firstLetter, $topRtl);
|
|
$this->assertNotContains($lastLetter, $topRtl);
|
|
$this->assertContains($lastLetter, $bottomRtl);
|
|
|
|
// Each RTL line slice is still in visual (reversed) order, so reversing it back to
|
|
// logical and concatenating the lines top-to-bottom reproduces the logical reading
|
|
// sequence. Compare the non-space glyphs (line breaks drop their separator space,
|
|
// while spaces inside a line are kept) to assert the letters keep logical order.
|
|
$reconstructed = [];
|
|
foreach ($rtlLines as $line) {
|
|
foreach (\array_reverse(\array_slice($ordarr, $line['pos'], $line['chars'])) as $cp) {
|
|
$reconstructed[] = $cp;
|
|
}
|
|
}
|
|
$stripSpaces = static fn(array $arr): array => \array_values(\array_filter(
|
|
$arr,
|
|
static fn(int $cp): bool => $cp !== 0x20,
|
|
));
|
|
$this->assertSame($stripSpaces($logical), $stripSpaces($reconstructed));
|
|
|
|
// Single line => reversal is a no-op (same glyphs in the same position). Only the
|
|
// recomputed totwidth may differ by a float ULP due to summation order.
|
|
$singleVisual = $obj->exposeSplitLines($ordarr, $dim, $dim['totwidth'] * 2.0, 0, false);
|
|
$singleRtl = $obj->exposeSplitLines($ordarr, $dim, $dim['totwidth'] * 2.0, 0, true);
|
|
$this->assertCount(1, $singleRtl);
|
|
$singleVisualLine = $singleVisual[0] ?? null;
|
|
$singleRtlLine = $singleRtl[0] ?? null;
|
|
$this->assertIsArray($singleVisualLine);
|
|
$this->assertIsArray($singleRtlLine);
|
|
$this->assertSame((int) $singleVisualLine['pos'], (int) $singleRtlLine['pos']);
|
|
$this->assertSame((int) $singleVisualLine['chars'], (int) $singleRtlLine['chars']);
|
|
$this->assertSame(0, (int) $singleRtlLine['pos']);
|
|
$this->assertSame(\count($ordarr), (int) $singleRtlLine['chars']);
|
|
|
|
// Default flag is unchanged for the LTR/empty cases.
|
|
$this->assertSame([], $obj->exposeSplitLines([], $dim, 10, 0, true));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testIsOrdArrBaseRtlResolvesDirection(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initUnicodeFont($obj);
|
|
$obj->addPage();
|
|
$this->setObjectProperty($obj, 'isunicode', true);
|
|
|
|
$latin = [0x41, 0x42, 0x43]; // ABC
|
|
$hebrew = [0x05D0, 0x05D1]; // strong R
|
|
$neutral = [0x20, 0x2E, 0x31]; // space, dot, digit -> no strong char
|
|
|
|
// Explicit forcedir always wins.
|
|
$this->assertTrue($obj->exposeIsOrdArrBaseRtl($latin, 'R'));
|
|
$this->assertFalse($obj->exposeIsOrdArrBaseRtl($hebrew, 'L'));
|
|
|
|
// Auto: first strong character decides.
|
|
$this->assertFalse($obj->exposeIsOrdArrBaseRtl($latin, ''));
|
|
$this->assertTrue($obj->exposeIsOrdArrBaseRtl($hebrew, ''));
|
|
// A leading neutral run is skipped until the first strong (R) character.
|
|
$this->assertTrue($obj->exposeIsOrdArrBaseRtl([...$neutral, ...$hebrew], ''));
|
|
|
|
// Latin letters are not listed in the bidirectional type table, so a mixed
|
|
// paragraph that starts with them stays left-to-right.
|
|
$this->assertFalse($obj->exposeIsOrdArrBaseRtl([...$latin, 0x20, ...$hebrew], ''));
|
|
// The same holds for the other characters of type L that are not listed.
|
|
$this->assertFalse($obj->exposeIsOrdArrBaseRtl([0x4E00, 0x20, ...$hebrew], ''));
|
|
|
|
// No strong character: fall back to the document default ($this->rtl).
|
|
$this->assertFalse($obj->exposeIsOrdArrBaseRtl($neutral, ''));
|
|
$this->setObjectProperty($obj, 'rtl', true);
|
|
$this->assertTrue($obj->exposeIsOrdArrBaseRtl($neutral, ''));
|
|
|
|
// prepareText surfaces the resolved base direction (and only when Bidi-reordered).
|
|
[, , , $baseRtl] = $obj->exposePrepareTextWithDir((string) \mb_chr(0x05D0) . (string) \mb_chr(0x05D1), '');
|
|
$this->assertTrue($baseRtl);
|
|
[, , , $baseLtr] = $obj->exposePrepareTextWithDir('abc', '');
|
|
$this->assertFalse($baseLtr);
|
|
}
|
|
|
|
/**
|
|
* The text level hyphenation collects the words by bidirectional type L, so it must
|
|
* hyphenate them exactly like the word level pass.
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
public function testHyphenateTextOrdArrHyphenatesLatinWords(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initUnicodeFont($obj);
|
|
$obj->addPage();
|
|
|
|
$patterns = ['hyphen' => 'hy3phen'];
|
|
$hypWord = $obj->exposeHyphenateWordOrdArr($patterns, $obj->exposeStrToOrdArr('hyphen'));
|
|
$hypText = $obj->exposeHyphenateTextOrdArr($patterns, $obj->exposeStrToOrdArr('hyphen,test'));
|
|
|
|
$this->assertContains(0x00AD, $hypWord);
|
|
$this->assertSame($hypWord, \array_slice($hypText, 0, \count($hypWord)));
|
|
$this->assertContains(0x00AD, $hypText);
|
|
}
|
|
|
|
/**
|
|
* End-to-end: addTextCell() on a wrapping RTL paragraph (E003's call pattern:
|
|
* width set, no height) must emit the glyph runs top-to-bottom in logical order,
|
|
* i.e. matching splitLines(rtl=true) and NOT the visual splitLines(rtl=false).
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
public function testAddTextCellStacksWrappedRtlParagraphTopDown(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initUnicodeFont($obj);
|
|
$this->setObjectProperty($obj, 'isunicode', true);
|
|
$obj->setRTL(true);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
$word = static fn(int $cp): string => (string) \mb_chr($cp) . (string) \mb_chr($cp) . (string) \mb_chr($cp);
|
|
$txt = \implode(' ', \array_map($word, [0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5]));
|
|
|
|
// A borderless cell (drawcell:false, no styles) has zero padding, so the internal
|
|
// split width equals toPoints(width); reuse it to derive the reference lines.
|
|
$cellWidthMm = 18.0;
|
|
[, $ordarr, $dim] = $obj->exposePrepareText($txt, '');
|
|
$renderWidthPts = $obj->toPoints($cellWidthMm);
|
|
|
|
$logicalLines = $obj->exposeSplitLines($ordarr, $dim, $renderWidthPts, 0, true);
|
|
$visualLines = $obj->exposeSplitLines($ordarr, $dim, $renderWidthPts, 0, false);
|
|
$this->assertGreaterThan(1, \count($logicalLines));
|
|
|
|
// Ordered list of text-show operands (one per rendered line) in the content stream.
|
|
$glyphTokens = static function (string $content): array {
|
|
$matches = [];
|
|
\preg_match_all('/(\([^)]*\)|<[0-9A-Fa-f]*>|\[[^\]]*\])\s*T[jJ]/s', $content, $matches);
|
|
return $matches[1] ?? [];
|
|
};
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
$beforeContent = $pageObj->getPage($pid)['content'];
|
|
$beforeCount = \count($beforeContent);
|
|
|
|
$obj->addTextCell(txt: $txt, pid: $pid, posx: 5, posy: 20, width: $cellWidthMm, drawcell: false);
|
|
|
|
$content = $pageObj->getPage($pid)['content'];
|
|
$actual = \implode('', \array_slice($content, $beforeCount));
|
|
$actualTokens = $glyphTokens($actual);
|
|
|
|
// Reference renders through the same outTextLines() the cell uses internally; with
|
|
// halign 'R' (the RTL default) no justification spacing is added, so the glyph-token
|
|
// sequence depends only on the line breaks and their top-to-bottom order.
|
|
$width = $obj->toUnit($renderWidthPts);
|
|
$expectedRtl = $glyphTokens($obj->exposeOutTextLines($ordarr, $logicalLines, 5, 20, $width, 0, 5, 0));
|
|
$expectedVisual = $glyphTokens($obj->exposeOutTextLines($ordarr, $visualLines, 5, 20, $width, 0, 5, 0));
|
|
|
|
// The emitted glyph order matches the logical (top-down) stacking, not the visual one.
|
|
$this->assertNotEmpty($actualTokens);
|
|
$this->assertSame($expectedRtl, $actualTokens);
|
|
$this->assertNotSame($expectedVisual, $actualTokens);
|
|
}
|
|
|
|
/**
|
|
* Stage 2a: getTextCell() is the path every HTML fragment renders through, so it
|
|
* must also stack a wrapping RTL paragraph top-down in logical order (matching
|
|
* splitLines(rtl=true)), not the visual (rtl=false) order. Before Stage 2a only
|
|
* addTextCell() reversed the line order; getTextCell() kept the visual stacking.
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
public function testGetTextCellStacksWrappedRtlParagraphTopDown(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initUnicodeFont($obj);
|
|
$this->setObjectProperty($obj, 'isunicode', true);
|
|
$obj->setRTL(true);
|
|
$obj->addPage();
|
|
|
|
$word = static fn(int $cp): string => (string) \mb_chr($cp) . (string) \mb_chr($cp) . (string) \mb_chr($cp);
|
|
$txt = \implode(' ', \array_map($word, [0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5]));
|
|
|
|
$cellWidthMm = 18.0;
|
|
[, $ordarr, $dim] = $obj->exposePrepareText($txt, '');
|
|
$renderWidthPts = $obj->toPoints($cellWidthMm);
|
|
|
|
$logicalLines = $obj->exposeSplitLines($ordarr, $dim, $renderWidthPts, 0, true);
|
|
$visualLines = $obj->exposeSplitLines($ordarr, $dim, $renderWidthPts, 0, false);
|
|
$this->assertGreaterThan(1, \count($logicalLines));
|
|
|
|
$glyphTokens = static function (string $content): array {
|
|
$matches = [];
|
|
\preg_match_all('/(\([^)]*\)|<[0-9A-Fa-f]*>|\[[^\]]*\])\s*T[jJ]/s', $content, $matches);
|
|
return $matches[1] ?? [];
|
|
};
|
|
|
|
// drawcell:false makes getTextCell return only the text-show output; the default
|
|
// halign 'C' adds no justification spacing, so the glyph-token order depends only
|
|
// on the line breaks and their top-to-bottom stacking.
|
|
$out = $obj->getTextCell(txt: $txt, posx: 5, posy: 20, width: $cellWidthMm, drawcell: false);
|
|
$actualTokens = $glyphTokens($out);
|
|
|
|
$width = $obj->toUnit($renderWidthPts);
|
|
$expectedRtl = $glyphTokens($obj->exposeOutTextLines($ordarr, $logicalLines, 5, 20, $width, 0, 5, 0));
|
|
$expectedVisual = $glyphTokens($obj->exposeOutTextLines($ordarr, $visualLines, 5, 20, $width, 0, 5, 0));
|
|
|
|
$this->assertNotEmpty($actualTokens);
|
|
$this->assertSame($expectedRtl, $actualTokens);
|
|
$this->assertNotSame($expectedVisual, $actualTokens);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetOutTextLineAndOutTextLineRenderFromPreparedText(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
[$txt, $ordarr, $dim] = $obj->exposePrepareText('Hello world');
|
|
|
|
$this->assertSame('', $obj->exposeGetOutTextLine('', [], []));
|
|
$this->assertSame('', $obj->exposeOutTextLine('', [], []));
|
|
|
|
$raw = $obj->exposeGetOutTextLine(
|
|
$txt,
|
|
$ordarr,
|
|
$dim,
|
|
1,
|
|
2,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
false,
|
|
true,
|
|
true,
|
|
true,
|
|
false,
|
|
);
|
|
$out = $obj->exposeOutTextLine($txt, $ordarr, $dim, 1, 2, 0, 0, 0, 0, 0, true, false, true, true, true, false);
|
|
|
|
$this->assertStringContainsString('BT ', $raw);
|
|
$this->assertStringContainsString(' ET', $raw);
|
|
$this->assertStringContainsString('re f', $out);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testOutTextLinesGetJustifiedStringAndHyphenationHelpers(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
[$txt, $ordarr, $dim] = $obj->exposePrepareText('Hello world again');
|
|
$lines = $obj->exposeSplitLines($ordarr, $dim, 20);
|
|
|
|
$this->assertSame('', $obj->exposeOutTextLines([], [], 0, 0, 0, 0, 0));
|
|
|
|
$block = $obj->exposeOutTextLines($ordarr, $lines, 1, 1, 30, 0, 1.5, 0, 0, 0, 0, 0, 'J', false);
|
|
$this->assertStringContainsString('BT ', $block);
|
|
|
|
$just = $obj->exposeGetJustifiedString($txt, $ordarr, $dim, 40);
|
|
$this->assertStringContainsString('Tw', $just);
|
|
|
|
$patterns = ['hyphen' => 'hy4phen'];
|
|
$word = $obj->exposeStrToOrdArr('hyphen');
|
|
$text = $obj->exposeStrToOrdArr('hyphen,test');
|
|
$trailingWordText = $obj->exposeStrToOrdArr('hyphen,word');
|
|
$hypWord = $obj->exposeHyphenateWordOrdArr($patterns, $word);
|
|
$hypText = $obj->exposeHyphenateTextOrdArr($patterns, $text);
|
|
$hypTrailingWordText = $obj->exposeHyphenateTextOrdArr($patterns, $trailingWordText);
|
|
|
|
$this->assertNotEmpty($hypWord);
|
|
$this->assertNotEmpty($hypText);
|
|
$this->assertSame($obj->exposeStrToOrdArr('word'), \array_slice($hypTrailingWordText, -4));
|
|
$this->assertSame($word, $obj->exposeHyphenateWordOrdArr([], $word));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testOutTextLinesLowercaseAlignAndInlineWordSpacingBranches(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
[, $ordarr] = $obj->exposePrepareText('a b c d');
|
|
|
|
/** @var array<int, array{pos:int, chars:int, spaces:int, totwidth:float, totspacewidth:float, words:int, septype:string}> $lines */
|
|
$lines = [
|
|
[
|
|
'pos' => 0,
|
|
'chars' => 3,
|
|
'spaces' => 1,
|
|
'totwidth' => 10.0,
|
|
'totspacewidth' => 2.0,
|
|
'words' => 2,
|
|
'septype' => 'S',
|
|
],
|
|
[
|
|
'pos' => 4,
|
|
'chars' => 3,
|
|
'spaces' => 1,
|
|
'totwidth' => 10.0,
|
|
'totspacewidth' => 2.0,
|
|
'words' => 2,
|
|
'septype' => 'S',
|
|
],
|
|
];
|
|
|
|
$center = $obj->exposeOutTextLines(
|
|
$ordarr,
|
|
$lines,
|
|
1.0,
|
|
1.0,
|
|
40.0,
|
|
0.0,
|
|
1.5,
|
|
0.0,
|
|
0.0,
|
|
0.6,
|
|
0.0,
|
|
0.0,
|
|
'c',
|
|
false,
|
|
);
|
|
$right = $obj->exposeOutTextLines(
|
|
$ordarr,
|
|
$lines,
|
|
1.0,
|
|
1.0,
|
|
40.0,
|
|
0.0,
|
|
1.5,
|
|
0.0,
|
|
0.0,
|
|
0.6,
|
|
0.0,
|
|
0.0,
|
|
'r',
|
|
false,
|
|
);
|
|
$auto = $obj->exposeOutTextLines($ordarr, $lines, 1.0, 1.0, 40.0, 0.0, 1.5, 0.0, 0.0, 0.6, 0.0, 0.0, '', false);
|
|
|
|
$this->assertStringContainsString('BT ', $center);
|
|
$this->assertStringContainsString('BT ', $right);
|
|
$this->assertStringContainsString('BT ', $auto);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testLastBBoxHelpersFallbackToZeroForSparseArrayIndexes(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
|
|
/** @var array<string, float> $box */
|
|
$box = ['x' => 1.0, 'y' => 2.0, 'w' => 3.0, 'h' => 4.0];
|
|
$this->setObjectProperty($obj, 'bbox', [5 => $box]);
|
|
$this->setObjectProperty($obj, 'textbbox', [7 => $box]);
|
|
$this->setObjectProperty($obj, 'cellbbox', [9 => $box]);
|
|
|
|
$zero = ['x' => 0.0, 'y' => 0.0, 'w' => 0.0, 'h' => 0.0];
|
|
$this->assertSame($zero, $obj->getLastBBox());
|
|
$this->assertSame($zero, $obj->getLastTextBBox());
|
|
$this->assertSame($zero, $obj->getLastCellBBox());
|
|
|
|
$this->setObjectProperty($obj, 'bbox', []);
|
|
$this->setObjectProperty($obj, 'textbbox', []);
|
|
$this->setObjectProperty($obj, 'cellbbox', []);
|
|
$this->assertSame($zero, $obj->getLastBBox());
|
|
$this->assertSame($zero, $obj->getLastTextBBox());
|
|
$this->assertSame($zero, $obj->getLastCellBBox());
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSplitLinesHandlesMissingSplitEntriesDefensively(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
/** @var array{chars:int, spaces:int, totwidth:float, totspacewidth:float, words:int, split:array<int, array{pos:int, ord:int, septype:string, spaces:int, totwidth:float, totspacewidth:float, wordwidth:float}>} $dim */
|
|
$dim = [
|
|
'chars' => 10,
|
|
'spaces' => 1,
|
|
'totwidth' => 20.0,
|
|
'totspacewidth' => 2.0,
|
|
'words' => 2,
|
|
'split' => [
|
|
0 => [
|
|
'pos' => 0,
|
|
'ord' => 32,
|
|
'septype' => 'WS',
|
|
'spaces' => 0,
|
|
'totwidth' => 5.0,
|
|
'totspacewidth' => 1.0,
|
|
'wordwidth' => 4.0,
|
|
],
|
|
],
|
|
];
|
|
|
|
$lines = $obj->exposeSplitLines([65, 32, 66], $dim, 8.0);
|
|
$this->assertIsArray($lines);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testRawTextLineMethodsReturnEmptyOnEmptyOrdinalArray(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
/** @var array{chars:int, spaces:int, totwidth:float, totspacewidth:float, words:int, split:array<int, array{pos:int, ord:int, septype:string, spaces:int, totwidth:float, totspacewidth:float, wordwidth:float}>} $dim */
|
|
$dim = [
|
|
'chars' => 1,
|
|
'spaces' => 0,
|
|
'totwidth' => 1.0,
|
|
'totspacewidth' => 0.0,
|
|
'words' => 1,
|
|
'split' => [],
|
|
];
|
|
|
|
$this->assertSame('', $obj->exposeRawGetOutTextLine('X', [], $dim));
|
|
$this->assertSame('', $obj->exposeRawOutTextLine('X', [], $dim));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetOutTextPosMatrixReturnsEmptyForInvalidMatrixSizeViaReflection(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$method = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'getOutTextPosMatrix');
|
|
/** @var array{float, float, float, float, float, float} $invalid */
|
|
$invalid = [1.0, 0.0, 0.0, 1.0, 0.0];
|
|
$this->assertSame('', $method->invoke($obj, 'X', $invalid));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTextAdditionalBranchesForCoverage(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
|
|
$cellNoBox = $obj->getTextCell(
|
|
'NoBox',
|
|
1,
|
|
2,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
[],
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
);
|
|
$this->assertNotSame('', $cellNoBox);
|
|
|
|
$obj->addTextCell('', -1, 1, 1, 0, 0, 0, 0, 'T', '');
|
|
$obj->addTextCell('AutoAlign', -1, 1, 1, 0, 0, 0, 0, 'T', '');
|
|
|
|
$this->assertSame('', $obj->exposeRawOutTextLines([], [], 0, 0, 0, 0, 0));
|
|
|
|
$obj->setTexHyphenPatterns(['hyphen' => 'hy3phen']);
|
|
$obj->enableZeroWidthBreakPoints(true);
|
|
[, $ordarrPrepared] = $obj->exposePrepareText('hyphen,word');
|
|
$this->assertNotEmpty($ordarrPrepared);
|
|
|
|
$ordarr = $obj->exposeHyphenateWordOrdArr(['testing' => 'te3st2ing'], $obj->exposeStrToOrdArr('testing'));
|
|
$dim = $obj->exposeGetOrdArrDims($ordarr);
|
|
$lines = $obj->exposeSplitLines($ordarr, $dim, 5);
|
|
$this->assertNotEmpty($lines);
|
|
|
|
$this->setObjectProperty($obj, 'isunicode', true);
|
|
$justOrdArr = $obj->exposeStrToOrdArr('word word');
|
|
$justDim = $obj->exposeGetOrdArrDims($justOrdArr);
|
|
$just = $obj->exposeGetJustifiedString('word word', $justOrdArr, $justDim, 20);
|
|
$this->assertNotSame('', $just);
|
|
|
|
$tmp = \tempnam(\sys_get_temp_dir(), 'tc-hyp-');
|
|
$this->assertNotFalse($tmp);
|
|
\file_put_contents($tmp, "\\patterns{\n\n hy4phen test1ing \n}");
|
|
$parsed = $obj->loadTexHyphenPatterns($tmp);
|
|
if (\file_exists($tmp)) {
|
|
\unlink($tmp);
|
|
}
|
|
$this->assertArrayHasKey('hyphen', $parsed);
|
|
|
|
$this->setObjectProperty($obj, 'defPageContentEnabled', true);
|
|
$pid = $this->requirePageId($page);
|
|
$obj->exposeSetPageContext($pid);
|
|
$defaultOut = $obj->defaultPageContent();
|
|
$this->assertStringContainsString('BT', $defaultOut);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTextRemainingBranchCoverageBatch(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$obj->getTextLine('Hello', 10, 20, 0, 0, 0, 0, 0, true, false, false, false, false, false, '', 'M');
|
|
$middleLtr = $obj->getLastBBox();
|
|
$obj->getTextLine('Hello', 10, 20, 0, 0, 0, 0, 0, true, false, false, false, false, false, 'R', 'M');
|
|
$middleRtl = $obj->getLastBBox();
|
|
$obj->getTextLine('Hello', 10, 20, 0, 0, 0, 0, 0, true, false, false, false, false, false, '', 'E');
|
|
$endLtr = $obj->getLastBBox();
|
|
$obj->getTextLine('Hello', 10, 20, 0, 0, 0, 0, 0, true, false, false, false, false, false, 'R', 'E');
|
|
$endRtl = $obj->getLastBBox();
|
|
|
|
$this->assertLessThan(10, $middleLtr['x'] ?? 0);
|
|
$this->assertGreaterThan(10, $middleRtl['x'] ?? 0);
|
|
$this->assertLessThan(10, $endLtr['x'] ?? 0);
|
|
$this->assertGreaterThan(10, $endRtl['x'] ?? 0);
|
|
|
|
$shadow = [
|
|
'xoffset' => -1.5,
|
|
'yoffset' => -2.0,
|
|
'opacity' => 0.5,
|
|
'mode' => 'Normal',
|
|
'color' => 'gray',
|
|
];
|
|
$shadowOut = $obj->getTextLine(
|
|
'Hello world',
|
|
5,
|
|
6,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
'',
|
|
'',
|
|
$shadow,
|
|
);
|
|
$this->assertSame(2, \substr_count($shadowOut, 'BT '));
|
|
$this->assertStringContainsString('/GS', $shadowOut);
|
|
$soft = $obj->exposeStrToOrdArr("test\u{00AD}ing words");
|
|
$softDim = $obj->exposeGetOrdArrDims($soft);
|
|
$softLines = $obj->exposeSplitLines($soft, $softDim, 5);
|
|
$this->assertGreaterThan(1, \count($softLines));
|
|
$lastIndex = \count($softLines) - 1;
|
|
$this->assertGreaterThanOrEqual(0, $lastIndex);
|
|
$lastLine = $softLines[$lastIndex] ?? null;
|
|
$this->assertIsArray($lastLine);
|
|
$this->assertGreaterThan(0, $lastLine['chars']);
|
|
|
|
$this->assertSame('', $obj->exposeRawGetOutTextLine('Hello', [], []));
|
|
$this->assertSame('', $obj->exposeRawOutTextLine('Hello', [], []));
|
|
$this->assertSame('', $obj->exposeRawGetOutTextPosMatrix('raw', [1, 2]));
|
|
|
|
$unicode = $this->getInternalTestObject();
|
|
$this->initUnicodeFont($unicode);
|
|
$unicode->addPage();
|
|
$this->setObjectProperty($unicode, 'isunicode', true);
|
|
|
|
[$unicodeText, $unicodeOrdArr, $unicodeDim] = $unicode->exposePrepareText("A \u{05D0} B", 'R');
|
|
$this->assertNotEmpty($unicodeOrdArr);
|
|
$this->assertGreaterThan(0, $unicodeDim['totwidth']);
|
|
|
|
$unicodePlain = $unicode->exposeGetJustifiedString($unicodeText, $unicodeOrdArr, $unicodeDim, 0);
|
|
$unicodeJustified = $unicode->exposeGetJustifiedString($unicodeText, $unicodeOrdArr, $unicodeDim, 40);
|
|
$this->assertStringContainsString('Tj', $unicodePlain);
|
|
$this->assertStringContainsString('TJ', $unicodeJustified);
|
|
|
|
$invalid = \tempnam(\sys_get_temp_dir(), 'tc-hyp-invalid-');
|
|
$this->assertNotFalse($invalid);
|
|
\file_put_contents($invalid, "% comment only\n\\patternsMissing{hy4phen}");
|
|
|
|
try {
|
|
$obj->loadTexHyphenPatterns($invalid);
|
|
$this->fail('Expected invalid hyphenation pattern section exception');
|
|
} catch (\Com\Tecnick\Pdf\Exception $e) {
|
|
$this->assertStringContainsString('Invalid hyphenation pattern section', $e->getMessage());
|
|
} finally {
|
|
if (\file_exists($invalid)) {
|
|
\unlink($invalid);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testPdfUaActualTextLigatureHelpersAndTagging(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$noLigature = $obj->exposeGetActualTextForOrdarr($obj->exposeStrToOrdArr('office'));
|
|
$this->assertSame('', $noLigature);
|
|
|
|
$this->assertSame('fi', $obj->exposeGetActualTextForOrdarr([0xFB01]));
|
|
$this->assertSame('ffi', $obj->exposeGetActualTextForOrdarr([0xFB03]));
|
|
|
|
$mixed = $obj->exposeGetActualTextForOrdarr([0x0061, 0xFB01, 0x0062]);
|
|
$this->assertSame('afib', $mixed);
|
|
|
|
$formatted = $obj->exposeFormatPdfUaActualText('fi');
|
|
$this->assertSame('<feff00660069>', $formatted);
|
|
|
|
$pdfua = new TestableText('mm', true, false, true, 'pdfua');
|
|
$withActual = $pdfua->exposeTagPdfUaTextContent("BT (x) Tj ET\n", 0, 'fi');
|
|
$withoutActual = $pdfua->exposeTagPdfUaTextContent("BT (x) Tj ET\n", 0);
|
|
|
|
$this->assertStringContainsString('/P <</MCID 0 /ActualText <feff00660069>>> BDC', $withActual);
|
|
$this->assertStringContainsString('EMC', $withActual);
|
|
$this->assertStringContainsString('/P <</MCID 1>> BDC', $withoutActual);
|
|
$this->assertStringNotContainsString('/ActualText', $withoutActual);
|
|
|
|
$multiLine = "BT (line1) Tj ET\nBT (line2) Tj ET\n";
|
|
$wrappedMultiLine = $pdfua->exposeTagPdfUaTextContent($multiLine, 0);
|
|
|
|
$this->assertSame(1, \substr_count($wrappedMultiLine, '/P <</MCID 2>> BDC'));
|
|
$this->assertSame(1, \substr_count($wrappedMultiLine, 'EMC'));
|
|
$this->assertStringContainsString("BT (line1) Tj ET\nBT (line2) Tj ET\n", $wrappedMultiLine);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetTextLineOmitsShadowAlphaInPdfx3(): void
|
|
{
|
|
$obj = new TestableText('mm', true, false, true, 'pdfx3');
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$shadow = [
|
|
'xoffset' => -1.5,
|
|
'yoffset' => -2.0,
|
|
'opacity' => 0.5,
|
|
'mode' => 'Normal',
|
|
'color' => 'gray',
|
|
];
|
|
|
|
$shadowOut = $obj->getTextLine(
|
|
'Hello world',
|
|
5,
|
|
6,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
'',
|
|
'',
|
|
$shadow,
|
|
);
|
|
|
|
$this->assertSame(2, \substr_count($shadowOut, 'BT '));
|
|
$this->assertStringNotContainsString('/GS', $shadowOut);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testPdfUaStructElementLifecycleAndNesting(): void
|
|
{
|
|
$plain = $this->getInternalTestObject();
|
|
$plain->beginStructElem('P', 0, 'ignored');
|
|
$plain->endStructElem();
|
|
$this->assertSame([], $this->getObjectProperty($plain, 'pdfuaStructStack'));
|
|
|
|
$obj = new TestableText('mm', true, false, true, 'pdfua');
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
$obj->beginStructElem('Figure', $pid, 'Parent alt', ['Lang' => 'en-US']);
|
|
$obj->beginStructElem('P', $pid);
|
|
$obj->exposeTagPdfUaTextContent("BT (nested) Tj ET\n", $pid);
|
|
$obj->endStructElem();
|
|
$obj->endStructElem();
|
|
|
|
/** @var array<int, array<string, mixed>> $log */
|
|
$log = $this->getObjectProperty($obj, 'pdfuaStructLog');
|
|
$this->assertCount(2, $log);
|
|
$this->assertSame('P', $log[0]['role'] ?? null);
|
|
$this->assertSame('Figure', $log[1]['role'] ?? null);
|
|
$this->assertSame('Parent alt', $log[1]['alt'] ?? null);
|
|
$this->assertSame(['Lang' => 'en-US'], $log[1]['attr'] ?? null);
|
|
|
|
/** @var array<int, mixed> $kids */
|
|
$kids = \is_array($log[1]['kids'] ?? null) ? $log[1]['kids'] : [];
|
|
$this->assertNotEmpty($kids);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testPdfUaFigureTaggingAndAnnotationRegistrationBranches(): void
|
|
{
|
|
$obj = new TestableText('mm', true, false, true, 'pdfua');
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
$beforeContent = $pageObj->getPage($pid)['content'];
|
|
$beforeCount = \count($beforeContent);
|
|
$obj->addTaggedFigureContent('', $pid, '');
|
|
$afterEmpty = $pageObj->getPage($pid)['content'];
|
|
$this->assertCount($beforeCount, $afterEmpty);
|
|
|
|
$wrapped = $obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'Standalone figure');
|
|
$this->assertStringContainsString('/Figure <</MCID', $wrapped);
|
|
$this->assertStringContainsString('EMC', $wrapped);
|
|
|
|
$obj->beginStructElem('Figure', $pid, 'Open figure');
|
|
$obj->addTaggedFigureContent("q\nQ\n", $pid, 'ignored alt');
|
|
$obj->exposeRegisterPdfUaAnnotation(42, $pid);
|
|
|
|
/** @var array<int, array<string, mixed>> $stack */
|
|
$stack = $this->getObjectProperty($obj, 'pdfuaStructStack');
|
|
$topKey = \array_key_last($stack);
|
|
$top = \is_int($topKey) && isset($stack[$topKey]) ? $stack[$topKey] : null;
|
|
$this->assertIsArray($top);
|
|
$this->assertSame([42], $top['annots'] ?? null);
|
|
$this->assertSame('Open figure', $top['alt'] ?? null);
|
|
|
|
$obj->endStructElem();
|
|
|
|
$obj->exposeRegisterPdfUaAnnotation(77, $pid);
|
|
|
|
/** @var array<int, array<string, mixed>> $log */
|
|
$log = $this->getObjectProperty($obj, 'pdfuaStructLog');
|
|
$lastKey = \array_key_last($log);
|
|
$last = \is_int($lastKey) && isset($log[$lastKey]) ? $log[$lastKey] : null;
|
|
$this->assertIsArray($last);
|
|
$this->assertSame('Link', $last['role'] ?? null);
|
|
$this->assertSame([77], $last['annots'] ?? null);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testPdfUaFigureBoundingBoxIsRecordedAndMerged(): void
|
|
{
|
|
$obj = new TestableText('mm', true, false, true, 'pdfua');
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
// A standalone figure records the box it was given, normalized to [llx, lly, urx, ury].
|
|
$obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'Standalone', [30.0, 80.0, 10.0, 20.0]);
|
|
|
|
/** @var array<int, array<string, mixed>> $log */
|
|
$log = $this->getObjectProperty($obj, 'pdfuaStructLog');
|
|
$lastKey = \array_key_last($log);
|
|
$last = \is_int($lastKey) && isset($log[$lastKey]) ? $log[$lastKey] : null;
|
|
$this->assertIsArray($last);
|
|
$this->assertSame([10.0, 20.0, 30.0, 80.0], $last['bbox'] ?? null);
|
|
|
|
// Content added to an open Figure bracket grows the box to the union of both.
|
|
$obj->beginStructElem('Figure', $pid, 'Open figure');
|
|
$obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, '', [10.0, 20.0, 30.0, 40.0]);
|
|
$obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, '', [25.0, 5.0, 50.0, 35.0]);
|
|
|
|
/** @var array<int, array<string, mixed>> $stack */
|
|
$stack = $this->getObjectProperty($obj, 'pdfuaStructStack');
|
|
$topKey = \array_key_last($stack);
|
|
$top = \is_int($topKey) && isset($stack[$topKey]) ? $stack[$topKey] : null;
|
|
$this->assertIsArray($top);
|
|
$this->assertSame([10.0, 5.0, 50.0, 40.0], $top['bbox'] ?? null);
|
|
|
|
// A figure tagged without a box gets no bbox entry.
|
|
$obj->endStructElem();
|
|
$obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'No box');
|
|
|
|
/** @var array<int, array<string, mixed>> $log */
|
|
$log = $this->getObjectProperty($obj, 'pdfuaStructLog');
|
|
$lastKey = \array_key_last($log);
|
|
$last = \is_int($lastKey) && isset($log[$lastKey]) ? $log[$lastKey] : null;
|
|
$this->assertIsArray($last);
|
|
$this->assertArrayNotHasKey('bbox', $last);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testRemoveOrdArrSoftHyphensKeepsTrailingSoftHyphenOnlyWhenPresent(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$withoutTrailing = $obj->exposeRemoveOrdArrSoftHyphens([65, 173, 8203, 66]);
|
|
$withTrailing = $obj->exposeRemoveOrdArrSoftHyphens([65, 8203, 173]);
|
|
|
|
$this->assertSame([65, 66], $withoutTrailing);
|
|
$this->assertSame([65, 173], $withTrailing);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testTextBaseCleanupAndSoftHyphenRemovalImplementationsAreCallable(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
$cleanupMethod = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'cleanupText');
|
|
$this->assertSame('A B C', $cleanupMethod->invoke($obj, "A\rB\u{00A0}C\u{00AD}"));
|
|
|
|
$removeMethod = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'removeOrdArrSoftHyphens');
|
|
$this->assertSame([65, 66], $removeMethod->invoke($obj, [65, 173, 8203, 66]));
|
|
$this->assertSame([65, 173], $removeMethod->invoke($obj, [65, 8203, 173]));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testLoadTexHyphenPatternsSkipsEmptyPatternTokens(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$tmp = \tempnam(\sys_get_temp_dir(), 'tc-hyp-empty-');
|
|
$this->assertNotFalse($tmp);
|
|
|
|
\file_put_contents($tmp, "\\patterns{\n\n hy4phen test1ing \n}");
|
|
$patterns = $obj->loadTexHyphenPatterns($tmp);
|
|
if (\file_exists($tmp)) {
|
|
\unlink($tmp);
|
|
}
|
|
|
|
$this->assertSame('hy4phen', $patterns['hyphen'] ?? null);
|
|
$this->assertSame('test1ing', $patterns['testing'] ?? null);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testFitTextCellByTruncationGuardBranchesViaReflection(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$method = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'fitTextCellByTruncation');
|
|
$this->assertSame([], $method->invoke($obj, [], 20.0, 10.0, 0.0, 0.0));
|
|
|
|
[, $shortOrdarr] = $obj->exposePrepareText('fit');
|
|
$this->assertSame($shortOrdarr, $method->invoke($obj, $shortOrdarr, 300.0, 300.0, 0.0, 0.0));
|
|
|
|
[, $longOrdarr] = $obj->exposePrepareText(
|
|
'This sentence is intentionally long to force truncation checks inside tiny cells.',
|
|
);
|
|
$this->assertSame([], $method->invoke($obj, $longOrdarr, 20.0, 0.1, 0.0, 0.0));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testPdfUaTaggingGuardsAndArtifactBranches(): void
|
|
{
|
|
$plain = $this->getInternalTestObject();
|
|
$this->assertSame('q Q', $plain->exposeTagPdfUaFigureContent('q Q', 0, 'alt'));
|
|
|
|
$obj = new TestableText('mm', true, false, true, 'pdfua');
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
$this->assertSame('/Artifact BMC' . "\n", $obj->beginArtifact());
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
$beforeCount = \count($pageObj->getPage($pid)['content']);
|
|
$obj->addArtifactContent('', $pid, 'Pagination', 'Footer');
|
|
$afterEmptyCount = \count($pageObj->getPage($pid)['content']);
|
|
$this->assertSame($beforeCount, $afterEmptyCount);
|
|
|
|
$obj->addArtifactContent('q 0 0 1 rg Q', $pid, 'Pagination', 'Footer');
|
|
$artifactContent = \implode("\n", $pageObj->getPage($pid)['content']);
|
|
$this->assertStringContainsString('/Artifact << /Type /Pagination /Subtype /Footer >> BDC', $artifactContent);
|
|
$this->assertStringContainsString('q 0 0 1 rg Q' . "\n", $artifactContent);
|
|
|
|
$taggedInvalid = $obj->exposeTagPdfUaTextContent('q 1 0 0 1 0 0 cm Q', $pid, 'fi');
|
|
$this->assertSame('q 1 0 0 1 0 0 cm Q', $taggedInvalid);
|
|
|
|
$logBefore = \json_encode($this->getObjectProperty($obj, 'pdfuaStructLog'), \JSON_THROW_ON_ERROR);
|
|
$obj->exposeRegisterPdfUaAnnotation(0, $pid);
|
|
$logAfter = \json_encode($this->getObjectProperty($obj, 'pdfuaStructLog'), \JSON_THROW_ON_ERROR);
|
|
$this->assertSame($logBefore, $logAfter);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testPdfUaFigureTaggingAddsAltOnOpenFigureAndParentKid(): void
|
|
{
|
|
$obj = new TestableText('mm', true, false, true, 'pdfua');
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage();
|
|
$pid = $this->requirePageId($page);
|
|
|
|
$obj->beginStructElem('Figure', $pid);
|
|
$obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'late alt');
|
|
/** @var array<int, array<string, mixed>> $stack */
|
|
$stack = $this->getObjectProperty($obj, 'pdfuaStructStack');
|
|
$figureKey = \array_key_last($stack);
|
|
$figureTop = \is_int($figureKey) && isset($stack[$figureKey]) ? $stack[$figureKey] : null;
|
|
$this->assertIsArray($figureTop);
|
|
$this->assertSame('late alt', $figureTop['alt'] ?? null);
|
|
$obj->endStructElem();
|
|
|
|
$obj->beginStructElem('P', $pid);
|
|
$obj->exposeTagPdfUaFigureContent("q\nQ\n", $pid, 'child figure');
|
|
/** @var array<int, array<string, mixed>> $stack */
|
|
$stack = $this->getObjectProperty($obj, 'pdfuaStructStack');
|
|
$parentKey = \array_key_last($stack);
|
|
$parent = \is_int($parentKey) && isset($stack[$parentKey]) ? $stack[$parentKey] : null;
|
|
$this->assertIsArray($parent);
|
|
/** @var array<int, mixed> $kids */
|
|
$kids = \is_array($parent['kids'] ?? null) ? $parent['kids'] : [];
|
|
$this->assertNotEmpty($kids);
|
|
|
|
$inlineNoNl = $obj->exposeTagPdfUaFigureContent('q Q', $pid, 'inline');
|
|
$this->assertStringContainsString("q Q\nEMC\n", $inlineNoNl);
|
|
$obj->endStructElem();
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testEndStructElemReturnsWhenTopIndexIsMissing(): void
|
|
{
|
|
$obj = new TestableText('mm', true, false, true, 'pdfua');
|
|
/** @var array<int, array<string, mixed>|null> $sparse */
|
|
$sparse = [
|
|
5 => null,
|
|
];
|
|
$this->setObjectProperty($obj, 'pdfuaStructStack', $sparse);
|
|
$obj->endStructElem();
|
|
$this->assertSame([], $this->getObjectProperty($obj, 'pdfuaStructLog'));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testSplitLinesSkipsMissingWordEntriesViaReflection(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$obj->addPage();
|
|
|
|
$method = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'splitLines');
|
|
/** @var array<int, int> $ordarr */
|
|
$ordarr = [65, 32, 66];
|
|
/** @var array<string, mixed> $dim */
|
|
$dim = [
|
|
'chars' => 3,
|
|
'spaces' => 1,
|
|
'words' => 2,
|
|
'totwidth' => 12.0,
|
|
'totspacewidth' => 2.0,
|
|
'split' => [
|
|
1 => [
|
|
'pos' => 1,
|
|
'ord' => 32,
|
|
'septype' => 'WS',
|
|
'spaces' => 1,
|
|
'totwidth' => 6.0,
|
|
'totspacewidth' => 2.0,
|
|
'wordwidth' => 4.0,
|
|
],
|
|
],
|
|
];
|
|
|
|
$this->assertIsArray($method->invoke($obj, $ordarr, $dim, 8.0, 0.0));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testUnicodeJustifiedStringHandlesZeroFontSizeGuard(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initUnicodeFont($obj);
|
|
$obj->addPage();
|
|
$this->setObjectProperty($obj, 'isunicode', true);
|
|
|
|
$markerMethod = new \ReflectionMethod(\Com\Tecnick\Pdf\Text::class, 'getTextCellTruncationMarkerOrdArr');
|
|
$this->assertSame([0x2026], $markerMethod->invoke($obj));
|
|
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
/** @var int $pon */
|
|
$pon = $this->getObjectProperty($obj, 'pon');
|
|
$cur = $font->getCurrentFont();
|
|
$font->cloneFont($pon, $cur['idx'], null, 0.0);
|
|
|
|
[, $ordarr, $dim] = $obj->exposePrepareText("A \u{05D0} B", 'R');
|
|
$just = $obj->exposeGetJustifiedString("A \u{05D0} B", $ordarr, $dim, 20.0);
|
|
$this->assertStringContainsString('TJ', $just);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddTextCellMultiBlockDrawCellUsesFillColorOnContinuationBorders(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage([
|
|
'region' => [
|
|
['RX' => 0.0, 'RY' => 0.0, 'RW' => 60.0, 'RH' => 4.0],
|
|
['RX' => 0.0, 'RY' => 6.0, 'RW' => 60.0, 'RH' => 4.0],
|
|
['RX' => 0.0, 'RY' => 12.0, 'RW' => 60.0, 'RH' => 4.0],
|
|
],
|
|
]);
|
|
$pid = $this->requirePageId($page);
|
|
|
|
$styles = [
|
|
'all' => [
|
|
'lineWidth' => 0.4,
|
|
'lineColor' => '#111111',
|
|
'fillColor' => '#00ff00',
|
|
'lineCap' => 'butt',
|
|
'lineJoin' => 'miter',
|
|
'dashArray' => [],
|
|
'dashPhase' => 0,
|
|
],
|
|
];
|
|
|
|
$obj->addTextCell(
|
|
'Long long long long long long long long long long long long long long long long long long long text',
|
|
$pid,
|
|
1,
|
|
1,
|
|
25,
|
|
0,
|
|
0,
|
|
0,
|
|
'T',
|
|
'L',
|
|
null,
|
|
$styles,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
$content = \implode("\n", $pageObj->getPage($pid)['content']);
|
|
$this->assertStringContainsString('0.000000 1.000000 0.000000 rg', $content);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testAddTextCellSpansRegionsAndRestoresFontOutputSuffix(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
$this->initFont($obj);
|
|
$page = $obj->addPage([
|
|
'region' => [
|
|
['RX' => 0.0, 'RY' => 0.0, 'RW' => 80.0, 'RH' => 6.0],
|
|
['RX' => 0.0, 'RY' => 8.0, 'RW' => 80.0, 'RH' => 6.0],
|
|
],
|
|
]);
|
|
$pid = $this->requirePageId($page);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Page\Page $pageObj */
|
|
$pageObj = $this->getObjectProperty($obj, 'page');
|
|
$before = $pageObj->getPage($pid)['content'];
|
|
$beforeCount = \count($before);
|
|
|
|
$styles = [
|
|
'all' => [
|
|
'lineWidth' => 0.4,
|
|
'lineCap' => 'butt',
|
|
'lineJoin' => 'miter',
|
|
'dashArray' => [],
|
|
'dashPhase' => 0,
|
|
'lineColor' => '#3a4a5a',
|
|
'fillColor' => '#dddddd',
|
|
],
|
|
];
|
|
|
|
$txt =
|
|
'This is a long text block that must wrap across tiny regions to exercise multi-block handling.'
|
|
. ' The renderer should continue to the next region and still close with restored font output.';
|
|
|
|
$obj->addTextCell(
|
|
$txt,
|
|
$pid,
|
|
1,
|
|
1,
|
|
78,
|
|
0,
|
|
0,
|
|
0,
|
|
'T',
|
|
'J',
|
|
null,
|
|
$styles,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
'',
|
|
null,
|
|
'F',
|
|
);
|
|
|
|
/** @var array<int, string> $after */
|
|
$after = $pageObj->getPage($pid)['content'];
|
|
$this->assertGreaterThan($beforeCount, \count($after));
|
|
$chunk = \implode('', \array_slice($after, $beforeCount));
|
|
$this->assertStringContainsString('BT ', $chunk);
|
|
|
|
/** @var array<int, array{x: float, y: float, w: float, h: float}> $cellBoxes */
|
|
$cellBoxes = $this->getObjectProperty($obj, 'cellbbox');
|
|
$this->assertGreaterThan(1, \count($cellBoxes));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetOutTextStateOperatorTzEmitsPercentageFromStretchRatio(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
// Stretching is a ratio (1.0 == 100%): ratio 1.0 emits no Tz.
|
|
$this->assertSame('raw', $obj->exposeGetOutTextStateOperatorTz('raw', 1.0));
|
|
|
|
// The PDF Tz operator is a percentage, so the ratio is multiplied by 100.
|
|
$compressed = $obj->exposeGetOutTextStateOperatorTz('raw', 0.9);
|
|
$this->assertStringContainsString('90.000000 Tz', $compressed);
|
|
$this->assertStringContainsString('raw 100 Tz', $compressed);
|
|
|
|
$this->assertStringContainsString('200.000000 Tz', $obj->exposeGetOutTextStateOperatorTz('raw', 2.0));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetOutTextStateOperatorTcUsesPointsWithSign(): void
|
|
{
|
|
$obj = $this->getInternalTestObject();
|
|
|
|
// Spacing is in points: 0 emits no Tc.
|
|
$this->assertSame('raw', $obj->exposeGetOutTextStateOperatorTc('raw', 0.0));
|
|
|
|
$positive = $obj->exposeGetOutTextStateOperatorTc('raw', 0.72);
|
|
$this->assertStringContainsString('0.720000 Tc', $positive);
|
|
$this->assertStringContainsString('raw 0 Tc', $positive);
|
|
|
|
// Negative tracking must round-trip with its sign preserved.
|
|
$this->assertStringContainsString('-0.720000 Tc', $obj->exposeGetOutTextStateOperatorTc('raw', -0.72));
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testStretchingRatioScalesLayoutWidthAndEmittedTz(): void
|
|
{
|
|
$obj = $this->getTestObject();
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
/** @var int $pon */
|
|
$pon = $this->getObjectProperty($obj, 'pon');
|
|
$fontfile = (string) \realpath(__DIR__
|
|
. '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/core/helvetica.json');
|
|
|
|
$font->insert($pon, 'helvetica', '', 11, 0.0, 1.0, $fontfile);
|
|
$obj->addPage();
|
|
$ordarr = \array_values($obj->uniconv->strToOrdArr('LEFT'));
|
|
$widthNeutral = $font->getOrdArrWidth($ordarr);
|
|
|
|
$font->insert($pon, 'helvetica', '', 11, 0.0, 0.9, $fontfile);
|
|
$widthStretched = $font->getOrdArrWidth($ordarr);
|
|
|
|
// Layout width tracks the ratio (90% of the unstretched width) ...
|
|
$this->assertEqualsWithDelta($widthNeutral * 0.9, $widthStretched, 0.001);
|
|
|
|
// ... and the rendered line emits the matching 90% Tz (not 0.9).
|
|
$line = $obj->getTextLine(txt: 'LEFT', posx: 10, posy: 20);
|
|
$this->assertStringContainsString('90.000000 Tz', $line);
|
|
}
|
|
|
|
/** @throws \Throwable */
|
|
public function testGetJustifiedStringDividesWordSpacingByStretchRatio(): void
|
|
{
|
|
$width = 100.0;
|
|
$txt = 'a b c';
|
|
$dim = [
|
|
'chars' => 5,
|
|
'spaces' => 2,
|
|
'totwidth' => 50.0,
|
|
'totspacewidth' => 10.0,
|
|
'words' => 3,
|
|
'split' => [],
|
|
];
|
|
|
|
$neutral = $this->justifyWordSpacing($txt, $dim, $width, 1.0);
|
|
$stretched = $this->justifyWordSpacing($txt, $dim, $width, 1.1);
|
|
|
|
// Tw is multiplied by the horizontal scale at render time, so the emitted
|
|
// word spacing must be the neutral value divided by the stretching ratio.
|
|
$this->assertEqualsWithDelta($neutral / 1.1, $stretched, 0.0001);
|
|
}
|
|
|
|
/**
|
|
* Build a justified single line at the given stretching ratio (forcing the
|
|
* Tw word-spacing path with a non-unicode core font) and return the emitted
|
|
* Tw value in points.
|
|
*
|
|
* @param TTextDims $dim
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
private function justifyWordSpacing(string $txt, array $dim, float $width, float $stretching): float
|
|
{
|
|
$obj = new TestableText(isunicode: false);
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
/** @var int $pon */
|
|
$pon = $this->getObjectProperty($obj, 'pon');
|
|
$fontfile = (string) \realpath(__DIR__
|
|
. '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts/core/helvetica.json');
|
|
$font->insert($pon, 'helvetica', '', 12, 0.0, $stretching, $fontfile);
|
|
|
|
$ordarr = $obj->exposeStrToOrdArr($txt);
|
|
$out = $obj->exposeGetJustifiedString($txt, $ordarr, $dim, $width);
|
|
|
|
$matches = [];
|
|
if (\preg_match('/(-?\d+\.\d+) Tw/', $out, $matches) !== 1) {
|
|
$this->fail('Expected a Tw word-spacing operator, got: ' . $out);
|
|
}
|
|
|
|
$value = $matches[1] ?? '0';
|
|
|
|
return \is_numeric($value) ? (float) $value : 0.0;
|
|
}
|
|
|
|
/**
|
|
* A caller-supplied $linespace that exactly cancels the font height makes
|
|
* the per-region line pitch zero. addTextCell must not divide by it
|
|
* (regression: DivisionByZeroError). Using point units keeps the unit
|
|
* conversion an identity so $linespace can cancel the font height exactly.
|
|
*
|
|
* @throws \Throwable
|
|
*/
|
|
public function testAddTextCellLineSpaceCancelingFontHeightDoesNotDivideByZero(): void
|
|
{
|
|
$obj = new \Com\Tecnick\Pdf\Tcpdf('pt');
|
|
$this->initFontAndPage($obj);
|
|
|
|
/** @var \Com\Tecnick\Pdf\Font\Stack $font */
|
|
$font = $this->getObjectProperty($obj, 'font');
|
|
$curfont = $font->getCurrentFont();
|
|
$fontHeight = $curfont['height'];
|
|
$this->assertGreaterThan(0.0, $fontHeight);
|
|
|
|
$obj->addTextCell(txt: 'Hello world', linespace: -$fontHeight);
|
|
|
|
$this->assertNotSame('', $obj->getOutPDFString());
|
|
}
|
|
}
|