Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 2 additions & 79 deletions src/Text/CharWidthTextMeasurer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

final class CharWidthTextMeasurer implements TextMeasurerInterface
{
use WrapsText;

private const int EM_UNITS = 1000;
private const int FALLBACK_WIDTH = 556;

Expand Down Expand Up @@ -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<string> $lines
*
* @return list<string>
*/
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;
}
}
102 changes: 102 additions & 0 deletions src/Text/WrapsText.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Atelier\Layout\Text;

/**
* Greedy line breaking, written once against TextMeasurerInterface.
*
* Wrapping needs to know how wide a line is, and nothing else about the font. A
* measurer that implements the interface can take this trait and be left with the
* one method only it can answer, measureLine().
*
* @phpstan-require-implements TextMeasurerInterface
*/
trait WrapsText
{
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->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<string> $lines
*
* @return list<string>
*/
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;
}
}
3 changes: 3 additions & 0 deletions tests/Text/CharWidthTextMeasurerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
112 changes: 112 additions & 0 deletions tests/Text/WrapsTextTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

declare(strict_types=1);

namespace Atelier\Layout\Tests\Text;

use Atelier\Layout\Text\FontWeight;
use Atelier\Layout\Text\TextMeasurerInterface;
use Atelier\Layout\Text\TextMetrics;
use Atelier\Layout\Text\WrapsText;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\TestCase;

#[CoversTrait(WrapsText::class)]
final class WrapsTextTest extends TestCase
{
public function testWrapsOnWordBoundaries(): void
{
// Five units a character, so six fit in thirty: one word a line.
$block = $this->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,
);
}
};
}
}
Loading