chore: adiciona .gitignore e commit.command
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user