Files
putnamrec/vendor/tecnickcom/tc-lib-pdf-font/test/SubsetTest.php
T
2026-08-20 11:41:17 +00:00

468 lines
16 KiB
PHP

<?php
/**
* SubsetTest.php
*
* @since 2026-05-01
* @category Library
* @package PdfFont
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2011-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-font
*
* This file is part of tc-lib-pdf-font software library.
*/
namespace Test;
/**
* Subset Test
*
* @since 2026-05-01
* @category Library
* @package PdfFont
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2011-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-font
*/
class SubsetTest extends TestUtil
{
private function setProp(object $obj, string $name, mixed $value): void
{
$prop = new \ReflectionProperty($obj, $name);
$prop->setValue($obj, $value);
}
/** @return array<array-key, mixed> */
private function getDefaultFdt(): array
{
$ref = new \ReflectionClass(\Com\Tecnick\Pdf\Font\Subset::class);
$prop = $ref->getProperty('fdt');
$instance = $ref->newInstanceWithoutConstructor();
return (static fn(mixed $value): array => \is_array($value) ? $value : [])($prop->getValue($instance));
}
/**
* @param array<array-key, mixed> $table
*
* @return array<array-key, mixed>
*/
private function getTableRecord(array $table, string $tag): array
{
if (!isset($table[$tag]) || !\is_array($table[$tag])) {
$this->fail('Missing or invalid table record: ' . $tag);
}
return $table[$tag];
}
/** @param array<array-key, mixed> $table */
private function getTableRecordString(array $table, string $tag, string $field): string
{
$record = $this->getTableRecord($table, $tag);
if (!isset($record[$field]) || !\is_string($record[$field])) {
$this->fail('Missing or invalid table string field: ' . $tag . '.' . $field);
}
return $record[$field];
}
/** @param array<array-key, mixed> $table */
private function getTableRecordInt(array $table, string $tag, string $field): int
{
$record = $this->getTableRecord($table, $tag);
if (!isset($record[$field]) || !\is_int($record[$field])) {
$this->fail('Missing or invalid table int field: ' . $tag . '.' . $field);
}
return $record[$field];
}
/** @throws \Com\Tecnick\Pdf\Font\Exception */
public function testTableChecksumPadsTrailingBytes(): void
{
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
public function __construct() {}
/** @throws \Com\Tecnick\Pdf\Font\Exception */
public function checksum(string $table, int $length): int
{
return $this->getTableChecksum($table, $length);
}
};
// 3-byte table must be zero-padded to a 4-byte word before summing.
$this->assertSame(0x01020300, $subset->checksum("\x01\x02\x03", 3));
}
/** @throws \Com\Tecnick\Pdf\Font\Exception */
public function testTableChecksumHandlesMixedFullAndPartialWords(): void
{
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
public function __construct() {}
/** @throws \Com\Tecnick\Pdf\Font\Exception */
public function checksum(string $table, int $length): int
{
return $this->getTableChecksum($table, $length);
}
};
$table = "\x11\x22\x33\x44\x55\x66\x77";
// 0x11223344 + 0x55667700, modulo 2^32
$this->assertSame(0x6688AA44, $subset->checksum($table, 7));
}
// -------------------------------------------------------------------------
// removeUnusedTables
// -------------------------------------------------------------------------
public function testRemoveUnusedTablesDropsUnknownTableTags(): void
{
// Build an anonymous subclass that exposes removeUnusedTables and lets us
// inspect the resulting fdt without running the full constructor chain.
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
public function __construct() {}
public function run(): void
{
$this->removeUnusedTables();
}
/** @return array<string, mixed> */
public function getTable(): array
{
return $this->fdt['table'];
}
};
// A font binary with 8 bytes of head data (for substr to not be empty)
$font = str_repeat("\x00", 64);
$this->setProp($subset, 'font', $font);
$this->setProp($subset, 'offset', 12);
// Provide two tables: 'head' (known → kept) and 'xxxx' (unknown → removed)
$this->setProp($subset, 'fdt', array_merge($this->getDefaultFdt(), [
'table' => [
'head' => ['offset' => 0, 'length' => 8, 'checkSum' => 0, 'data' => ''],
'xxxx' => ['offset' => 0, 'length' => 8, 'checkSum' => 0, 'data' => ''],
],
]));
$subset->run();
$table = $subset->getTable();
$this->assertArrayHasKey('head', $table);
$this->assertArrayNotHasKey('xxxx', $table);
}
// -------------------------------------------------------------------------
// addProcessedTables
// -------------------------------------------------------------------------
public function testAddProcessedTablesBuildsLocaAndGlyfFromSubsetGlyphs(): void
{
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
public function __construct() {}
public function run(): void
{
$this->addProcessedTables();
}
/** @return array<string, mixed> */
public function getTable(): array
{
return $this->fdt['table'];
}
};
// Font data: 12 bytes (glyf table at offset 0, 12 bytes of raw glyph data)
$font = str_repeat("\xAB", 12);
$this->setProp($subset, 'font', $font);
$this->setProp($subset, 'offset', 0);
// subglyphs: only glyph 0 is in the subset
$this->setProp($subset, 'subglyphs', [0 => true]);
// Inject the fdt state the method needs
$this->setProp($subset, 'fdt', array_merge($this->getDefaultFdt(), [
'tot_num_glyphs' => 2,
'short_offset' => false, // long (Offset32) loca entries
'indexToLoc' => [0 => 0, 1 => 8], // glyph 0 is 8 bytes
'table' => [
'glyf' => ['offset' => 0, 'length' => 12, 'checkSum' => 0, 'data' => ''],
'loca' => ['offset' => 0, 'length' => 0, 'checkSum' => 0, 'data' => ''],
],
]));
$subset->run();
$table = $subset->getTable();
// loca must have been rebuilt
$this->assertNotEmpty($this->getTableRecordString($table, 'loca', 'data'));
// glyf must contain the extracted glyph bytes (8 bytes for glyph 0, padded to multiple of 4)
$this->assertNotEmpty($this->getTableRecordString($table, 'glyf', 'data'));
// The checksum must have been computed (non-zero for non-empty data)
$this->assertNotSame(
0,
$this->getTableRecordInt($table, 'loca', 'checkSum') + $this->getTableRecordInt($table, 'glyf', 'checkSum'),
);
}
/** @throws \Com\Tecnick\Pdf\Font\Exception */
public function testBuildSubsetFontKeepsTableDirectoryOffsetsIntact(): void
{
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
public function __construct() {}
/** @throws \Com\Tecnick\Pdf\Font\Exception */
public function run(): void
{
$this->buildSubsetFont();
}
public function getSubFont(): string
{
return $this->subfont;
}
};
$this->setProp($subset, 'fdt', array_merge($this->getDefaultFdt(), [
'table' => [
// Offsets in fdt are relative to the 12-byte sfnt header.
'head' => [
'checkSum' => 0,
'data' => str_repeat("\x00", 12),
'length' => 12,
'offset' => 12,
],
],
]));
$subset->run();
$subfont = $subset->getSubFont();
// With a single table, the table directory offset must point to byte 28
// (12-byte sfnt header + 16-byte table record).
$offset = \unpack('N', \substr($subfont, 20, 4));
$this->assertIsArray($offset);
$this->assertSame(28, $offset[1]);
}
public function testAddProcessedTablesUsesNextAvailableLocaIndexWhenImmediateIsMissing(): void
{
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
public function __construct() {}
public function run(): void
{
$this->addProcessedTables();
}
/** @return array<string, mixed> */
public function getTable(): array
{
return $this->fdt['table'];
}
};
// 8 bytes for glyph 0 followed by 8 bytes for glyph 1.
$font = str_repeat("\xAB", 16);
$this->setProp($subset, 'font', $font);
$this->setProp($subset, 'offset', 0);
$this->setProp($subset, 'subglyphs', [0 => true]);
// Simulate parser output where index 1 was removed as duplicate-empty marker.
// Glyph 0 must still use index 2 as the closing boundary.
$this->setProp($subset, 'fdt', array_merge($this->getDefaultFdt(), [
'tot_num_glyphs' => 3,
'short_offset' => false,
'indexToLoc' => [0 => 0, 2 => 8, 3 => 16],
'table' => [
'glyf' => ['offset' => 0, 'length' => 16, 'checkSum' => 0, 'data' => ''],
'loca' => ['offset' => 0, 'length' => 0, 'checkSum' => 0, 'data' => ''],
],
]));
$subset->run();
$table = $subset->getTable();
// Glyph 0 must not be dropped just because index 1 is missing.
$this->assertNotEmpty($this->getTableRecordString($table, 'glyf', 'data'));
}
public function testAddCompositeGlyphsPreservesNumericGlyphIndexes(): void
{
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
private bool $added = false;
public function __construct() {}
public function run(): void
{
$this->addCompositeGlyphs();
}
/** @return array<int, bool> */
public function getSubglyphs(): array
{
return $this->subglyphs;
}
/**
* @param array<int, bool> $new_sga
* @param int $key
*
* @return array<int, bool>
*/
protected function findCompositeGlyphs(array $new_sga, int $key): array
{
if (!$this->added) {
$this->added = true;
$new_sga[3283] = true;
}
return $new_sga;
}
};
$this->setProp($subset, 'subglyphs', [853 => true]);
$subset->run();
$subglyphs = $subset->getSubglyphs();
$this->assertArrayHasKey(853, $subglyphs);
$this->assertArrayHasKey(3283, $subglyphs);
}
public function testAddCompositeGlyphsSkipsDisabledDerivedGlyphs(): void
{
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
private bool $first = true;
public function __construct() {}
public function run(): void
{
$this->addCompositeGlyphs();
}
/** @return array<int, bool> */
public function getSubglyphs(): array
{
return $this->subglyphs;
}
/**
* @param array<int, bool> $new_sga
* @param int $key
*
* @return array<int, bool>
*/
protected function findCompositeGlyphs(array $new_sga, int $key): array
{
if ($this->first) {
$this->first = false;
$new_sga[400] = false;
$new_sga[401] = true;
}
return $new_sga;
}
};
$this->setProp($subset, 'subglyphs', [100 => true]);
$subset->run();
$subglyphs = $subset->getSubglyphs();
$this->assertArrayHasKey(100, $subglyphs);
$this->assertArrayHasKey(401, $subglyphs);
$this->assertArrayNotHasKey(400, $subglyphs);
}
public function testFindCompositeGlyphsParsesScaleAndTwoByTwoComponents(): void
{
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
public function __construct() {}
/**
* @param array<int, bool> $newSga
*
* @return array<int, bool>
*/
public function runFindCompositeGlyphs(array $newSga, int $key): array
{
return $this->findCompositeGlyphs($newSga, $key);
}
};
// Glyph header: numberOfContours = -1 (composite), 8 bytes bbox.
// Component 1: MORE_COMPONENTS + WE_HAVE_AN_X_AND_Y_SCALE, glyph 5.
// Component 2: WE_HAVE_A_TWO_BY_TWO, glyph 6.
$font =
"\xFF\xFF\x00\x00\x00\x00\x00\x00\x00\x00"
. "\x00\x60\x00\x05\x00\x00\x00\x00\x00\x00"
. "\x00\x80\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
$this->setProp($subset, 'font', $font);
$this->setProp($subset, 'fbyte', new \Com\Tecnick\File\Byte($font));
$this->setProp($subset, 'subglyphs', []);
$this->setProp($subset, 'fdt', \array_replace_recursive($this->getDefaultFdt(), [
'indexToLoc' => [0 => 0],
'table' => [
'glyf' => ['offset' => 0, 'length' => \strlen($font), 'checkSum' => 0, 'data' => ''],
],
]));
$newSga = $subset->runFindCompositeGlyphs([], 0);
$this->assertArrayHasKey(5, $newSga);
$this->assertArrayHasKey(6, $newSga);
}
public function testAddProcessedTablesCreatesShortLocaAndPadsTables(): void
{
$subset = new class() extends \Com\Tecnick\Pdf\Font\Subset {
public function __construct() {}
public function run(): void
{
$this->addProcessedTables();
}
/** @return array<string, mixed> */
public function getTable(): array
{
return $this->fdt['table'];
}
};
// One glyph with 3 bytes to force padding in both loca and glyf tables.
$font = "\xAA\xBB\xCC";
$this->setProp($subset, 'font', $font);
$this->setProp($subset, 'offset', 0);
$this->setProp($subset, 'subglyphs', [0 => true]);
$this->setProp($subset, 'fdt', \array_replace_recursive($this->getDefaultFdt(), [
'tot_num_glyphs' => 1,
'short_offset' => true,
'indexToLoc' => [0 => 0, 1 => 3],
'table' => [
'glyf' => ['offset' => 0, 'length' => 3, 'checkSum' => 0, 'data' => ''],
],
]));
$subset->run();
$table = $subset->getTable();
$this->assertArrayHasKey('loca', $table);
$this->assertSame(4, $this->getTableRecordInt($table, 'loca', 'length'));
$this->assertSame(4, $this->getTableRecordInt($table, 'glyf', 'length'));
$this->assertSame(0, \strlen($this->getTableRecordString($table, 'loca', 'data')) % 4);
$this->assertSame(0, \strlen($this->getTableRecordString($table, 'glyf', 'data')) % 4);
}
}