Files
md_to_gost/tests/test_paragraph_sizer_cache.py
Igor20264 f7136bd8bd
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled
v0.5.3
- Мелкие исправления увеличивающие работу конвертера
2026-09-10 10:50:15 +03:00

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()