Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
5818060
test(pdf): fix exporter assertion and normalize CI history
vitormattos May 29, 2026
90213b2
test(layout): harden text line breaker mutation coverage
vitormattos May 29, 2026
b9420b4
fix(layout): align text line breaker return docs
vitormattos May 29, 2026
a63243e
fix: harden mutation hotspots
vitormattos May 29, 2026
05020a9
fix: address phpmd embedder warnings
vitormattos May 29, 2026
61aeadc
refactor(pdf): add ParsedPngImage
vitormattos May 29, 2026
7d1bf3b
refactor(pdf): add WarningToExceptionConverterInterface
vitormattos May 29, 2026
a60b52f
refactor(pdf): add FilesystemImageSourceReaderInterface
vitormattos May 29, 2026
9bf5d9c
refactor(pdf): add ImageMetadataInspectorInterface
vitormattos May 29, 2026
fbe5151
refactor(pdf): add JpegPdfImageFactoryInterface
vitormattos May 29, 2026
138dd60
refactor(pdf): add PngParserInterface
vitormattos May 29, 2026
c0a4616
refactor(pdf): add PngPdfImageFactoryInterface
vitormattos May 29, 2026
5fbb585
refactor(pdf): add PngScanlineUnfiltererInterface
vitormattos May 29, 2026
3f26bd6
refactor(pdf): add PhpWarningToExceptionConverter
vitormattos May 29, 2026
d9480c8
refactor(pdf): add FilesystemImageSourceReader
vitormattos May 29, 2026
3628510
refactor(pdf): add ImageMetadataInspector
vitormattos May 29, 2026
e456b23
refactor(pdf): add JpegPdfImageFactory
vitormattos May 29, 2026
028ac99
refactor(pdf): add PngColorTypeDescription
vitormattos May 29, 2026
9a0cc99
refactor(pdf): add PngParser
vitormattos May 29, 2026
3236a05
refactor(pdf): add PngScanlineUnfilterer
vitormattos May 29, 2026
3535138
refactor(pdf): add PngPdfImageFactory
vitormattos May 29, 2026
a5bd7e2
refactor(pdf): update FilesystemPdfImageEmbedder
vitormattos May 29, 2026
f06fdc4
refactor(pdf): update SinglePagePdfExporter
vitormattos May 29, 2026
b91d793
fix(pdf): update StandardFontMetrics
vitormattos May 29, 2026
9ca5e75
fix(layout): update TextLineBreaker
vitormattos May 29, 2026
9c36b68
test(support): add UsesTemporaryFiles
vitormattos May 29, 2026
c391d66
test(support): update PngFixtureFactory
vitormattos May 29, 2026
5bfe53d
test(pdf): add FilesystemImageSourceReaderTest
vitormattos May 29, 2026
9c53398
test(pdf): add ImageMetadataInspectorTest
vitormattos May 29, 2026
6b9733c
test(pdf): add JpegPdfImageFactoryTest
vitormattos May 29, 2026
eef32a0
test(pdf): add PhpWarningToExceptionConverterTest
vitormattos May 29, 2026
5ff240f
test(pdf): add PngColorTypeDescriptionTest
vitormattos May 29, 2026
62c5f49
test(pdf): add PngParserTest
vitormattos May 29, 2026
cc8815b
test(pdf): add PngPdfImageFactoryTest
vitormattos May 29, 2026
48f84a3
test(pdf): add PngScanlineUnfiltererTest
vitormattos May 29, 2026
d81a686
test(pdf): update FilesystemPdfImageEmbedderTest
vitormattos May 29, 2026
b19303e
test(pdf): update SinglePagePdfExporterTest
vitormattos May 29, 2026
333dcb1
test(pdf): update StandardFontMetricsTest
vitormattos May 29, 2026
85d6eaf
test(layout): update TextLineBreakerTest
vitormattos May 29, 2026
5d4ec6e
test(layout): update StructuredLayoutRendererTest
vitormattos May 29, 2026
9dc3c7d
test(pdf): update TemplateDocumentBuilderTest
vitormattos May 29, 2026
b2a5983
test(core): update XObjectTemplateCompilerTest
vitormattos May 29, 2026
4fbd3d1
refactor(pdf): group image formats by namespace
vitormattos May 29, 2026
96a5a38
test(pdf): mirror image format namespaces
vitormattos May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 40 additions & 42 deletions src/Layout/TextLineBreaker.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public function wrap(
$lines[] = $currentLine;
}

