v0.5.3
- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user