Files
gart/code/tests/test_text_layout.py

257 lines
10 KiB
Python

"""Tests for kinetic-typography block layout (fcpxml/text_layout.py).
The reference values come from "Exemplo Letra.fcpxmld", a Final Cut export the
user built by hand: the sentence "Toda a minha vida, assim," with each word
positioned in the Inspector. Those hand-placed coordinates are the only ground
truth for the template's coordinate space, so the calibration tests below pin
the layout to them — if a change drifts from the user's own framing, they fail.
"""
import pytest
from fcpxml.text_layout import _SAFETY_MARGIN as SAFETY_MARGIN
from fcpxml.text_layout import (
LayoutBox,
PlacedWord,
layout_sentence,
measure_text,
metrics_for,
rhythm_font_size,
rhythm_italic,
)
# Hand-placed positions from the calibration export, in canvas points.
REFERENCE = {
"Toda": (-251.828, -109.0),
"a": (2.236, -101.135),
"minha": (267.319, -95.0),
"vida,": (71.0395, -233.65),
"assim,": (210.88, -100.0),
}
REFERENCE_SIZES = (170, 128, 151, 128, 128)
class Style:
"""Stands in for WordStyle, carrying the reference export's values."""
bold = False
kerning = 2.048
font_size = 128
size_pattern = REFERENCE_SIZES
italic_pattern = (False, False, False, False, True)
@pytest.fixture
def sentence():
return [{"word": w} for w in REFERENCE]
@pytest.fixture
def box():
return LayoutBox.for_frame(2160, 3840)
class TestMeasureText:
def test_empty_string_has_no_width(self):
assert measure_text("", 128) == 0.0
def test_width_scales_with_font_size(self):
assert measure_text("minha", 256) > measure_text("minha", 128)
def test_narrow_letters_measure_less_than_wide_ones(self):
assert measure_text("lli", 128) < measure_text("mmm", 128)
def test_accented_letters_measure_like_their_base(self):
assert measure_text("ção", 128) == pytest.approx(
measure_text("cao", 128), rel=1e-9
)
def test_kerning_widens_by_one_step_per_character(self):
plain = measure_text("abc", 100, kerning=0)
kerned = measure_text("abc", 100, kerning=10)
# 3 characters x 10 units, carried through the safety margin.
assert kerned - plain == pytest.approx(30 * SAFETY_MARGIN, rel=1e-6)
def test_uses_the_real_font_metrics_when_available(self):
"""Generic Helvetica advances differ from the actual macOS variants by
up to 21% of the em. Measuring "Helvetica Neue" against the generic
table once made words run narrow, which is what puts two of them on
top of each other."""
generic = measure_text("minha", 151)
neue = measure_text("minha", 151, font="Helvetica Neue")
assert generic != neue
def test_never_underestimates_real_advances(self):
"""The whole point of the embedded metrics: a width that runs low by
even a few points lets neighbouring words touch. Measured width must
always be the true advance plus the safety margin, never less."""
cases = [
("Toda", "Helvetica", "Light", 170, 2.72, 398.3),
("minha", "Helvetica Neue", None, 151, 2.416, 423.4),
("assim,", "Helvetica Neue", "Light Italic", 128, 2.048, 367.7),
("dificuldade", "Helvetica", "Light", 170, 2.72, 880.1),
("encontrar", "Helvetica Neue", None, 151, 2.416, 667.7),
]
for text, font, face, size, kerning, truth in cases:
got = measure_text(text, size, kerning=kerning, font=font, face=face)
assert got >= truth, f"{text!r} measured {got:.1f} < real {truth}"
assert got == pytest.approx(truth * SAFETY_MARGIN, rel=0.001)
def test_metrics_cover_every_font_the_rhythm_uses(self):
for font, face in (
("Helvetica", "Light"),
("Helvetica Neue", None),
("Helvetica Neue", "Light Italic"),
):
assert metrics_for(font, face) is not None, f"{font} {face}"
def test_unknown_font_falls_back_instead_of_raising(self):
assert measure_text("oi", 100, font="No Such Font") > 0
def test_matches_reference_word_widths(self):
"""Half-width sums must reproduce the center-to-center distances the
user's hand-placed words show: 254.06 (Toda→a), 265.08 (a→minha)."""
w_toda = measure_text("Toda", 170, kerning=2.72)
w_a = measure_text("a", 128, kerning=2.048)
w_minha = measure_text("minha", 151, kerning=2.416)
assert (w_toda + w_a) / 2 == pytest.approx(254.06, abs=25)
assert (w_a + w_minha) / 2 == pytest.approx(265.08, abs=25)
class TestRhythm:
def test_font_size_cycles_through_the_pattern(self):
sizes = [rhythm_font_size(i, Style()) for i in range(5)]
assert sizes == [float(s) for s in REFERENCE_SIZES]
def test_font_size_wraps_past_the_pattern(self):
assert rhythm_font_size(5, Style()) == rhythm_font_size(0, Style())
def test_italic_follows_its_own_pattern(self):
flags = [rhythm_italic(i, Style()) for i in range(5)]
assert flags == [False, False, False, False, True]
def test_no_pattern_falls_back_to_plain_style(self):
class Plain:
font_size = 90
assert rhythm_font_size(3, Plain()) == 90.0
assert rhythm_italic(3, Plain()) is False
class TestLayoutSentence:
def test_places_every_word(self, sentence, box):
out = layout_sentence(sentence, Style(), box)
assert [p.text for p in out.placed] == list(REFERENCE)
assert out.overflow == []
def test_no_two_words_overlap(self, sentence, box):
out = layout_sentence(sentence, Style(), box)
for i, a in enumerate(out.placed):
for b in out.placed[i + 1:]:
assert not a.overlaps(b), f"{a.text!r} overlaps {b.text!r}"
def test_blank_and_empty_words_are_skipped(self, box):
out = layout_sentence(
[{"word": "ok"}, {"word": " "}, {"word": ""}, {}], Style(), box
)
assert [p.text for p in out.placed] == ["ok"]
def test_no_words_yields_empty_layout(self, box):
out = layout_sentence([], Style(), box)
assert out.placed == [] and out.overflow == []
def test_lines_are_horizontally_centered(self, sentence, box):
out = layout_sentence(sentence, Style(), box)
for line in {p.line_index for p in out.placed}:
words = [p for p in out.placed if p.line_index == line]
span = min(w.left for w in words) + max(w.right for w in words)
assert span == pytest.approx(0.0, abs=1e-6)
def test_words_stay_inside_the_box(self, sentence, box):
out = layout_sentence(sentence, Style(), box)
for p in out.placed:
assert p.left >= -box.width / 2 - 1e-6
assert p.right <= box.width / 2 + 1e-6
def test_later_lines_sit_lower(self, sentence, box):
out = layout_sentence(sentence, Style(), box)
ys = {p.line_index: p.y for p in out.placed}
assert ys[1] < ys[0], "y grows upward, so line 2 must be below line 1"
class TestCalibrationAgainstRealExport:
"""Pins the layout to the user's own hand-placed framing."""
def test_line_spacing_matches_the_export(self, sentence, box):
out = layout_sentence(sentence, Style(), box)
ys = sorted({round(p.y, 4) for p in out.placed}, reverse=True)
assert ys[0] - ys[1] == pytest.approx(132.65, abs=6)
def test_first_line_height_matches_the_export(self, sentence, box):
out = layout_sentence(sentence, Style(), box)
first = next(p for p in out.placed if p.line_index == 0)
# The user's line 1 averages -101.1 across its hand-dragged words.
assert first.y == pytest.approx(-101.1, abs=8)
def test_word_x_tracks_the_export(self, sentence, box):
"""Within the slop of dragging by hand — the user placed these by eye,
so this guards against a scale error, not against small offsets."""
out = layout_sentence(sentence, Style(), box)
for p in out.placed:
if p.line_index != 0:
continue # the user misplaced "assim," onto line 1
assert p.x == pytest.approx(REFERENCE[p.text][0], abs=30)
class TestOverflowSplitsIntoSubSentences:
def test_long_sentence_overflows_instead_of_spilling(self, box):
words = [{"word": f"palavra{i}"} for i in range(40)]
out = layout_sentence(words, Style(), box)
assert out.overflow, "a 40-word sentence must not all fit"
assert out.placed
assert len(out.placed) + len(out.overflow) == 40
def test_overflow_preserves_the_original_word_dicts(self, box):
words = [{"word": f"palavra{i}", "start": float(i)} for i in range(40)]
out = layout_sentence(words, Style(), box)
assert all("start" in w for w in out.overflow)
def test_placed_words_stay_within_the_band(self, box):
words = [{"word": f"palavra{i}"} for i in range(40)]
out = layout_sentence(words, Style(), box)
top = max(p.top for p in out.placed)
bottom = min(p.bottom for p in out.placed)
assert top - bottom <= box.height + 1e-6
def test_a_word_wider_than_the_box_is_still_placed(self, box):
out = layout_sentence([{"word": "m" * 200}], Style(), box)
assert len(out.placed) == 1, "never silently drop a spoken word"
class TestPlacedWord:
def test_position_param_formats_as_fcp_writes_it(self):
w = PlacedWord("oi", 128, False, -251.828, -109.0, 400, 96, 0)
assert w.position_param() == "-251.828 -109"
def test_boxes_that_touch_do_not_count_as_overlapping(self):
a = PlacedWord("a", 128, False, 0, 0, 100, 100, 0)
b = PlacedWord("b", 128, False, 100, 0, 100, 100, 0)
assert not a.overlaps(b)
def test_boxes_that_intersect_do_overlap(self):
a = PlacedWord("a", 128, False, 0, 0, 100, 100, 0)
b = PlacedWord("b", 128, False, 50, 0, 100, 100, 0)
assert a.overlaps(b)
class TestLayoutBox:
def test_for_frame_halves_pixels_into_points(self):
box = LayoutBox.for_frame(2160, 3840, side_margin=0.0)
assert box.width == pytest.approx(1080.0)
def test_side_margin_narrows_the_usable_width(self):
wide = LayoutBox.for_frame(2160, 3840, side_margin=0.0)
narrow = LayoutBox.for_frame(2160, 3840, side_margin=0.10)
assert narrow.width < wide.width
def test_center_y_can_be_overridden(self):
assert LayoutBox.for_frame(2160, 3840, center_y=-400.0).center_y == -400.0