Files
baseProject/vendor/tecnickcom/tc-lib-pdf/test/TcpdfImporterFacadeTest.php

648 lines
21 KiB
PHP

<?php
/**
* TcpdfImporterFacadeTest.php
*
* @since 2026-04-25
* @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;
use Com\Tecnick\Pdf\Import\ImportPageOutOfRangeException;
use Com\Tecnick\Pdf\Import\ImportSourceNotFoundException;
use Com\Tecnick\Pdf\Import\ImportUnsupportedFeatureException;
use Com\Tecnick\Pdf\Import\PageTemplate;
use Com\Tecnick\Pdf\Tcpdf;
use PHPUnit\Framework\TestCase;
/**
* Integration tests for the PDF import facade methods on Tcpdf.
*/
class TcpdfImporterFacadeTest extends TestCase
{
/** Path to the single-page test fixture. */
private string $simplePdf;
/** Path to the two-page test fixture with a shared font. */
private string $multipagePdf;
/** Path to a fixture with explicit Media/Crop/Bleed/Trim/Art boxes. */
private string $boxOptionsPdf;
/** Path to a fixture with /Rotate 90 on the page dictionary. */
private string $rotatedPdf;
/** Path to a fixture containing alpha/blend ExtGState content. */
private string $transparencyPdf;
/** Path to a fixture with an /Encrypt trailer entry. */
private string $encryptedPdf;
/**
* @throws \Throwable
*/
protected function setUp(): void
{
if (!\defined('K_PATH_FONTS')) {
$fonts = (string) \realpath(__DIR__ . '/../vendor/tecnickcom/tc-lib-pdf-font/target/fonts');
\define('K_PATH_FONTS', $fonts);
}
$this->simplePdf = __DIR__ . '/fixtures/simple_import.pdf';
$this->multipagePdf = __DIR__ . '/fixtures/multipage_import.pdf';
$this->boxOptionsPdf = __DIR__ . '/fixtures/box_options_import.pdf';
$this->rotatedPdf = __DIR__ . '/fixtures/rotated_import.pdf';
$this->transparencyPdf = __DIR__ . '/fixtures/transparency_import.pdf';
$this->encryptedPdf = __DIR__ . '/fixtures/encrypted_import_stub.pdf';
}
// ------------------------------------------------------------------ helpers
/**
* @throws \Throwable
*/
private function makePdf(): Tcpdf
{
$pdf = new Tcpdf();
// A default font must be inserted before any addPage call so that
// setPageContext / getOutCurrentFont does not receive a null font key.
$pdf->font->insert($pdf->pon, 'helvetica', '', 12);
return $pdf;
}
/**
* @throws \Throwable
*/
private function makePdfWithMode(string $mode): Tcpdf
{
$pdf = new Tcpdf(mode: $mode);
$pdf->font->insert($pdf->pon, 'helvetica', '', 12);
return $pdf;
}
/**
* @throws \Throwable
*/
private function getPdfVersion(Tcpdf $pdf): string
{
$ref = new \ReflectionClass($pdf);
while ($ref !== false) {
if ($ref->hasProperty('pdfver')) {
$prop = $ref->getProperty('pdfver');
return $this->stringValue($prop->getValue($pdf));
}
$ref = $ref->getParentClass();
}
return '';
}
private function stringValue(mixed $value): string
{
return \is_string($value) ? $value : '';
}
/**
* Build a minimal one-page PDF whose /Contents is an array of two Flate streams.
*/
private function buildMultiContentFlatePdf(): string
{
$streamA = \gzcompress("BT /F1 12 Tf 20 160 Td (A) Tj ET\n");
$streamB = \gzcompress("BT /F1 12 Tf 20 140 Td (B) Tj ET\n");
self::assertNotFalse($streamA);
self::assertNotFalse($streamB);
$objects = [];
$objects[] = '1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj' . "\n";
$objects[] = '2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj' . "\n";
$objects[] =
'3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] '
. '/Resources << /Font << /F1 6 0 R >> >> /Contents [4 0 R 5 0 R] >> endobj'
. "\n";
$objects[] =
'4 0 obj << /Length '
. \strlen($streamA)
. ' /Filter /FlateDecode >> stream'
. "\n"
. $streamA
. "\n"
. 'endstream endobj'
. "\n";
$objects[] =
'5 0 obj << /Length '
. \strlen($streamB)
. ' /Filter /FlateDecode >> stream'
. "\n"
. $streamB
. "\n"
. 'endstream endobj'
. "\n";
$objects[] = '6 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj' . "\n";
$pdf = "%PDF-1.4\n";
$offsets = [0 => 0];
foreach ($objects as $idx => $obj) {
$objNum = $idx + 1;
$offsets[$objNum] = \strlen($pdf);
$pdf .= $obj;
}
$xrefOffset = \strlen($pdf);
$pdf .= 'xref' . "\n";
$pdf .= '0 7' . "\n";
$pdf .= '0000000000 65535 f ' . "\n";
for ($objNum = 1; $objNum <= 6; ++$objNum) {
$offset = $offsets[$objNum] ?? 0;
$pdf .= \sprintf('%010d 00000 n ' . "\n", $offset);
}
$pdf .= 'trailer << /Size 7 /Root 1 0 R >>' . "\n";
$pdf .= 'startxref' . "\n";
$pdf .= $xrefOffset . "\n";
$pdf .= '%%EOF' . "\n";
return $pdf;
}
// ------------------------------------------------------------------ setImportSourceFile / setImportSourceData
/**
* @throws \Throwable
*/
public function testSetImportSourceFileReturnsNonEmptyId(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$this->assertNotEmpty($srcId);
}
/**
* @throws \Throwable
*/
public function testSetImportSourceDataReturnsNonEmptyId(): void
{
$pdf = $this->makePdf();
$data = (string) \file_get_contents($this->simplePdf);
$srcId = $pdf->setImportSourceData($data);
$this->assertNotEmpty($srcId);
}
/**
* @throws \Throwable
*/
public function testSetImportSourceFileThrowsForMissingFile(): void
{
$pdf = $this->makePdf();
$this->expectException(ImportSourceNotFoundException::class);
$pdf->setImportSourceFile('/nonexistent/path.pdf');
}
/**
* @throws \Throwable
*/
public function testSetImportSourceFileThrowsForEncryptedPdf(): void
{
$pdf = $this->makePdf();
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('encrypted PDF', '/') . '/');
$pdf->setImportSourceFile($this->encryptedPdf);
}
/**
* @throws \Throwable
*/
public function testSetImportSourceFileWithPasswordThrowsActionableEncryptedError(): void
{
$pdf = $this->makePdf();
$this->expectException(ImportUnsupportedFeatureException::class);
$this->expectExceptionMessageMatches('/' . preg_quote('password-based import is not supported', '/') . '/');
$pdf->setImportSourceFile($this->encryptedPdf, ['password' => 'secret']);
}
// ------------------------------------------------------------------ getSourcePageCount
/**
* @throws \Throwable
*/
public function testGetSourcePageCountSimple(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$this->assertSame(1, $pdf->getSourcePageCount($srcId));
}
/**
* @throws \Throwable
*/
public function testGetSourcePageCountMultipage(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$this->assertSame(2, $pdf->getSourcePageCount($srcId));
}
// ------------------------------------------------------------------ importPage / importPages
/**
* @throws \Throwable
*/
public function testImportPageReturnsPageTemplate(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->importPage($srcId, 1);
$this->assertInstanceOf(PageTemplate::class, $tpl);
$this->assertGreaterThan(0.0, $tpl->getWidth());
$this->assertGreaterThan(0.0, $tpl->getHeight());
}
/**
* @throws \Throwable
*/
public function testImportPageThrowsForOutOfRange(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$this->expectException(ImportPageOutOfRangeException::class);
$pdf->importPage($srcId, 99);
}
/**
* @throws \Throwable
*/
public function testImportPageUsesTrimBoxWhenRequested(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->boxOptionsPdf);
$tpl = $pdf->importPage($srcId, 1, ['box' => 'TrimBox']);
$this->assertEqualsWithDelta(460.0, $tpl->getWidth(), 0.01);
$this->assertEqualsWithDelta(660.0, $tpl->getHeight(), 0.01);
}
/**
* @throws \Throwable
*/
public function testImportPageUsesArtBoxWhenRequested(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->boxOptionsPdf);
$tpl = $pdf->importPage($srcId, 1, ['box' => 'ArtBox']);
$this->assertEqualsWithDelta(440.0, $tpl->getWidth(), 0.01);
$this->assertEqualsWithDelta(640.0, $tpl->getHeight(), 0.01);
}
/**
* @throws \Throwable
*/
public function testImportPageRespectsRotationByDefault(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->rotatedPdf);
$tpl = $pdf->importPage($srcId, 1);
$this->assertSame(90, $tpl->getRotation());
$this->assertGreaterThan($tpl->getHeight(), $tpl->getWidth());
$this->assertEqualsWithDelta(500.0, $tpl->getWidth(), 0.01);
$this->assertEqualsWithDelta(300.0, $tpl->getHeight(), 0.01);
}
/**
* @throws \Throwable
*/
public function testImportPageCanDisableRotationRespect(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->rotatedPdf);
$tpl = $pdf->importPage($srcId, 1, ['respectRotation' => false]);
$this->assertSame(0, $tpl->getRotation());
$this->assertGreaterThan($tpl->getWidth(), $tpl->getHeight());
$this->assertEqualsWithDelta(300.0, $tpl->getWidth(), 0.01);
$this->assertEqualsWithDelta(500.0, $tpl->getHeight(), 0.01);
}
/**
* @throws \Throwable
*/
public function testImportPageWithGroupXObjectBumpsPdfVersionTo14Minimum(): void
{
$pdf = $this->makePdf();
$pdf->setPDFVersion('1.3');
$srcId = $pdf->setImportSourceFile($this->transparencyPdf);
$pdf->importPage($srcId, 1, ['groupXObject' => true]);
$this->assertSame('1.4', $this->getPdfVersion($pdf));
}
/**
* @throws \Throwable
*/
public function testImportPageWithGroupXObjectDisabledKeepsVersion(): void
{
$pdf = $this->makePdf();
$pdf->setPDFVersion('1.3');
$srcId = $pdf->setImportSourceFile($this->transparencyPdf);
$pdf->importPage($srcId, 1, ['groupXObject' => false]);
$this->assertSame('1.3', $this->getPdfVersion($pdf));
}
/**
* @throws \Throwable
*/
public function testImportPageEmitsTransparencyGroupByDefault(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->transparencyPdf);
$tpl = $pdf->importPage($srcId, 1);
$pdf->addPage();
$pdf->useImportedPage($tpl, 10, 10, 120, 80, ['keepAspectRatio' => false]);
$raw = $pdf->getOutPDFString();
$this->assertStringContainsString('/Group << /Type /Group /S /Transparency >>', $raw);
}
/**
* @throws \Throwable
*/
public function testImportPageSuppressesTransparencyGroupInPdfx3(): void
{
$pdf = $this->makePdfWithMode('pdfx3');
$srcId = $pdf->setImportSourceFile($this->transparencyPdf);
$tpl = $pdf->importPage($srcId, 1, ['groupXObject' => true]);
$pdf->addPage();
$pdf->useImportedPage($tpl, 10, 10, 120, 80, ['keepAspectRatio' => false]);
$raw = $pdf->getOutPDFString();
$this->assertStringNotContainsString('/Group << /Type /Group /S /Transparency >>', $raw);
}
/**
* @throws \Throwable
*/
public function testImportPagesNullRangeImportsAll(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->importPages($srcId);
$this->assertCount(2, $tpls);
foreach ($tpls as $tpl) {
$this->assertInstanceOf(PageTemplate::class, $tpl);
}
}
/**
* @throws \Throwable
*/
public function testImportPagesExplicitRange(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->importPages($srcId, [1]);
$this->assertCount(1, $tpls);
assert(isset($tpls[0]), "\$tpls[0] must be set");
$this->assertInstanceOf(PageTemplate::class, $tpls[0]);
}
/**
* @throws \Throwable
*/
public function testImportPagesThrowsForOutOfRange(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$this->expectException(ImportPageOutOfRangeException::class);
$pdf->importPages($srcId, [1, 99]);
}
// ------------------------------------------------------------------ useImportedPage
/**
* @throws \Throwable
*/
public function testUseImportedPageReturnsPlacementDimensions(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->importPage($srcId, 1);
$pdf->addPage();
$placed = $pdf->useImportedPage($tpl, 10.0, 10.0, 100.0, null, []);
$this->assertArrayHasKey('x', $placed);
$this->assertArrayHasKey('y', $placed);
$this->assertArrayHasKey('width', $placed);
$this->assertArrayHasKey('height', $placed);
$this->assertEqualsWithDelta(100.0, $placed['width'], 0.01);
}
/**
* @throws \Throwable
*/
public function testUseImportedPageAlignCenterCentersInsideRequestedBox(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->importPage($srcId, 1);
$pdf->addPage();
$placed = $pdf->useImportedPage($tpl, 10.0, 20.0, 100.0, 200.0, ['keepAspectRatio' => true, 'align' => 'CC']);
// In a 100x200 box with source ratio 612:792, width is the limiting axis.
$this->assertEqualsWithDelta(100.0, $placed['width'], 0.01);
$this->assertEqualsWithDelta(129.41, $placed['height'], 0.05);
$this->assertEqualsWithDelta(10.0, $placed['x'], 0.01);
$this->assertEqualsWithDelta(55.29, $placed['y'], 0.05);
}
/**
* @throws \Throwable
*/
public function testUseImportedPageWithClipAddsClipOperatorToPageContent(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->importPage($srcId, 1);
$pdf->addPage();
$pdf->useImportedPage($tpl, 15.0, 25.0, 80.0, 60.0, ['clip' => true, 'keepAspectRatio' => false]);
$page = $pdf->page->getPage();
$content = \implode('', $page['content']);
$this->assertStringContainsString(' re W n ', $content);
}
// ------------------------------------------------------------------ addPageFromImport
/**
* @throws \Throwable
*/
public function testAddPageFromImportCreatesPageAndReturnsTemplate(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->addPageFromImport($srcId, 1);
$this->assertInstanceOf(PageTemplate::class, $tpl);
// The first page has pid 0; subsequent pages have positive pids.
$pageId = $pdf->page->getPageID();
$this->assertGreaterThanOrEqual(0, $pageId);
}
/**
* @throws \Throwable
*/
public function testAddPageFromImportPageDimensionsMatchTemplate(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->addPageFromImport($srcId, 1);
$pageId = $pdf->page->getPageID();
$page = $pdf->page->getPage($pageId);
$pageW = $page['width'];
$pageH = $page['height'];
$this->assertGreaterThan(0.0, $pageW);
$this->assertGreaterThan(0.0, $pageH);
// The aspect ratio of the page must match the template.
$this->assertEqualsWithDelta($tpl->getWidth() / $tpl->getHeight(), $pageW / $pageH, 0.01);
}
/**
* @throws \Throwable
*/
public function testAddPageFromImportPlacesXObject(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->simplePdf);
$tpl = $pdf->addPageFromImport($srcId, 1);
$pageId = $pdf->page->getPageID();
$pageContent = $pdf->page->getPage($pageId);
// The page content should reference the XObject.
$content = \implode('', $pageContent['content']);
$this->assertStringContainsString($tpl->getXobjId(), $content);
}
// ------------------------------------------------------------------ appendDocument
/**
* @throws \Throwable
*/
public function testAppendDocumentCreatesOnePagePerSourcePage(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->appendDocument($srcId);
$this->assertCount(2, $tpls);
}
/**
* @throws \Throwable
*/
public function testAppendDocumentConcatenatesDecodedMultiStreamContents(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceData($this->buildMultiContentFlatePdf());
$tpls = $pdf->appendDocument($srcId);
$this->assertCount(1, $tpls);
$raw = $pdf->getOutPDFString();
$this->assertStringContainsString('(A) Tj ET', $raw);
$this->assertStringContainsString('(B) Tj ET', $raw);
}
/**
* @throws \Throwable
*/
public function testAppendDocumentWithRangeCreatesOnlyRequestedPages(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->appendDocument($srcId, [2]);
$this->assertCount(1, $tpls);
assert(isset($tpls[0]), "\$tpls[0] must be set");
$this->assertSame(2, $tpls[0]->getSourcePage());
}
/**
* @throws \Throwable
*/
public function testAppendDocumentRestoresCallerPageContext(): void
{
$pdf = $this->makePdf();
// Create an initial page.
$callerPage = $pdf->addPage();
if (!isset($callerPage['pid']) || !\is_int($callerPage['pid'])) {
$this->fail('Expected integer page id.');
}
$callerPid = $callerPage['pid'];
// Append pages from a multi-page source.
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$pdf->appendDocument($srcId);
// Current page ID should be restored to the caller's page.
$this->assertSame($callerPid, $pdf->page->getPageID());
}
/**
* @throws \Throwable
*/
public function testAppendDocumentWithNoPriorPageLeavesCurrentOnLastAppended(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->appendDocument($srcId);
// No prior page (pid is -1), so restore does not run;
// the current page is the last appended one.
$finalPid = $pdf->page->getPageID();
$this->assertGreaterThan(0, $finalPid);
// Both appended pages should be reachable.
$this->assertCount(2, $tpls);
}
/**
* @throws \Throwable
*/
public function testAppendDocumentThrowsForOutOfRangePage(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$this->expectException(ImportPageOutOfRangeException::class);
$pdf->appendDocument($srcId, [5]);
}
/**
* @throws \Throwable
*/
public function testAppendDocumentXObjectsRegistered(): void
{
$pdf = $this->makePdf();
$srcId = $pdf->setImportSourceFile($this->multipagePdf);
$tpls = $pdf->appendDocument($srcId);
$pages = $pdf->page->getPages();
$pageContent = '';
foreach ($pages as $page) {
$content = $page['content'];
if ($content === []) {
continue;
}
$pageContent .= \implode('', $content);
}
foreach ($tpls as $tpl) {
$this->assertStringContainsString($tpl->getXobjId(), $pageContent);
}
}
}