v0.5.3
- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
@@ -9,7 +9,7 @@ from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from md2gost.layout_tracker import LayoutState
|
||||
from md2gost.renderable import Renderable
|
||||
from md2gost.rendered_info import RenderedInfo
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
from .paragraph_sizer import ParagraphSizerResult, measure_paragraph
|
||||
from ..util import create_element
|
||||
|
||||
|
||||
@@ -35,8 +35,16 @@ class Caption(Renderable):
|
||||
self._parent = parent
|
||||
self._before = before
|
||||
self._category = category
|
||||
self._size_cache: dict = {}
|
||||
self._docx_paragraph = DocxParagraph(create_element("w:p"), parent)
|
||||
|
||||
# Table/listing captions use keep_with_next. pageBreakBefore + keepWithNext
|
||||
# on a paragraph that Word already wraps to a new page produces a blank
|
||||
# page that the layout tracker / debug overlay never sees. Written here
|
||||
# rather than at the end of render() so that measuring the caption twice
|
||||
# (fit check, then emit) sees the same paragraph.
|
||||
self._docx_paragraph.paragraph_format.page_break_before = False
|
||||
|
||||
style_name = CAPTION_STYLES.get(category, "Caption")
|
||||
try:
|
||||
self._docx_paragraph.style = style_name
|
||||
@@ -58,18 +66,20 @@ class Caption(Renderable):
|
||||
def center(self):
|
||||
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
|
||||
def measure(self, previous_paragraph: DocxParagraph | None, max_width) -> ParagraphSizerResult:
|
||||
"""Line count and spacing of the caption line against a given predecessor."""
|
||||
return measure_paragraph(
|
||||
self._size_cache, self._docx_paragraph, previous_paragraph, max_width
|
||||
)
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
|
||||
"RenderedInfo | Renderable", None, None]:
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
height_data = self.measure(
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width
|
||||
).calculate_height()
|
||||
)
|
||||
|
||||
# Table/listing captions use keep_with_next. pageBreakBefore + keepWithNext
|
||||
# on a paragraph that Word already wraps to a new page produces a blank
|
||||
# page that the layout tracker / debug overlay never sees.
|
||||
leftover = 0
|
||||
need_new_page = (
|
||||
self._before
|
||||
@@ -79,11 +89,6 @@ class Caption(Renderable):
|
||||
)
|
||||
if need_new_page:
|
||||
leftover = layout_state.remaining_page_height
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
None,
|
||||
layout_state.max_width
|
||||
).calculate_height()
|
||||
height_data = self.measure(None, layout_state.max_width)
|
||||
|
||||
self._docx_paragraph.paragraph_format.page_break_before = False
|
||||
yield RenderedInfo(self._docx_paragraph, height_data.full + leftover)
|
||||
|
||||
@@ -8,7 +8,7 @@ from .image import Image
|
||||
from .listing import Listing
|
||||
from .renderable import Renderable
|
||||
from .requires_numbering import RequiresNumbering
|
||||
from ..diagram_renderer import render_diagram
|
||||
from ..diagram_renderer import register_pending_diagram, render_diagram
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
|
||||
@@ -34,6 +34,9 @@ class DiagramFigure(Renderable, RequiresNumbering):
|
||||
)
|
||||
self._number = None
|
||||
self._listing_number = None
|
||||
# Parsing runs fully before rendering, so this collects the whole document
|
||||
# and lets the first render() draw every PlantUML source in one JVM.
|
||||
register_pending_diagram(lang, source)
|
||||
self.landscape = bool(caption_info and caption_info.landscape)
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
@@ -79,6 +79,7 @@ class Image(Renderable, RequiresNumbering):
|
||||
logging.warning("SVG blip attach failed (%s): %s", svg_path, exc)
|
||||
|
||||
self._number = None
|
||||
self._caption: Caption | None = None
|
||||
self.landscape = bool(caption_info and caption_info.landscape)
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
@@ -89,6 +90,21 @@ class Image(Renderable, RequiresNumbering):
|
||||
|
||||
def set_number(self, number: str):
|
||||
self._number = number
|
||||
self._caption = None
|
||||
|
||||
def _get_caption(self) -> Caption:
|
||||
"""One caption object per figure.
|
||||
|
||||
Paragraph.render() lays a figure out once to learn its height and the
|
||||
Renderer then renders it for real; a shared caption keeps the second
|
||||
pass from re-measuring the same line.
|
||||
"""
|
||||
if self._caption is None:
|
||||
self._caption = Caption(
|
||||
self._parent, "Рисунок", self._caption_info, self._number, False
|
||||
)
|
||||
self._caption.center()
|
||||
return self._caption
|
||||
|
||||
def _reset_native_size(self) -> None:
|
||||
if self._image is None or self._native_width is None or self._native_height is None:
|
||||
@@ -142,7 +158,4 @@ class Image(Renderable, RequiresNumbering):
|
||||
|
||||
layout_state.add_height(rendered_image.height)
|
||||
|
||||
caption = Caption(self._parent, "Рисунок", self._caption_info, self._number, False)
|
||||
caption.center()
|
||||
|
||||
yield from caption.render(rendered_image, copy(layout_state))
|
||||
yield from self._get_caption().render(rendered_image, copy(layout_state))
|
||||
|
||||
@@ -33,7 +33,7 @@ class DocxParagraphPygmentsFormatter(Formatter):
|
||||
self._styles[token] = style
|
||||
|
||||
def _add_run_to_last_paragraph(self, text, style):
|
||||
self._paragraphs[-1].add_run(text, style["bold"] or None, style["italic"] in style or None,
|
||||
self._paragraphs[-1].add_run(text, style["bold"] or None, style.get("italic") or None,
|
||||
RGBColor.from_string(style['color']) if style['color'] else None)
|
||||
|
||||
def format(self, tokensource, outfile):
|
||||
@@ -63,20 +63,6 @@ class Listing(Renderable, RequiresNumbering):
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
def _create_table(self, parent, width: Length):
|
||||
# Kept for tests / callers that still expect the helper; render uses multi-row.
|
||||
left_margin = Twips(int(
|
||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
right_margin = Twips(int(
|
||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
|
||||
return create_table(
|
||||
parent, 1, 1,
|
||||
Twips(_twips(width) + _twips(left_margin) + _twips(right_margin)),
|
||||
)
|
||||
|
||||
def set_text(self, text: str):
|
||||
def create_paragraph() -> Paragraph:
|
||||
paragraph = Paragraph(self._parent)
|
||||
|
||||
@@ -9,7 +9,7 @@ from docx.opc.constants import RELATIONSHIP_TYPE
|
||||
from . import Renderable
|
||||
from .caption import CaptionInfo
|
||||
from .image import Image
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
from .paragraph_sizer import ParagraphSizerResult, measure_paragraph
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..sub_renderable import SubRenderable
|
||||
from ..util import create_element
|
||||
@@ -41,7 +41,7 @@ class Link:
|
||||
for i, part in enumerate(parts):
|
||||
docx_run = DocxRun(create_element("w:r"), self._docx_paragraph)
|
||||
self._hyperlink.append(docx_run._element)
|
||||
docx_run.text = text
|
||||
docx_run.text = part
|
||||
docx_run.style = "Hyperlink"
|
||||
docx_run.bold = is_bold
|
||||
docx_run.italic = is_italic
|
||||
@@ -95,6 +95,7 @@ class Paragraph(Renderable):
|
||||
self._docx_paragraph.style = "Normal"
|
||||
self._images: list[Image] = []
|
||||
self._pending_refs: list[tuple[object, str, str]] = [] # (run, type, name)
|
||||
self._size_cache: dict = {}
|
||||
|
||||
def add_run(self, text: str, is_bold: bool = None, is_italic: bool = None, color: RGBColor = None,
|
||||
strike_through: bool = None):
|
||||
@@ -231,6 +232,13 @@ class Paragraph(Renderable):
|
||||
def first_line_indent(self, value: Length):
|
||||
self._docx_paragraph.paragraph_format.first_line_indent = value
|
||||
|
||||
def measure(self, previous_paragraph: DocxParagraph | None,
|
||||
max_width: Length) -> ParagraphSizerResult:
|
||||
"""Line count and spacing of this paragraph against a given predecessor."""
|
||||
return measure_paragraph(
|
||||
self._size_cache, self._docx_paragraph, previous_paragraph, max_width
|
||||
)
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
remaining_space = layout_state.remaining_page_height
|
||||
@@ -238,11 +246,10 @@ class Paragraph(Renderable):
|
||||
if self.page_break_before:
|
||||
layout_state.add_height(layout_state.remaining_page_height)
|
||||
if self._docx_paragraph.text or not self._images:
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
height_data = self.measure(
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width).calculate_height()
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width)
|
||||
|
||||
if layout_state.current_page_height == 0 and layout_state.page > 1:
|
||||
height_data.before = 0
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -139,8 +139,6 @@ class Table(Renderable, RequiresNumbering):
|
||||
|
||||
def _peek_row_height(self, row_idx: int, layout_state: LayoutState) -> Length:
|
||||
"""Estimate row height without attaching cell paragraphs to a table."""
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
|
||||
row = self._rows[row_idx]
|
||||
merge_row = self._merge[row_idx]
|
||||
row_height = 0
|
||||
@@ -158,9 +156,9 @@ class Table(Renderable, RequiresNumbering):
|
||||
if merge_v != "continue":
|
||||
max_w = col_w * span - CELL_OFFSET
|
||||
for paragraph in row[col]:
|
||||
cell_height += ParagraphSizer(
|
||||
paragraph._docx_paragraph, None, max_w
|
||||
).calculate_height().full
|
||||
# Same measurement _build_docx_row() needs later, so the
|
||||
# cell paragraph is sized once per row width.
|
||||
cell_height += paragraph.measure(None, max_w).full
|
||||
row_height = max(cell_height, row_height)
|
||||
col += span
|
||||
row_height += Pt(0.5) + (
|
||||
@@ -169,24 +167,18 @@ class Table(Renderable, RequiresNumbering):
|
||||
return row_height
|
||||
|
||||
def _start_block_height(
|
||||
self, previous_rendered: RenderedInfo | None, layout_state: LayoutState
|
||||
self, caption: Caption, previous_rendered: RenderedInfo | None,
|
||||
layout_state: LayoutState
|
||||
) -> Length:
|
||||
"""Caption + header + first data row (method: no orphan title/header)."""
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
|
||||
# Dry-run caption sizing (temporary Caption; not yielded into the body).
|
||||
cap = Caption(
|
||||
self._parent, "Таблица", self._caption_info, self._number, True
|
||||
)
|
||||
prev_p = (
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph)
|
||||
else None
|
||||
)
|
||||
total = ParagraphSizer(
|
||||
cap._docx_paragraph, prev_p, layout_state.max_width
|
||||
).calculate_height().full
|
||||
total = caption.measure(prev_p, layout_state.max_width).full
|
||||
if self._rows:
|
||||
total += self._peek_row_height(0, layout_state)
|
||||
if len(self._rows) > 1:
|
||||
@@ -234,7 +226,8 @@ class Table(Renderable, RequiresNumbering):
|
||||
# not fit, push the whole block to the next page (layout leftover;
|
||||
# Word also gets keep_with_next on caption/header).
|
||||
leftover = Pt(0)
|
||||
start_h = self._start_block_height(previous_rendered, layout_state)
|
||||
caption = Caption(self._parent, "Таблица", self._caption_info, self._number, True)
|
||||
start_h = self._start_block_height(caption, previous_rendered, layout_state)
|
||||
if (
|
||||
layout_state.current_page_height > 0
|
||||
and start_h > layout_state.remaining_page_height
|
||||
@@ -244,8 +237,7 @@ class Table(Renderable, RequiresNumbering):
|
||||
previous_rendered = None
|
||||
|
||||
caption_rendered_infos = list(
|
||||
Caption(self._parent, "Таблица", self._caption_info, self._number, True)
|
||||
.render(previous_rendered, copy(layout_state))
|
||||
caption.render(previous_rendered, copy(layout_state))
|
||||
)
|
||||
if leftover and caption_rendered_infos:
|
||||
first = caption_rendered_infos[0]
|
||||
|
||||
Reference in New Issue
Block a user