v0.5.3
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
Igor20264
2026-09-10 10:50:15 +03:00
parent 510f7e7adf
commit e6b58efeb3
95 changed files with 7165 additions and 8771 deletions
+103 -11
View File
@@ -1,9 +1,11 @@
import logging
import os
from dataclasses import dataclass
from functools import cached_property
from copy import deepcopy
from dataclasses import dataclass, replace
from functools import cached_property, lru_cache
from math import ceil
from lxml import etree
from docx.enum.text import WD_LINE_SPACING
from docx.oxml import CT_R
from docx.text.run import Run
@@ -70,7 +72,7 @@ class Font:
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)
return Pt(len(text) * self._advance / 64)
def get_line_height(self) -> Length:
# TODO: make it work for all fonts
@@ -80,13 +82,36 @@ class Font:
return Pt(13.61)
else:
return Pt(self._face.size.height / 64)
@cached_property
def is_mono(self):
def _metrics(self) -> tuple[bool, int]:
# The advance is snapshotted here instead of being read back from
# self._face.glyph later: the face is shared between callers via
# _get_font, and any other load_char would otherwise change the glyph
# slot under us.
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
# return self._face.glyph.bitmap.width
m_width = self._face.glyph.advance.x
return i_width == m_width, m_width
@property
def is_mono(self) -> bool:
return self._metrics[0]
@property
def _advance(self) -> int:
return self._metrics[1]
@lru_cache(maxsize=256)
def _get_font(name: str, bold: bool, italic: bool, size_pt: float) -> Font:
"""Fonts are immutable once built, so identical descriptors share one instance.
Constructing a Font costs ~2.6 ms (FreeType face + PIL canvas) and
calculate_height() would otherwise build 2 + len(runs) of them per call.
"""
return Font(name, bold, italic, size_pt)
@@ -152,7 +177,7 @@ class ParagraphSizer:
lines = 1
line_width = first_line_indent
space_width = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt).get_text_width(" ")
space_width = _get_font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt).get_text_width(" ")
if not is_mono:
space_width *= 0.81
@@ -168,7 +193,7 @@ class ParagraphSizer:
docx_font,
run.font
)
font = Font(run_docx_font.name, run_docx_font.bold, run_docx_font.italic, run_docx_font.size.pt)
font = _get_font(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"):
@@ -178,6 +203,11 @@ class ParagraphSizer:
for c in run_text:
if c == " ":
# word_parts_widths[-1] is only ever read here and at the run
# boundary below, so the prefix width is measured once per
# word instead of once per character.
if word_part:
word_parts_widths[-1] = font.get_text_width(word_part)
if any(word_parts_widths):
width = spaces*space_width + sum(word_parts_widths)
if width <= max_width - line_width:
@@ -200,7 +230,11 @@ class ParagraphSizer:
spaces += 1
else:
word_part += c
word_parts_widths[-1] = font.get_text_width(word_part)
# A word may span runs; the next iteration appends a fresh slot for
# the continuation, so this run's fragment has to be measured now.
if word_part:
word_parts_widths[-1] = font.get_text_width(word_part)
return int(lines)
@@ -221,7 +255,7 @@ class ParagraphSizer:
max_width -= (paragraph_format.left_indent or 0) + \
(paragraph_format.right_indent or 0)
font = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt)
font = _get_font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt)
# here self.paragraph.runs is not used because
# it does not always return all runs (e.g. if they are inside hyperlink)
@@ -265,3 +299,61 @@ class ParagraphSizer:
raise NotImplementedError("Line spacing rule AT_LEAST is not supported")
return ParagraphSizerResult(before, lines, line_height, line_spacing, after)
def _is_blank_pPr(pPr) -> bool:
"""An empty w:pPr carries no formatting and is equivalent to none at all.
Measuring a paragraph materialises one: paragraph_format reads go through
get_or_add_pPr. Treating it as absent keeps a paragraph's key the same
before and after its first measurement.
"""
return pPr is None or (not len(pPr) and not pPr.attrib)
def _content_key(paragraph: Paragraph) -> bytes:
"""Everything calculate_height() reads from the measured paragraph."""
p = deepcopy(paragraph._p)
if p.pPr is not None and _is_blank_pPr(p.pPr):
p.remove(p.pPr)
return etree.tostring(p)
def _predecessor_key(paragraph: Paragraph) -> bytes:
"""Everything calculate_height() reads from the *preceding* paragraph.
Only its style and its direct formatting matter (space_after feeds the
collapsed space before the measured paragraph), so the runs are left out
of the key. That also keeps the key stable once the predecessor has been
appended to the body: attaching a w:p makes lxml move the drawing
namespace declarations onto it, which would otherwise look like an edit.
"""
pPr = paragraph._p.pPr
return b"" if _is_blank_pPr(pPr) else etree.tostring(deepcopy(pPr))
def measure_paragraph(cache: dict, paragraph: Paragraph,
previous_paragraph: Paragraph | None,
max_width: Length) -> ParagraphSizerResult:
"""calculate_height() memoised per owning renderable.
The same paragraph is measured several times per document: a table row is
peeked before it is built, a listing's first line is probed before the
loop, a figure caption is sized once to see whether the figure fits and
again when it is emitted. One measurement costs milliseconds, serialising
the two paragraphs costs microseconds, so the XML itself is the key — an
edit between two calls misses the cache instead of returning a stale
height. The cache lives on the caller, so it never spans documents (style
definitions are not part of the key).
"""
key = (
_content_key(paragraph),
_predecessor_key(previous_paragraph) if previous_paragraph is not None else None,
int(max_width),
)
result = cache.get(key)
if result is None:
result = ParagraphSizer(paragraph, previous_paragraph, max_width).calculate_height()
cache[key] = result
# Callers zero out .before on a new page, so hand out a private copy.
return replace(result)