diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd3a64..3491232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Public API and behaviour changes only. Versions follow Semantic Versioning. +## Unreleased + +### Added + +- `WrapsText`, the greedy line breaking of `TextMeasurerInterface::wrap()` as a trait + ## 0.7.0 - 2026-08-07 ### Added diff --git a/src/Text/CharWidthTextMeasurer.php b/src/Text/CharWidthTextMeasurer.php index 7770356..d31786a 100644 --- a/src/Text/CharWidthTextMeasurer.php +++ b/src/Text/CharWidthTextMeasurer.php @@ -6,6 +6,8 @@ final class CharWidthTextMeasurer implements TextMeasurerInterface { + use WrapsText; + private const int EM_UNITS = 1000; private const int FALLBACK_WIDTH = 556; @@ -58,83 +60,4 @@ public function measureLine(string $text, float $fontSize, FontWeight $weight = ascent: $this->ascentFactor * $fontSize, ); } - - public function wrap(string $text, float $maxWidth, float $fontSize, float $lineHeight = 1.2, bool $breakWords = false, FontWeight $weight = FontWeight::Normal): TextBlockMetrics - { - if ($maxWidth <= 0.0 || '' === trim($text)) { - return new TextBlockMetrics([], 0.0, 0.0, 0.0, 0.0); - } - - $lines = []; - $current = ''; - $words = preg_split('/(\s+)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE); - assert(false !== $words); - - foreach ($words as $word) { - $candidate = $current.$word; - if ($this->measureLine($candidate, $fontSize, $weight)->width <= $maxWidth || '' === $current) { - if ($this->measureLine($candidate, $fontSize, $weight)->width <= $maxWidth || !$breakWords) { - $current = $candidate; - continue; - } - } - - if ('' !== trim($current)) { - $lines[] = trim($current); - } - $current = preg_match('/^\s+$/', $word) ? '' : $word; - } - - if ('' !== trim($current)) { - $lines[] = trim($current); - } - - if ($breakWords) { - $lines = $this->breakLongLines($lines, $maxWidth, $fontSize, $weight); - } - - $maxLineWidth = 0.0; - foreach ($lines as $line) { - $maxLineWidth = max($maxLineWidth, $this->measureLine($line, $fontSize, $weight)->width); - } - - $lineBoxHeight = $fontSize * $lineHeight; - $firstBaseline = $this->ascentFactor * $fontSize; - $lastBaseline = [] === $lines ? 0.0 : $firstBaseline + (\count($lines) - 1) * $lineBoxHeight; - - return new TextBlockMetrics( - lines: $lines, - width: $maxLineWidth, - height: \count($lines) * $lineBoxHeight, - firstBaseline: $firstBaseline, - lastBaseline: $lastBaseline, - ); - } - - /** - * @param list $lines - * - * @return list - */ - private function breakLongLines(array $lines, float $maxWidth, float $fontSize, FontWeight $weight): array - { - $result = []; - foreach ($lines as $line) { - $current = ''; - foreach (mb_str_split($line) as $char) { - $candidate = $current.$char; - if ('' !== $current && $this->measureLine($candidate, $fontSize, $weight)->width > $maxWidth) { - $result[] = $current; - $current = $char; - continue; - } - $current = $candidate; - } - if ('' !== $current) { - $result[] = $current; - } - } - - return $result; - } } diff --git a/src/Text/WrapsText.php b/src/Text/WrapsText.php new file mode 100644 index 0000000..5740edc --- /dev/null +++ b/src/Text/WrapsText.php @@ -0,0 +1,102 @@ +measureLine($candidate, $fontSize, $weight)->width <= $maxWidth || '' === $current) { + if ($this->measureLine($candidate, $fontSize, $weight)->width <= $maxWidth || !$breakWords) { + $current = $candidate; + continue; + } + } + + if ('' !== trim($current)) { + $lines[] = trim($current); + } + $current = preg_match('/^\s+$/', $word) ? '' : $word; + } + + if ('' !== trim($current)) { + $lines[] = trim($current); + } + + if ($breakWords) { + $lines = $this->splitLinesToWidth($lines, $maxWidth, $fontSize, $weight); + } + + $maxLineWidth = 0.0; + foreach ($lines as $line) { + $maxLineWidth = max($maxLineWidth, $this->measureLine($line, $fontSize, $weight)->width); + } + + $lineBoxHeight = $fontSize * $lineHeight; + + // Measured rather than read from a factor, because the trait only sees the + // interface. 'M' rather than an empty string: a measurer backed by a real + // font has no glyph to report an ascent for when there is no text. + $firstBaseline = $this->measureLine('M', $fontSize, $weight)->ascent; + $lastBaseline = [] === $lines ? 0.0 : $firstBaseline + (\count($lines) - 1) * $lineBoxHeight; + + return new TextBlockMetrics( + lines: $lines, + width: $maxLineWidth, + height: \count($lines) * $lineBoxHeight, + firstBaseline: $firstBaseline, + lastBaseline: $lastBaseline, + ); + } + + /** + * Cuts lines that still exceed the width after word breaking, character by character. + * + * @param list $lines + * + * @return list + */ + private function splitLinesToWidth(array $lines, float $maxWidth, float $fontSize, FontWeight $weight): array + { + $result = []; + foreach ($lines as $line) { + $current = ''; + foreach (mb_str_split($line) as $char) { + $candidate = $current.$char; + if ('' !== $current && $this->measureLine($candidate, $fontSize, $weight)->width > $maxWidth) { + $result[] = $current; + $current = $char; + continue; + } + $current = $candidate; + } + if ('' !== $current) { + $result[] = $current; + } + } + + return $result; + } +} diff --git a/tests/Text/CharWidthTextMeasurerTest.php b/tests/Text/CharWidthTextMeasurerTest.php index e3bfa27..7b8767b 100644 --- a/tests/Text/CharWidthTextMeasurerTest.php +++ b/tests/Text/CharWidthTextMeasurerTest.php @@ -6,10 +6,13 @@ use Atelier\Layout\Text\CharWidthTextMeasurer; use Atelier\Layout\Text\FontWeight; +use Atelier\Layout\Text\WrapsText; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesTrait; use PHPUnit\Framework\TestCase; #[CoversClass(CharWidthTextMeasurer::class)] +#[UsesTrait(WrapsText::class)] #[CoversClass(FontWeight::class)] final class CharWidthTextMeasurerTest extends TestCase { diff --git a/tests/Text/WrapsTextTest.php b/tests/Text/WrapsTextTest.php new file mode 100644 index 0000000..0f7bf0a --- /dev/null +++ b/tests/Text/WrapsTextTest.php @@ -0,0 +1,112 @@ +measurer()->wrap('one two three four', 30.0, 10.0); + + $this->assertSame(['one', 'two', 'three', 'four'], $block->lines); + } + + public function testReportsTheWidestLine(): void + { + $block = $this->measurer()->wrap('one two three four', 30.0, 10.0); + + // 'three', the longest of the four, at five units a character. + $this->assertEqualsWithDelta(25.0, $block->width, 0.001); + } + + public function testStacksBaselinesByLineHeight(): void + { + $block = $this->measurer()->wrap('one two three four', 30.0, 10.0, 1.5); + + $this->assertEqualsWithDelta(8.0, $block->firstBaseline, 0.001); + $this->assertEqualsWithDelta(8.0 + 3 * 15.0, $block->lastBaseline, 0.001); + $this->assertEqualsWithDelta(4 * 15.0, $block->height, 0.001); + } + + public function testTakesTheBaselineFromTheMeasurerRatherThanItsOwnState(): void + { + // A measurer with a different ascent must be followed, since the trait + // only ever sees the interface. + $block = $this->measurer(ascent: 0.5)->wrap('one', 30.0, 10.0); + + $this->assertEqualsWithDelta(5.0, $block->firstBaseline, 0.001); + } + + public function testReturnsAnEmptyBlockForANonPositiveWidth(): void + { + $block = $this->measurer()->wrap('one two', 0.0, 10.0); + + $this->assertSame([], $block->lines); + $this->assertEqualsWithDelta(0.0, $block->height, 0.001); + } + + public function testReturnsAnEmptyBlockForBlankText(): void + { + $block = $this->measurer()->wrap(" \n ", 100.0, 10.0); + + $this->assertSame([], $block->lines); + } + + public function testKeepsAWordTooLongToFitWhenBreakingIsOff(): void + { + $block = $this->measurer()->wrap('antidisestablishmentarianism', 30.0, 10.0); + + $this->assertSame(['antidisestablishmentarianism'], $block->lines); + } + + public function testBreaksAWordTooLongToFitWhenBreakingIsOn(): void + { + $block = $this->measurer()->wrap('antidisestablishmentarianism', 30.0, 10.0, breakWords: true); + + $this->assertGreaterThan(1, \count($block->lines)); + $this->assertSame('antidisestablishmentarianism', implode('', $block->lines)); + } + + public function testMeasuresWithTheRequestedWeight(): void + { + // At forty units 'one two' fits when normal and does not when bold. + $normal = $this->measurer()->wrap('one two three four', 40.0, 10.0); + $bold = $this->measurer()->wrap('one two three four', 40.0, 10.0, weight: FontWeight::Bold); + + $this->assertSame(['one two', 'three', 'four'], $normal->lines); + $this->assertSame(['one', 'two', 'three', 'four'], $bold->lines); + } + + private function measurer(float $ascent = 0.8): TextMeasurerInterface + { + return new class($ascent) implements TextMeasurerInterface { + use WrapsText; + + public function __construct(private readonly float $ascent) + { + } + + public function measureLine(string $text, float $fontSize, FontWeight $weight = FontWeight::Normal): TextMetrics + { + $factor = FontWeight::Bold === $weight ? 0.7 : 0.5; + + return new TextMetrics( + width: mb_strlen($text) * $factor * $fontSize, + height: $fontSize, + ascent: $this->ascent * $fontSize, + ); + } + }; + } +}