return $lines === [] ? [$text] : $lines;
return $lines;
}

private function fitsOnCurrentLine(
Expand Down Expand Up @@ -98,14 +98,9 @@ private function appendBrokenWord(
string &$currentLine,
): void {
$segments = $this->breakWord($word, $maxWidth, $fontAlias, $fontSize, $hyphens);
$lastIndex = count($segments) - 1;

foreach ($segments as $index => $segment) {
if ($index === $lastIndex) {
$currentLine = $segment;
continue;
}
$currentLine = array_pop($segments) ?? '';

foreach ($segments as $segment) {
$lines[] = $segment;
}
}
Expand All @@ -120,7 +115,7 @@ private function breakWord(
float $fontSize,
string $hyphens,
): array {
if ($hyphens === 'none') {
if ($hyphens === 'none' || ($hyphens !== 'manual' && $hyphens !== 'auto')) {
return [$word];
}

Expand All @@ -129,10 +124,6 @@ private function breakWord(
return $manualSegments;
}

if ($hyphens !== 'auto') {
return [$word];
}

return $this->breakWordAutomatically($word, $maxWidth, $fontAlias, $fontSize);
}

Expand All @@ -146,15 +137,17 @@ private function resolveManualBreaks(
float $fontSize,
string $hyphens,
): ?array {
if ($hyphens !== 'manual' || !str_contains($word, "\u{00AD}")) {
if ($hyphens !== 'manual') {
return null;
}

if (!str_contains($word, "\u{00AD}")) {
return null;
}

$manualBreaks = explode("\u{00AD}", $word);

return count($manualBreaks) > 1
? $this->packManualSegments($manualBreaks, $maxWidth, $fontAlias, $fontSize)
: null;
return $this->packManualSegments($manualBreaks, $maxWidth, $fontAlias, $fontSize);
}

/**
Expand All @@ -166,64 +159,67 @@ private function breakWordAutomatically(
string $fontAlias,
float $fontSize,
): array {
$characters = $this->splitCharacters($word);
if ($characters === []) {
return [$word];
}

$segments = [];
$remaining = $this->splitCharacters($word);
$hyphenWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, '-');
$offset = 0;
$characterCount = count($characters);

while ($remaining !== []) {
['segment' => $segment, 'consumed' => $consumed] = $this->resolveAutoSegment(
$remaining,
while (isset($characters[$offset])) {
$segment = $this->resolveAutoSegment(
array_slice($characters, $offset),
$maxWidth,
$fontAlias,
$fontSize,
$hyphenWidth,
);

if ($consumed <= 0) {
break;
}

$remaining = array_slice($remaining, $consumed);
$segments[] = $remaining === [] ? $segment : ($segment . '-');
$segmentCharacters = $this->splitCharacters($segment);
$fallbackCount = count($this->splitCharacters($characters[$offset]));
$consumedCount = max(count($segmentCharacters) + $fallbackCount - 1, $fallbackCount);
$offset = min($offset + $consumedCount, $characterCount);
$segments[] = !isset($characters[$offset]) ? $segment : ($segment . '-');
}

return $segments === [] ? [$word] : $segments;
return $segments;
}

/**
* @param list<string> $remaining
* @return array{segment: string, consumed: int}
* @return string
*/
private function resolveAutoSegment(
array $remaining,
float $maxWidth,
string $fontAlias,
float $fontSize,
): array {
float $hyphenWidth,
): string {
$segment = '';
$consumed = 0;
$remainingCount = count($remaining);

foreach ($remaining as $index => $character) {
$candidate = $segment . $character;
$isLastCharacter = $index === ($remainingCount - 1);
$candidateWidth = $this->fontMetrics->measureString(
$fontAlias,
$fontSize,
$candidate . ($isLastCharacter ? '' : '-'),
);
$hasMoreCharacters = ($index + 1) < $remainingCount;
$candidateWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $candidate)
+ ($hasMoreCharacters ? $hyphenWidth : 0.0);

if ($candidateWidth > $maxWidth && $segment !== '') {
break;
}

if ($candidateWidth > $maxWidth) {
return ['segment' => $character, 'consumed' => 1];
return $character;
}

$segment = $candidate;
$consumed = $index + 1;
}

return ['segment' => $segment, 'consumed' => $consumed];
return $segment;
}

/**
Expand All @@ -239,13 +235,15 @@ private function packManualSegments(
$packed = [];
$current = '';
$lastIndex = count($segments) - 1;
$hyphenWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, '-');

foreach ($segments as $index => $segment) {
$candidate = $current . $segment;
$candidateWithHyphen = $candidate . ($index === $lastIndex ? '' : '-');
$candidateWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $candidate)
+ ($index === $lastIndex ? 0.0 : $hyphenWidth);
if (
$current !== ''
&& $this->fontMetrics->measureString($fontAlias, $fontSize, $candidateWithHyphen) > $maxWidth
&& $candidateWidth > $maxWidth
) {
$packed[] = $current . '-';
$current = $segment;
Expand Down
27 changes: 21 additions & 6 deletions src/Layout/TextOverflowTruncator.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,13 @@ public function forceEllipsis(
float $fontSize,
): string {
$ellipsis = '...';
$ellipsisWidth = $this->fontMetrics->measureString($fontAlias, $fontSize, $ellipsis);
$characters = $this->splitCharacters($text);

while ($characters !== []) {
$candidate = implode('', $characters) . $ellipsis;
if ($this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) <= $maxWidth) {
return rtrim(implode('', $characters)) . $ellipsis;
foreach ($this->buildCandidates($characters) as $candidate) {
if (($this->fontMetrics->measureString($fontAlias, $fontSize, $candidate) + $ellipsisWidth) <= $maxWidth) {
return rtrim($candidate) . $ellipsis;
}

array_pop($characters);
}

return $ellipsis;
Expand All @@ -59,4 +57,21 @@ private function splitCharacters(string $text): array

return $characters === false ? [] : $characters;
}

/**
* @param list<string> $characters
* @return list<string>
*/
private function buildCandidates(array $characters): array
{
$candidates = [];
$candidate = '';

foreach ($characters as $character) {
$candidate .= $character;
$candidates[] = $candidate;
}

return array_reverse($candidates);
}
}
47 changes: 47 additions & 0 deletions src/Pdf/FilesystemImageSourceReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreSign
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Pdf;

use InvalidArgumentException;

/** @internal */
final readonly class FilesystemImageSourceReader implements FilesystemImageSourceReaderInterface
{
private WarningToExceptionConverterInterface $warningConverter;

public function __construct(?WarningToExceptionConverterInterface $warningConverter = null)
{
$this->warningConverter = $warningConverter ?? new PhpWarningToExceptionConverter();
}

public function read(string $source): string
{
$this->assertReadableSource($source);

$contents = $this->warningConverter->run(
static fn (): string|false => file_get_contents($source),
sprintf('Failed to read image source "%s".', $source),
);
if (!is_string($contents)) {
throw new InvalidArgumentException(sprintf('Failed to read image source "%s".', $source));
}

return $contents;
}

private function assertReadableSource(string $source): void
{
if (!is_file($source)) {
throw new InvalidArgumentException(sprintf('Image source "%s" must be an existing file.', $source));
}

if (!is_readable($source)) {
throw new InvalidArgumentException(sprintf('Image source "%s" must be readable.', $source));
}
}
}
14 changes: 14 additions & 0 deletions src/Pdf/FilesystemImageSourceReaderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreSign
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreSign\XObjectTemplate\Pdf;

/** @internal */
interface FilesystemImageSourceReaderInterface
{
public function read(string $source): string;
}
Loading
Loading