Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
28 / 28 |
|
100.00% |
3 / 3 |
CRAP | |
100.00% |
1 / 1 |
| TextLayout | |
100.00% |
28 / 28 |
|
100.00% |
3 / 3 |
13 | |
100.00% |
1 / 1 |
| measure | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| wrap | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
10 | |||
| winAnsi | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Pdf\Writer; |
| 6 | |
| 7 | use Phpdftk\Encoding\WinAnsiTable; |
| 8 | use Phpdftk\FontMetrics\AfmData; |
| 9 | |
| 10 | /** |
| 11 | * Shared text-layout primitives — greedy word wrapping and width |
| 12 | * measurement against AFM metrics. Used by `Pdf` and by the shared |
| 13 | * primitive renderers (`TableRenderer`, `ListRenderer`, etc.) so flow |
| 14 | * layout and explicit placement agree on geometry. |
| 15 | * |
| 16 | * Width measurement assumes WinAnsi-encoded byte input: the caller is |
| 17 | * responsible for encoding UTF-8 to the font's byte encoding before |
| 18 | * calling `measure()` / `wrap()`. This matches what `ContentStream::showText()` |
| 19 | * expects when the caller passes a resource-name string (no auto-encoding). |
| 20 | */ |
| 21 | final class TextLayout |
| 22 | { |
| 23 | /** @var array<int, string> WinAnsi byte → glyph name, cached after first call. */ |
| 24 | private static ?array $winAnsi = null; |
| 25 | |
| 26 | /** |
| 27 | * Measure a single line of byte-encoded text in points. |
| 28 | */ |
| 29 | public static function measure(string $text, AfmData $metrics, float $size): float |
| 30 | { |
| 31 | $winAnsi = self::winAnsi(); |
| 32 | $units = 0; |
| 33 | $len = strlen($text); |
| 34 | for ($i = 0; $i < $len; $i++) { |
| 35 | $byte = ord($text[$i]); |
| 36 | $glyph = $winAnsi[$byte] ?? '.notdef'; |
| 37 | $units += $metrics->getWidth($glyph); |
| 38 | } |
| 39 | return ($units / 1000.0) * $size; |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Greedy word-wrap: split on whitespace, pack words onto lines |
| 44 | * until the next word would overflow the column width. A single |
| 45 | * word wider than the column is emitted on its own line without |
| 46 | * mid-word breaking. Explicit newlines produce paragraph breaks |
| 47 | * within the returned line list. |
| 48 | * |
| 49 | * @return list<string> |
| 50 | */ |
| 51 | public static function wrap(string $text, AfmData $metrics, float $size, float $columnWidth): array |
| 52 | { |
| 53 | $out = []; |
| 54 | $text = str_replace(["\r\n", "\r"], "\n", $text); |
| 55 | $paragraphs = explode("\n", $text); |
| 56 | |
| 57 | foreach ($paragraphs as $paragraph) { |
| 58 | $words = preg_split('/\s+/', trim($paragraph)) ?: []; |
| 59 | if ($words === [''] || $words === []) { |
| 60 | $out[] = ''; |
| 61 | continue; |
| 62 | } |
| 63 | $line = ''; |
| 64 | foreach ($words as $word) { |
| 65 | $candidate = $line === '' ? $word : ($line . ' ' . $word); |
| 66 | if (self::measure($candidate, $metrics, $size) <= $columnWidth) { |
| 67 | $line = $candidate; |
| 68 | } else { |
| 69 | if ($line !== '') { |
| 70 | $out[] = $line; |
| 71 | } |
| 72 | $line = $word; |
| 73 | } |
| 74 | } |
| 75 | if ($line !== '') { |
| 76 | $out[] = $line; |
| 77 | } |
| 78 | } |
| 79 | return $out; |
| 80 | } |
| 81 | |
| 82 | /** @return array<int, string> */ |
| 83 | private static function winAnsi(): array |
| 84 | { |
| 85 | return self::$winAnsi ??= WinAnsiTable::getTable(); |
| 86 | } |
| 87 | } |