257 lines
10 KiB
Python
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
|