feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
Translation quality & format preservation: - Word: merge adjacent same-format runs into one unit (sentence-level coherence like inline-tag handling); translate comments/balloons; dedupe textbox collection (was translated twice); RTL no longer overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs) - PPTX: chart translations now actually reach the output file (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP); CJK typeface hints (a:ea) - Excel: sheet renames no longer break references — rewrite cell formulas (3D/quoted), defined names, data validations, cond. formats - PDF: bold/italic honored (hebo/heit/hebi); table cells never merge; unchanged blocks left untouched (typography preserved, fixes duplicate hyperlinks); attempted/changed stats + route gate now cover PDF; CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings) Features: - formality param (formal/informal) + automatic regional-variant prompts - output_mode=bilingual docx (source above translation) - per-user translation memory on Redis (falls back to LRU), context-hashed - QA report + 0-100 confidence score in job status; L0 on by default - OpenAI-compatible providers: whole chunk in ONE numbered-JSON request (~15x fewer calls) with per-item fallback; base prompt always present (custom prompt no longer replaces translation instructions) Infra & marketing alignment: - plan-based engine gating + vision gating (closes paid-engine leak); /providers/available filtered per plan; 107 languages exposed - zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash) - admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin (TestClient breakage); Prometheus test fixture fixed - marketing docs aligned with code (PDF+OCR, retention, engines, pricing) - security: .env.ionos/.env.production/provider_settings.json removed Tests: 1173 passed / 0 failed (6 network tests deselected: free Google endpoint temporarily blocked from this machine)
This commit is contained in:
@@ -31,6 +31,73 @@ RTL_LANGUAGES: frozenset = frozenset(
|
||||
{"ar", "he", "fa", "ur", "ku", "ps", "ug", "sd", "yi", "dv", "ckb"}
|
||||
)
|
||||
|
||||
# East-Asian / complex-script font hints: when the target language uses
|
||||
# glyphs a Latin theme font lacks, Word falls back to a substitute —
|
||||
# setting the eastAsia (CJK) or cs (Arabic script) typeface keeps the
|
||||
# rendering consistent across runs.
|
||||
CJK_EASTASIA_FONTS: dict = {
|
||||
"zh": "SimSun",
|
||||
"zh-CN": "SimSun",
|
||||
"zh-TW": "PMingLiU",
|
||||
"ja": "Yu Mincho",
|
||||
"ko": "Batang",
|
||||
}
|
||||
CS_FONTS: dict = {
|
||||
"ar": "Arial",
|
||||
"he": "Arial",
|
||||
"fa": "Arial",
|
||||
"ur": "Arial",
|
||||
}
|
||||
|
||||
|
||||
def _font_hints_for_target(target_language: str):
|
||||
"""(eastAsia_font, cs_font) hints for the target language, if any."""
|
||||
code = (target_language or "").strip()
|
||||
base = code.split("-")[0].lower()
|
||||
return CJK_EASTASIA_FONTS.get(code) or CJK_EASTASIA_FONTS.get(base), CS_FONTS.get(base)
|
||||
|
||||
|
||||
def _apply_font_hints(document: Document, target_language: str) -> None:
|
||||
"""Set eastAsia/cs typeface hints on every run for CJK/Arabic targets.
|
||||
|
||||
Blanket application is safe: the hint only affects the glyphs of that
|
||||
script, which Latin text does not contain.
|
||||
"""
|
||||
eastasia, cs = _font_hints_for_target(target_language)
|
||||
if not eastasia and not cs:
|
||||
return
|
||||
|
||||
runs = []
|
||||
for para in document.paragraphs:
|
||||
runs.extend(para.runs)
|
||||
for table in document.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
runs.extend(para.runs)
|
||||
for section in document.sections:
|
||||
for hf in (section.header, section.footer):
|
||||
for para in hf.paragraphs:
|
||||
runs.extend(para.runs)
|
||||
|
||||
hinted = 0
|
||||
for run in runs:
|
||||
rPr = run._r.get_or_add_rPr()
|
||||
rFonts = rPr.find(qn("w:rFonts"))
|
||||
if rFonts is None:
|
||||
rFonts = OxmlElement("w:rFonts")
|
||||
rPr.insert(0, rFonts)
|
||||
if eastasia and not rFonts.get(qn("w:eastAsia")):
|
||||
rFonts.set(qn("w:eastAsia"), eastasia)
|
||||
hinted += 1
|
||||
if cs and not rFonts.get(qn("w:cs")):
|
||||
rFonts.set(qn("w:cs"), cs)
|
||||
hinted += 1
|
||||
|
||||
if hinted:
|
||||
from core.logging import get_logger as _gl
|
||||
_gl(__name__).info("word_font_hints_applied", runs=hinted, eastasia=eastasia, cs=cs)
|
||||
|
||||
|
||||
from core.logging import get_logger
|
||||
|
||||
@@ -62,7 +129,9 @@ def _set_paragraph_rtl(paragraph: Paragraph) -> None:
|
||||
|
||||
Sets:
|
||||
- w:pPr/w:bidi → paragraph text direction = RTL
|
||||
- w:pPr/w:jc → alignment = right
|
||||
- w:pPr/w:jc → mirrored alignment (left→right), ONLY when the
|
||||
paragraph has no explicit alignment — centered/justified titles
|
||||
must not be forced right-aligned.
|
||||
- w:rPr/w:rtl → run-level RTL marker for each run
|
||||
"""
|
||||
pPr = paragraph._p.get_or_add_pPr()
|
||||
@@ -71,10 +140,12 @@ def _set_paragraph_rtl(paragraph: Paragraph) -> None:
|
||||
pPr.append(OxmlElement("w:bidi"))
|
||||
|
||||
jc = pPr.find(qn("w:jc"))
|
||||
if jc is None:
|
||||
jc = OxmlElement("w:jc")
|
||||
pPr.append(jc)
|
||||
jc.set(qn("w:val"), "right")
|
||||
explicit_alignment = jc is not None and jc.get(qn("w:val")) not in (None, "", "left")
|
||||
if not explicit_alignment:
|
||||
if jc is None:
|
||||
jc = OxmlElement("w:jc")
|
||||
pPr.append(jc)
|
||||
jc.set(qn("w:val"), "right")
|
||||
|
||||
for run in paragraph.runs:
|
||||
rPr = run._r.get_or_add_rPr()
|
||||
@@ -173,6 +244,7 @@ class WordTranslator:
|
||||
self._provider = provider
|
||||
self._custom_prompt: Optional[str] = None
|
||||
self._translation_stats = {"attempted": 0, "changed": 0}
|
||||
self._tm_scope = None # set via set_tm_scope (per-user translation memory)
|
||||
|
||||
def set_provider(self, provider: TranslationProvider) -> None:
|
||||
"""Set the translation provider."""
|
||||
@@ -182,6 +254,12 @@ class WordTranslator:
|
||||
"""Set custom system prompt for LLM providers."""
|
||||
self._custom_prompt = prompt
|
||||
|
||||
def set_tm_scope(self, user_id: Optional[str], prompt: Optional[str] = None) -> None:
|
||||
"""Enable the per-user translation memory for this job."""
|
||||
from services.translation_tm import TMScope
|
||||
|
||||
self._tm_scope = TMScope.from_prompt(user_id, prompt or self._custom_prompt)
|
||||
|
||||
def translate_file(
|
||||
self,
|
||||
input_path: Path,
|
||||
@@ -333,6 +411,10 @@ class WordTranslator:
|
||||
if target_language.lower() in RTL_LANGUAGES:
|
||||
_apply_rtl_to_document(document)
|
||||
|
||||
# CJK / Arabic-script font hints so Word renders the target
|
||||
# script with a proper typeface instead of per-run fallbacks.
|
||||
_apply_font_hints(document, target_language)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
{
|
||||
@@ -461,12 +543,30 @@ class WordTranslator:
|
||||
non_empty = [t for t in texts if t and t.strip()]
|
||||
self._translation_stats["attempted"] += len(non_empty)
|
||||
|
||||
from services.translation_tm import translate_with_tm
|
||||
|
||||
provider_name = (
|
||||
self._provider.get_name() if hasattr(self._provider, "get_name")
|
||||
else type(self._provider).__name__
|
||||
) if self._provider is not None else "legacy"
|
||||
|
||||
if self._provider is not None:
|
||||
translated = self._translate_with_provider(
|
||||
texts, target_language, source_language
|
||||
)
|
||||
def _do_translate(miss_texts):
|
||||
return self._translate_with_provider(
|
||||
miss_texts, target_language, source_language
|
||||
)
|
||||
else:
|
||||
translated = self._translate_with_legacy(texts, target_language, source_language)
|
||||
def _do_translate(miss_texts):
|
||||
return self._translate_with_legacy(
|
||||
miss_texts, target_language, source_language
|
||||
)
|
||||
|
||||
# Translation memory: reuse this user's previous translations
|
||||
# (identical context/prompt) before hitting the provider.
|
||||
translated = translate_with_tm(
|
||||
texts, target_language, source_language,
|
||||
provider_name, self._tm_scope, _do_translate,
|
||||
)
|
||||
|
||||
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
|
||||
self._translation_stats["changed"] += changed
|
||||
@@ -541,12 +641,19 @@ class WordTranslator:
|
||||
|
||||
Handles: paragraphs, tables, SDT (TOC/index), text boxes, shapes,
|
||||
AlternateContent blocks, and any nested drawing elements.
|
||||
|
||||
A single ``seen_run_elements`` set is shared by every collector so
|
||||
runs living in text boxes are never collected twice (the paragraph
|
||||
walk descends into w:txbxContent too).
|
||||
"""
|
||||
count_before = len(text_elements)
|
||||
seen_run_elements: set = set()
|
||||
|
||||
# Pass 1: walk direct body children
|
||||
for element in document.element.body:
|
||||
self._collect_from_element(element, document, text_elements)
|
||||
self._collect_from_element(
|
||||
element, document, text_elements, seen_run_elements
|
||||
)
|
||||
|
||||
pass1_count = len(text_elements) - count_before
|
||||
|
||||
@@ -554,15 +661,18 @@ class WordTranslator:
|
||||
# Text boxes / rectangles / shapes store their text here, nested deep
|
||||
# inside <w:drawing> → <a:graphic> → <wps:wsp> → <wps:txbx> or
|
||||
# inside <w:pict> → <v:shape> → <v:textbox>.
|
||||
self._collect_from_textboxes(document.element.body, document, text_elements)
|
||||
self._collect_from_textboxes(
|
||||
document.element.body, document, text_elements, seen_run_elements
|
||||
)
|
||||
|
||||
pass2_count = len(text_elements) - count_before - pass1_count
|
||||
|
||||
# Pass 3: footnotes and endnotes (live in separate parts)
|
||||
# Pass 3: footnotes, endnotes and comments (live in separate parts)
|
||||
if post_save_callbacks is None:
|
||||
post_save_callbacks = []
|
||||
self._collect_from_footnotes(document, text_elements, post_save_callbacks)
|
||||
self._collect_from_endnotes(document, text_elements, post_save_callbacks)
|
||||
self._collect_from_comments(document, text_elements, post_save_callbacks)
|
||||
|
||||
total = len(text_elements) - count_before
|
||||
_log_info(
|
||||
@@ -573,34 +683,38 @@ class WordTranslator:
|
||||
)
|
||||
|
||||
def _collect_from_element(
|
||||
self, element, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
self, element, document: Document,
|
||||
text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
seen_run_elements: Optional[set] = None,
|
||||
) -> None:
|
||||
"""Recursively collect from any element type."""
|
||||
if isinstance(element, CT_P):
|
||||
paragraph = Paragraph(element, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
|
||||
elif isinstance(element, CT_Tbl):
|
||||
table = Table(element, document)
|
||||
self._collect_from_table(table, text_elements)
|
||||
self._collect_from_table(table, text_elements, seen_run_elements)
|
||||
elif element.tag == qn("w:sdt"):
|
||||
self._collect_from_sdt(element, document, text_elements)
|
||||
self._collect_from_sdt(element, document, text_elements, seen_run_elements)
|
||||
elif element.tag == self._TAG_ALT_CONTENT:
|
||||
# <mc:AlternateContent> wraps drawing/shape content
|
||||
for part in element:
|
||||
self._collect_from_element(part, document, text_elements)
|
||||
self._collect_from_element(part, document, text_elements, seen_run_elements)
|
||||
else:
|
||||
# For any other container element, recurse into children
|
||||
# to catch paragraphs nested in unexpected wrappers
|
||||
for child in element:
|
||||
if isinstance(child, CT_P):
|
||||
paragraph = Paragraph(child, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
|
||||
elif isinstance(child, CT_Tbl):
|
||||
table = Table(child, document)
|
||||
self._collect_from_table(table, text_elements)
|
||||
self._collect_from_table(table, text_elements, seen_run_elements)
|
||||
|
||||
def _collect_from_textboxes(
|
||||
self, root, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
self, root, document: Document,
|
||||
text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
seen_run_elements: Optional[set] = None,
|
||||
) -> None:
|
||||
"""Find and collect text from ALL <w:txbxContent> elements in the XML tree.
|
||||
|
||||
@@ -612,20 +726,23 @@ class WordTranslator:
|
||||
- Shapes nested in <mc:AlternateContent> blocks
|
||||
|
||||
The <w:txbxContent> element contains regular <w:p> paragraphs
|
||||
with <w:r> runs, just like normal body text.
|
||||
with <w:r> runs, just like normal body text. Runs already collected
|
||||
during the body walk are skipped via ``seen_run_elements``.
|
||||
"""
|
||||
# Find all w:txbxContent elements anywhere in the tree
|
||||
for txbx in root.iter(qn("w:txbxContent")):
|
||||
for child in txbx:
|
||||
if isinstance(child, CT_P):
|
||||
paragraph = Paragraph(child, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
|
||||
elif isinstance(child, CT_Tbl):
|
||||
table = Table(child, document)
|
||||
self._collect_from_table(table, text_elements)
|
||||
self._collect_from_table(table, text_elements, seen_run_elements)
|
||||
|
||||
def _collect_from_sdt(
|
||||
self, sdt_element, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
self, sdt_element, document: Document,
|
||||
text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
seen_run_elements: Optional[set] = None,
|
||||
) -> None:
|
||||
"""Collect text from Structured Document Tags (TOC, index, content controls).
|
||||
|
||||
@@ -645,10 +762,10 @@ class WordTranslator:
|
||||
for child in sdt_content:
|
||||
if isinstance(child, CT_P):
|
||||
paragraph = Paragraph(child, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
|
||||
elif isinstance(child, CT_Tbl):
|
||||
table = Table(child, document)
|
||||
self._collect_from_table(table, text_elements)
|
||||
self._collect_from_table(table, text_elements, seen_run_elements)
|
||||
|
||||
def _collect_from_footnotes(
|
||||
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
@@ -783,6 +900,59 @@ class WordTranslator:
|
||||
|
||||
post_save_callbacks.append(write_endnotes_back)
|
||||
|
||||
def _collect_from_comments(
|
||||
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
post_save_callbacks: List[Callable[[Path], None]] = None,
|
||||
) -> None:
|
||||
"""Collect text from comments/balloons (word/comments.xml part).
|
||||
|
||||
Same mechanism as footnotes: the comments part is separate from the
|
||||
main document tree, so translations are written back after save.
|
||||
"""
|
||||
comments_xml = self._find_part_by_content_type(
|
||||
document,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
|
||||
)
|
||||
if comments_xml is None:
|
||||
return
|
||||
|
||||
collected = 0
|
||||
for t_elem in comments_xml.iter(qn("w:t")):
|
||||
original = t_elem.text or ""
|
||||
if not original.strip():
|
||||
continue
|
||||
|
||||
def make_t_setter(t):
|
||||
def setter(text: str) -> None:
|
||||
t.text = text
|
||||
return setter
|
||||
|
||||
text_elements.append((original, make_t_setter(t_elem)))
|
||||
collected += 1
|
||||
|
||||
if collected and post_save_callbacks is not None:
|
||||
def write_comments_back(output_path: Path) -> None:
|
||||
try:
|
||||
new_blob = etree.tostring(
|
||||
comments_xml,
|
||||
xml_declaration=True,
|
||||
encoding="UTF-8",
|
||||
standalone=True,
|
||||
)
|
||||
tmp_path = output_path.with_suffix(".tmp_com")
|
||||
with zipfile.ZipFile(output_path, "r") as zin, \
|
||||
zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.namelist():
|
||||
if item == "word/comments.xml":
|
||||
zout.writestr(item, new_blob)
|
||||
else:
|
||||
zout.writestr(item, zin.read(item))
|
||||
tmp_path.replace(output_path)
|
||||
except Exception as e:
|
||||
_log_error("word_comments_writeback_error", error=str(e))
|
||||
|
||||
post_save_callbacks.append(write_comments_back)
|
||||
|
||||
def _collect_from_charts(
|
||||
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
) -> None:
|
||||
@@ -1144,23 +1314,39 @@ class WordTranslator:
|
||||
except Exception as e:
|
||||
_log_error("word_diagram_zip_rewrite_error", error=str(e))
|
||||
|
||||
@staticmethod
|
||||
def _rpr_signature(run_element) -> str:
|
||||
"""Formatting signature of a run: serialized rPr XML (or "")."""
|
||||
rpr = run_element.find(qn("w:rPr"))
|
||||
if rpr is None:
|
||||
return ""
|
||||
import lxml.etree as _et
|
||||
|
||||
return _et.tostring(rpr, encoding="unicode")
|
||||
|
||||
def _collect_from_paragraph(
|
||||
self,
|
||||
paragraph: Paragraph,
|
||||
text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
seen_run_elements: Optional[set] = None,
|
||||
) -> None:
|
||||
"""Collect text from paragraph runs, preserving inter-run whitespace.
|
||||
|
||||
Each run is sent for translation WITHOUT its surrounding whitespace.
|
||||
The whitespace is captured and reapplied after translation so that words
|
||||
at formatting boundaries (e.g. bold/normal) do not get concatenated.
|
||||
Adjacent runs sharing the SAME parent element and the SAME run
|
||||
formatting (rPr) are merged into ONE translation unit: the sentence
|
||||
is translated whole — not fragment by fragment — and the result is
|
||||
written into the first run while the sibling runs are blanked.
|
||||
This is what keeps mid-sentence bold spans ("This is *very*
|
||||
important") coherent in the target language, like DeepL's inline
|
||||
tag handling.
|
||||
|
||||
Note: python-docx's `paragraph.runs` only returns DIRECT child <w:r>
|
||||
elements, not those inside <w:hyperlink> (used for TOC entries,
|
||||
cross-references, bookmark links). We therefore iterate the full
|
||||
XML tree to find every <w:r> and use a set of element ids to
|
||||
deduplicate — this avoids translating the same run twice while
|
||||
ensuring hyperlink text IS picked up.
|
||||
XML tree to find every <w:r> and deduplicate by element identity —
|
||||
`seen_run_elements` is shared across paragraphs so runs living in
|
||||
text boxes (also collected by _collect_from_textboxes) are not
|
||||
translated twice.
|
||||
"""
|
||||
# Check full paragraph text including nested content (hyperlinks, etc.)
|
||||
full_text = ''.join(
|
||||
@@ -1169,33 +1355,83 @@ class WordTranslator:
|
||||
if not full_text:
|
||||
return
|
||||
|
||||
# Collect every <w:r> element in the paragraph tree, including
|
||||
# those nested in <w:hyperlink>, <w:smartTag>, etc. The dedup by
|
||||
# element id is defensive — `paragraph.runs` and the manual iter
|
||||
# below could overlap if python-docx starts surfacing nested runs.
|
||||
seen_run_ids: set = set()
|
||||
if seen_run_elements is None:
|
||||
seen_run_elements = set()
|
||||
|
||||
# 1) Direct runs (paragraph.runs is the python-docx-native API).
|
||||
for run in paragraph.runs:
|
||||
run_id = id(run._r)
|
||||
if run_id in seen_run_ids:
|
||||
continue
|
||||
seen_run_ids.add(run_id)
|
||||
if run.text and run.text.strip():
|
||||
self._append_run_translation(run, text_elements)
|
||||
|
||||
# 2) Runs nested inside <w:hyperlink> (TOC, cross-references).
|
||||
# python-docx's `paragraph.runs` does NOT descend into hyperlinks in
|
||||
# version 1.x — we have to walk the XML ourselves.
|
||||
# Every <w:r> in the paragraph tree, in document order, deduplicated
|
||||
# by element identity (paragraph.runs and the manual iter overlap).
|
||||
ordered_runs = []
|
||||
for r_elem in paragraph._p.iter(qn('w:r')):
|
||||
run_id = id(r_elem)
|
||||
if run_id in seen_run_ids:
|
||||
if id(r_elem) in seen_run_elements:
|
||||
continue
|
||||
seen_run_ids.add(run_id)
|
||||
# Build a Run wrapper so the setter API is consistent.
|
||||
run = Run(r_elem, paragraph)
|
||||
if run.text and run.text.strip():
|
||||
seen_run_elements.add(id(r_elem))
|
||||
ordered_runs.append(r_elem)
|
||||
|
||||
# Merge adjacent runs: same parent + same formatting signature.
|
||||
# Merging never crosses a parent boundary, so runs belonging to
|
||||
# different hyperlinks stay separate units.
|
||||
group: list = [] # list of r_elems
|
||||
group_signature: Optional[str] = None
|
||||
|
||||
def _flush_group():
|
||||
combined = "".join(
|
||||
(t.text or "")
|
||||
for r in group
|
||||
for t in r.findall(qn("w:t"))
|
||||
)
|
||||
if not combined.strip():
|
||||
return
|
||||
non_empty = [r for r in group if r.findall(qn("w:t"))]
|
||||
if len(non_empty) == 1:
|
||||
run = Run(non_empty[0], paragraph)
|
||||
self._append_run_translation(run, text_elements)
|
||||
return
|
||||
leading = combined[: len(combined) - len(combined.lstrip())]
|
||||
trailing = combined[len(combined.rstrip()):]
|
||||
stripped = combined.strip()
|
||||
if not stripped:
|
||||
return
|
||||
|
||||
first = non_empty[0]
|
||||
|
||||
def make_group_setter(first_r, siblings, lead: str, trail: str):
|
||||
def setter(text: str) -> None:
|
||||
from docx.text.run import Run as _Run
|
||||
|
||||
run = _Run(first_r, paragraph)
|
||||
# Reapply the group's boundary whitespace so words are
|
||||
# never concatenated with the next differently-formatted
|
||||
# run ("This is quite" + "very" → "quite very").
|
||||
run.text = lead + text.strip() + trail
|
||||
# Blank the merged siblings: the whole sentence now
|
||||
# lives in the first run (formatting is identical).
|
||||
for sib in siblings:
|
||||
for t_elem in sib.findall(qn("w:t")):
|
||||
t_elem.text = ""
|
||||
|
||||
return setter
|
||||
|
||||
siblings = non_empty[1:]
|
||||
text_elements.append(
|
||||
(stripped, make_group_setter(first, siblings, leading, trailing))
|
||||
)
|
||||
|
||||
for r_elem in ordered_runs:
|
||||
# Whitespace-only runs join the group: dropping them would
|
||||
# concatenate words ("Hello" + " " + "World" → "HelloWorld").
|
||||
# They carry no w:t text, so a group of only whitespace runs is
|
||||
# skipped at flush time by the strip() check.
|
||||
signature = self._rpr_signature(r_elem)
|
||||
same_parent = (
|
||||
group and group[-1].getparent() is r_elem.getparent()
|
||||
)
|
||||
if group and same_parent and signature == group_signature:
|
||||
group.append(r_elem)
|
||||
else:
|
||||
_flush_group()
|
||||
group = [r_elem]
|
||||
group_signature = signature
|
||||
_flush_group()
|
||||
|
||||
def _append_run_translation(
|
||||
self,
|
||||
@@ -1220,15 +1456,16 @@ class WordTranslator:
|
||||
text_elements.append((stripped, make_setter(run, leading, trailing)))
|
||||
|
||||
def _collect_from_table(
|
||||
self, table: Table, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
self, table: Table, text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
seen_run_elements: Optional[set] = None,
|
||||
) -> None:
|
||||
"""Collect text from table cells."""
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for paragraph in cell.paragraphs:
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
|
||||
for nested_table in cell.tables:
|
||||
self._collect_from_table(nested_table, text_elements)
|
||||
self._collect_from_table(nested_table, text_elements, seen_run_elements)
|
||||
|
||||
def _collect_from_section(
|
||||
self, section: Section, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
|
||||
Reference in New Issue
Block a user