463 lines
20 KiB
Python
463 lines
20 KiB
Python
"""Equivalence tests for the ParagraphSizer hot-path optimizations.
|
|
|
|
`_LegacyFont` and `_legacy_count_lines` are verbatim copies of the
|
|
pre-optimization implementations. Every test here asserts that the current
|
|
implementation produces bit-identical results, so the caching and the
|
|
per-word (instead of per-character) width measurement cannot silently move
|
|
line breaks.
|
|
"""
|
|
|
|
import unittest
|
|
from functools import cached_property
|
|
from math import ceil
|
|
|
|
from docx.oxml import OxmlElement
|
|
from docx.shared import Cm, Pt
|
|
from docx.text.font import Font as DocxFont
|
|
from docx.text.run import Run
|
|
from freetype import Face
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from md2gost.renderable.find_font import find_font
|
|
from md2gost.renderable.listing import LISTING_OFFSET
|
|
from md2gost.renderable.paragraph_sizer import (
|
|
Font,
|
|
ParagraphSizer,
|
|
_get_font,
|
|
_merge_objects,
|
|
measure_paragraph,
|
|
)
|
|
|
|
from . import _create_test_document
|
|
|
|
|
|
class _LegacyFont:
|
|
"""Verbatim copy of Font before the metrics snapshot was introduced."""
|
|
|
|
def __init__(self, name: str, bold: bool, italic: bool, size_pt: int):
|
|
path = find_font(name, bold, italic)
|
|
self._freetypefont = ImageFont.truetype(path, size_pt)
|
|
self._draw = ImageDraw.Draw(Image.new("RGB", (1000, 1000)))
|
|
|
|
self._face = Face(path)
|
|
self._face.set_char_size(int(size_pt * 64))
|
|
|
|
def get_text_width(self, text: str):
|
|
if not self.is_mono:
|
|
bbox = self._draw.textbbox((0, 0), text, self._freetypefont)
|
|
return Pt(bbox[2] - bbox[0])
|
|
else:
|
|
return Pt(len(text) * self._face.glyph.advance.x / 64)
|
|
|
|
def get_line_height(self):
|
|
if "Times" in str(self._face.family_name) and self._freetypefont.size == 14:
|
|
return Pt(16.05)
|
|
if "Courier" in str(self._face.family_name) and self._freetypefont.size == 12:
|
|
return Pt(13.61)
|
|
else:
|
|
return Pt(self._face.size.height / 64)
|
|
|
|
@cached_property
|
|
def is_mono(self):
|
|
self._face.load_char("i")
|
|
i_width = self._face.glyph.advance.x
|
|
self._face.load_char("m")
|
|
return i_width == self._face.glyph.advance.x
|
|
|
|
|
|
def _legacy_count_lines(runs, max_width, docx_font: DocxFont, first_line_indent, is_mono: bool = False):
|
|
"""Verbatim copy of ParagraphSizer.count_lines before the optimization."""
|
|
lines = 1
|
|
line_width = first_line_indent
|
|
|
|
space_width = _LegacyFont(
|
|
docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt).get_text_width(" ")
|
|
if not is_mono:
|
|
space_width *= 0.81
|
|
|
|
word_part = ""
|
|
word_parts_widths = [0]
|
|
spaces = 0
|
|
for i, run in enumerate(runs):
|
|
if word_part:
|
|
word_part = ""
|
|
word_parts_widths.append(0)
|
|
|
|
run_docx_font = _merge_objects(docx_font, run.font)
|
|
font = _LegacyFont(run_docx_font.name, run_docx_font.bold,
|
|
run_docx_font.italic, run_docx_font.size.pt)
|
|
|
|
run_text = run.text
|
|
if run_text == "" and run._element.xpath("w:noBreakHyphen"):
|
|
run_text = "-"
|
|
if i == len(runs) - 1:
|
|
run_text += " "
|
|
|
|
for c in run_text:
|
|
if c == " ":
|
|
if any(word_parts_widths):
|
|
width = spaces * space_width + sum(word_parts_widths)
|
|
if width <= max_width - line_width:
|
|
line_width += width
|
|
elif width > max_width - first_line_indent:
|
|
if lines == 1 and line_width == first_line_indent and not spaces:
|
|
lines += ceil((width - (max_width - first_line_indent)) / max_width)
|
|
line_width = (width - (max_width - first_line_indent)) % max_width
|
|
else:
|
|
lines += ceil(width / max_width)
|
|
line_width = width % max_width
|
|
else:
|
|
lines += 1
|
|
line_width = sum(word_parts_widths)
|
|
|
|
word_part = ""
|
|
word_parts_widths = [0]
|
|
spaces = 1
|
|
else:
|
|
spaces += 1
|
|
else:
|
|
word_part += c
|
|
word_parts_widths[-1] = font.get_text_width(word_part)
|
|
|
|
return int(lines)
|
|
|
|
|
|
_BODY = (
|
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam lacinia fringilla lectus, "
|
|
"nec euismod odio convallis sed. Nunc ac libero ultricies, condimentum neque et, "
|
|
"fermentum urna. Donec feugiat diam sed nulla rutrum, sit amet accumsan odio tempor."
|
|
)
|
|
|
|
# (name, list of run texts). Single-element lists are single-run paragraphs.
|
|
_SINGLE_RUN_CASES = [
|
|
("body", _BODY),
|
|
("empty", ""),
|
|
("one_space", " "),
|
|
("only_spaces", " "),
|
|
("one_word", "word"),
|
|
("leading_spaces", " leading spaces after indent"),
|
|
("trailing_spaces", "trailing spaces before end "),
|
|
("double_spaces", "double spaces between words"),
|
|
("long_word", "verylongword" * 13),
|
|
("word_then_long_word", "someword " + "verylongword" * 13),
|
|
("long_word_then_word", "verylongword" * 13 + " someword"),
|
|
("cyrillic", "Электроэнцефалографический анализ показал устойчивую динамику показателей."),
|
|
("cyrillic_long", "Электроэнцефалографический " * 8),
|
|
("punctuation_only", ". , ; : ! ?"),
|
|
("em_dash_tail", "OpenAI is a leading research organization. Hello world —"),
|
|
("code_line", " table._cells[0]._element.append(paragraph_rendered_info.docx_element._element)"),
|
|
("code_short", " continuation_paragraph = Paragraph(self.parent)"),
|
|
("code_ooo", "ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo"),
|
|
("code_comment", " # and ((min(2, height_data.lines) - 1) * height_data.line_spacing + 1)\\"),
|
|
("tabs_as_spaces", " def add_link(self, text: str, url: str, is_bold: bool = None):"),
|
|
]
|
|
|
|
_MULTI_RUN_CASES = [
|
|
("split_mid_word", ["verylong", "wordthat", "spansruns and then more text follows here"]),
|
|
("split_mid_word_long", ["verylongword" * 6, "verylongword" * 7]),
|
|
("empty_runs_interleaved", ["", "hello", "", " ", "", "world", ""]),
|
|
("space_owned_by_own_run", ["hello", " ", "world", " ", "again"]),
|
|
("code_tokens", ["", " ", "run", " ", "=", " ", "DocxRun", "(", "create_element",
|
|
"(", '"', "w:r", '"', ")", ",", " ", "self", ".", "_docx_paragraph", ")"]),
|
|
("code_tokens2", ["", " ", "-", ">", " ", "Generator", "[", "RenderedInfo", " ",
|
|
"|", " ", "Renderable", ",", " ", "None", ",", " ", "None", "]", ":"]),
|
|
("trailing_empty_run", ["some words here", ""]),
|
|
("trailing_space_run", ["some words here", " "]),
|
|
("all_empty", ["", "", ""]),
|
|
("word_ends_exactly_at_run_end", ["complete", " ", "words", " ", "only"]),
|
|
("many_fragments_one_word", list("supercalifragilisticexpialidocious")),
|
|
("long_text_split_in_three", [_BODY[:90], _BODY[90:180], _BODY[180:]]),
|
|
]
|
|
|
|
# (bold, italic) per run, cycled - exercises the per-run font merge path
|
|
_STYLE_CYCLE = [(None, None), (True, None), (None, True), (True, True)]
|
|
|
|
|
|
class TestCountLinesEquivalence(unittest.TestCase):
|
|
"""New count_lines must return exactly what the legacy implementation returned."""
|
|
|
|
def setUp(self):
|
|
self._document, self._max_height, self._max_width = _create_test_document()
|
|
|
|
def _assert_same(self, paragraph, max_width, first_line_indent, is_mono):
|
|
sizer = ParagraphSizer(paragraph, None, max_width)
|
|
runs = paragraph.runs
|
|
docx_font = paragraph.style.font
|
|
expected = _legacy_count_lines(runs, max_width, docx_font, first_line_indent, is_mono)
|
|
actual = sizer.count_lines(runs, max_width, docx_font, first_line_indent, is_mono)
|
|
self.assertEqual(expected, actual)
|
|
|
|
def test_single_run_normal_style(self):
|
|
for name, text in _SINGLE_RUN_CASES:
|
|
for indent in (0, Cm(1.25)):
|
|
with self.subTest(case=name, indent=indent):
|
|
paragraph = self._document.add_paragraph()
|
|
paragraph.add_run(text)
|
|
self._assert_same(paragraph, self._max_width, indent, False)
|
|
|
|
def test_single_run_code_style(self):
|
|
max_width = self._max_width - LISTING_OFFSET
|
|
for name, text in _SINGLE_RUN_CASES:
|
|
for is_mono in (False, True):
|
|
with self.subTest(case=name, is_mono=is_mono):
|
|
paragraph = self._document.add_paragraph(style="Code")
|
|
paragraph.add_run(text)
|
|
self._assert_same(paragraph, max_width, 0, is_mono)
|
|
|
|
def test_single_run_narrow_width(self):
|
|
"""Narrow columns push almost every word onto its own line."""
|
|
for name, text in _SINGLE_RUN_CASES:
|
|
with self.subTest(case=name):
|
|
paragraph = self._document.add_paragraph()
|
|
paragraph.add_run(text)
|
|
self._assert_same(paragraph, Cm(3), 0, False)
|
|
|
|
def test_multi_run_normal_style(self):
|
|
for name, texts in _MULTI_RUN_CASES:
|
|
for indent in (0, Cm(1.25)):
|
|
with self.subTest(case=name, indent=indent):
|
|
paragraph = self._document.add_paragraph()
|
|
for text in texts:
|
|
paragraph.add_run(text)
|
|
self._assert_same(paragraph, self._max_width, indent, False)
|
|
|
|
def test_multi_run_code_style(self):
|
|
max_width = self._max_width - LISTING_OFFSET
|
|
for name, texts in _MULTI_RUN_CASES:
|
|
with self.subTest(case=name):
|
|
paragraph = self._document.add_paragraph(style="Code")
|
|
for text in texts:
|
|
paragraph.add_run(text)
|
|
self._assert_same(paragraph, max_width, 0, True)
|
|
|
|
def test_multi_run_mixed_bold_italic(self):
|
|
"""Different fonts per run: word fragments must be measured with their own font."""
|
|
for name, texts in _MULTI_RUN_CASES:
|
|
with self.subTest(case=name):
|
|
paragraph = self._document.add_paragraph()
|
|
for i, text in enumerate(texts):
|
|
run = paragraph.add_run(text)
|
|
run.font.bold, run.font.italic = _STYLE_CYCLE[i % len(_STYLE_CYCLE)]
|
|
self._assert_same(paragraph, self._max_width, Cm(1.25), False)
|
|
|
|
def test_no_break_hyphen_runs(self):
|
|
"""An empty run carrying w:noBreakHyphen is measured as a literal '-'."""
|
|
for texts in (["во", "", "первых"],
|
|
["физико", "", "математический анализ данных"],
|
|
["", "", ""],
|
|
["интервал 100", "", "150 Мб и далее по тексту"]):
|
|
with self.subTest(texts=texts):
|
|
paragraph = self._document.add_paragraph()
|
|
for text in texts:
|
|
run = paragraph.add_run(text)
|
|
if text == "":
|
|
run._element.append(OxmlElement("w:noBreakHyphen"))
|
|
self._assert_same(paragraph, self._max_width, Cm(1.25), False)
|
|
|
|
def test_widths_measured_once_per_word(self):
|
|
"""Guards the optimization itself: no per-character re-measurement."""
|
|
paragraph = self._document.add_paragraph()
|
|
text = "alpha bravo charlie delta echo foxtrot"
|
|
paragraph.add_run(text)
|
|
|
|
calls = []
|
|
original = Font.get_text_width
|
|
|
|
def counting(self, value):
|
|
calls.append(value)
|
|
return original(self, value)
|
|
|
|
Font.get_text_width = counting
|
|
try:
|
|
ParagraphSizer(paragraph, None, self._max_width).count_lines(
|
|
paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25))
|
|
finally:
|
|
Font.get_text_width = original
|
|
|
|
words = text.split(" ")
|
|
# one call for the space width, then one per whole word
|
|
self.assertEqual([" "] + words, calls)
|
|
self.assertLess(len(calls), len(text.replace(" ", "")))
|
|
|
|
|
|
class TestCalculateHeightEquivalence(unittest.TestCase):
|
|
"""Full calculate_height results for a representative document must not move."""
|
|
|
|
def setUp(self):
|
|
self._document, self._max_height, self._max_width = _create_test_document()
|
|
|
|
def _result_tuple(self, paragraph, max_width):
|
|
result = ParagraphSizer(paragraph, None, max_width).calculate_height()
|
|
return (result.lines, int(result.line_height), result.line_spacing,
|
|
int(result.before), int(result.after), int(result.base), int(result.full))
|
|
|
|
def test_repeated_calls_are_stable(self):
|
|
"""Shared Font instances must not accumulate state across calls."""
|
|
paragraph = self._document.add_paragraph()
|
|
paragraph.add_run(_BODY)
|
|
first = self._result_tuple(paragraph, self._max_width)
|
|
for _ in range(5):
|
|
self.assertEqual(first, self._result_tuple(paragraph, self._max_width))
|
|
|
|
def test_interleaved_fonts_do_not_interfere(self):
|
|
"""Alternating between cached fonts must not change either one's output."""
|
|
body = self._document.add_paragraph()
|
|
body.add_run(_BODY)
|
|
code = self._document.add_paragraph(style="Code")
|
|
code.add_run(" table._cells[0]._element.append(rendered_info.docx_element._element)")
|
|
|
|
body_first = self._result_tuple(body, self._max_width)
|
|
code_first = self._result_tuple(code, self._max_width - LISTING_OFFSET)
|
|
|
|
for _ in range(3):
|
|
self.assertEqual(code_first, self._result_tuple(code, self._max_width - LISTING_OFFSET))
|
|
self.assertEqual(body_first, self._result_tuple(body, self._max_width))
|
|
|
|
|
|
class TestFontCache(unittest.TestCase):
|
|
def test_same_descriptor_returns_same_instance(self):
|
|
self.assertIs(_get_font("Times New Roman", False, False, 14),
|
|
_get_font("Times New Roman", False, False, 14))
|
|
|
|
def test_different_descriptors_return_different_instances(self):
|
|
base = _get_font("Times New Roman", False, False, 14)
|
|
self.assertIsNot(base, _get_font("Times New Roman", True, False, 14))
|
|
self.assertIsNot(base, _get_font("Times New Roman", False, True, 14))
|
|
self.assertIsNot(base, _get_font("Times New Roman", False, False, 12))
|
|
self.assertIsNot(base, _get_font("Courier New", False, False, 14))
|
|
|
|
def test_cached_font_matches_fresh_font(self):
|
|
descriptors = [
|
|
("Times New Roman", False, False, 14),
|
|
("Times New Roman", True, False, 14),
|
|
("Times New Roman", False, True, 14),
|
|
("Times New Roman", True, True, 14),
|
|
("Courier New", False, False, 12),
|
|
("Calibri", False, False, 14),
|
|
]
|
|
samples = ["", " ", "i", "m", "hello", "in", "Электроэнцефалографический",
|
|
" ", "a b c", "verylongword" * 5, "()[]{}", "0123456789"]
|
|
for descriptor in descriptors:
|
|
legacy = _LegacyFont(*descriptor)
|
|
cached = _get_font(*descriptor)
|
|
with self.subTest(descriptor=descriptor):
|
|
self.assertEqual(legacy.is_mono, cached.is_mono)
|
|
self.assertEqual(legacy.get_line_height(), cached.get_line_height())
|
|
for sample in samples:
|
|
self.assertEqual(legacy.get_text_width(sample),
|
|
cached.get_text_width(sample),
|
|
msg=f"{descriptor} {sample!r}")
|
|
|
|
def test_mono_width_is_independent_of_glyph_slot(self):
|
|
"""get_text_width must not depend on whichever glyph was loaded last."""
|
|
font = _get_font("Courier New", False, False, 12)
|
|
self.assertTrue(font.is_mono)
|
|
expected = font.get_text_width("hello world")
|
|
for char in "iMW._@":
|
|
font._face.load_char(char)
|
|
self.assertEqual(expected, font.get_text_width("hello world"))
|
|
|
|
def test_font_holds_no_per_call_state(self):
|
|
"""Repeated and interleaved measurements return identical values."""
|
|
font = _get_font("Times New Roman", False, False, 14)
|
|
other = _get_font("Courier New", False, False, 12)
|
|
widths = {text: font.get_text_width(text) for text in ("a", "ab", "abc", "hello world")}
|
|
for _ in range(3):
|
|
other.get_text_width("hello world")
|
|
for text, width in widths.items():
|
|
self.assertEqual(width, font.get_text_width(text))
|
|
|
|
def test_public_font_constructor_still_works(self):
|
|
"""Font itself stays constructible - existing tests instantiate it directly."""
|
|
font = Font("Times New Roman", False, False, 14)
|
|
self.assertFalse(font.is_mono)
|
|
self.assertGreater(font.get_text_width("hello"), 0)
|
|
|
|
|
|
class TestMeasureParagraphCache(unittest.TestCase):
|
|
"""measure_paragraph() decides page breaks, so a hit must equal a fresh run."""
|
|
|
|
def setUp(self):
|
|
self._document, self._max_height, self._max_width = _create_test_document()
|
|
|
|
def _fresh(self, paragraph, previous, max_width):
|
|
return ParagraphSizer(paragraph, previous, max_width).calculate_height()
|
|
|
|
def test_hit_equals_fresh_measurement(self):
|
|
cache = {}
|
|
paragraph = self._document.add_paragraph()
|
|
paragraph.add_run(_BODY)
|
|
first = measure_paragraph(cache, paragraph, None, self._max_width)
|
|
for _ in range(3):
|
|
self.assertEqual(self._fresh(paragraph, None, self._max_width), first)
|
|
self.assertEqual(first, measure_paragraph(cache, paragraph, None, self._max_width))
|
|
|
|
def test_edited_text_is_remeasured(self):
|
|
cache = {}
|
|
paragraph = self._document.add_paragraph()
|
|
run = paragraph.add_run("short")
|
|
short = measure_paragraph(cache, paragraph, None, self._max_width)
|
|
run.text = _BODY
|
|
long = measure_paragraph(cache, paragraph, None, self._max_width)
|
|
self.assertGreater(long.lines, short.lines)
|
|
self.assertEqual(self._fresh(paragraph, None, self._max_width), long)
|
|
|
|
def test_edited_spacing_is_remeasured(self):
|
|
cache = {}
|
|
paragraph = self._document.add_paragraph()
|
|
paragraph.add_run(_BODY)
|
|
before = measure_paragraph(cache, paragraph, None, self._max_width)
|
|
paragraph.paragraph_format.space_before = Pt(12)
|
|
after = measure_paragraph(cache, paragraph, None, self._max_width)
|
|
self.assertNotEqual(int(before.before), int(after.before))
|
|
self.assertEqual(self._fresh(paragraph, None, self._max_width), after)
|
|
|
|
def test_width_is_part_of_the_key(self):
|
|
cache = {}
|
|
paragraph = self._document.add_paragraph()
|
|
paragraph.add_run(_BODY)
|
|
wide = measure_paragraph(cache, paragraph, None, self._max_width)
|
|
narrow = measure_paragraph(cache, paragraph, None, self._max_width // 2)
|
|
self.assertGreater(narrow.lines, wide.lines)
|
|
|
|
def test_predecessor_spacing_is_part_of_the_key(self):
|
|
cache = {}
|
|
previous = self._document.add_paragraph()
|
|
previous.add_run("previous")
|
|
paragraph = self._document.add_paragraph()
|
|
paragraph.paragraph_format.space_before = Pt(12)
|
|
paragraph.add_run(_BODY)
|
|
alone = measure_paragraph(cache, paragraph, None, self._max_width)
|
|
collapsed = measure_paragraph(cache, paragraph, previous, self._max_width)
|
|
self.assertNotEqual(int(alone.before), int(collapsed.before))
|
|
self.assertEqual(self._fresh(paragraph, previous, self._max_width), collapsed)
|
|
|
|
def test_result_is_a_private_copy(self):
|
|
"""Paragraph.render() zeroes .before on a new page; the cache must not keep that."""
|
|
cache = {}
|
|
paragraph = self._document.add_paragraph()
|
|
paragraph.paragraph_format.space_before = Pt(12)
|
|
paragraph.add_run(_BODY)
|
|
first = measure_paragraph(cache, paragraph, None, self._max_width)
|
|
self.assertGreater(int(first.before), 0)
|
|
first.before = 0
|
|
second = measure_paragraph(cache, paragraph, None, self._max_width)
|
|
self.assertGreater(int(second.before), 0)
|
|
|
|
def test_attaching_the_predecessor_still_hits(self):
|
|
"""A figure is measured detached, then again after the Renderer attaches it."""
|
|
cache = {}
|
|
previous = self._document.add_paragraph()
|
|
previous.add_run("previous")
|
|
detached = self._document.add_paragraph()
|
|
detached.add_run(_BODY)
|
|
measure_paragraph(cache, detached, previous, self._max_width)
|
|
size = len(cache)
|
|
self._document._body._element.append(previous._p)
|
|
measure_paragraph(cache, detached, previous, self._max_width)
|
|
self.assertEqual(size, len(cache))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|