feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup
- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,18 +7,22 @@ Updated to use new TranslationProvider interface with structured error handling.
|
||||
"""
|
||||
|
||||
import time
|
||||
import zipfile
|
||||
import io
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional, Callable, Any
|
||||
|
||||
from docx import Document
|
||||
from docx.text.paragraph import Paragraph
|
||||
from docx.text.run import Run
|
||||
from docx.table import Table, _Cell
|
||||
from docx.oxml.text.paragraph import CT_P
|
||||
from docx.oxml.table import CT_Tbl
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.section import Section
|
||||
from lxml import etree
|
||||
|
||||
from services.providers.base import TranslationProvider
|
||||
|
||||
@@ -154,6 +158,10 @@ class WordTranslator:
|
||||
MAX_FILE_SIZE_MB = 50
|
||||
DOCX_MAGIC_BYTES = b"PK" # .docx files are ZIP archives
|
||||
|
||||
# Namespace URIs not registered in python-docx's nsmap
|
||||
_NS_MC = "http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
_TAG_ALT_CONTENT = f"{{{_NS_MC}}}AlternateContent"
|
||||
|
||||
def __init__(self, provider: Optional[TranslationProvider] = None):
|
||||
"""
|
||||
Initialize WordTranslator.
|
||||
@@ -218,9 +226,17 @@ class WordTranslator:
|
||||
runs_translated = 0
|
||||
|
||||
text_elements: List[Tuple[str, Callable[[str], None]]] = []
|
||||
chart_translations: List[Dict[str, Any]] = []
|
||||
diagram_translations: List[Dict[str, Any]] = []
|
||||
|
||||
self._collect_from_body(document, text_elements)
|
||||
|
||||
# Collect chart text from ZIP (chart titles, axis labels, series names)
|
||||
self._collect_charts_from_zip(input_path, text_elements, chart_translations)
|
||||
|
||||
# Collect SmartArt/diagram text from ZIP
|
||||
self._collect_diagrams_from_zip(input_path, text_elements, diagram_translations)
|
||||
|
||||
total_sections = len(document.sections)
|
||||
total_elements = 0
|
||||
for section_idx, section in enumerate(document.sections):
|
||||
@@ -332,6 +348,14 @@ class WordTranslator:
|
||||
details={"file_name": output_path.name, "error": str(e)},
|
||||
)
|
||||
|
||||
# Re-inject chart translations into the saved .docx ZIP
|
||||
if chart_translations:
|
||||
self._apply_chart_translations(input_path, output_path, chart_translations)
|
||||
|
||||
# Re-inject SmartArt/diagram translations into the saved .docx ZIP
|
||||
if diagram_translations:
|
||||
self._apply_diagram_translations(output_path, diagram_translations)
|
||||
|
||||
processing_time_ms = round((time.time() - start_time) * 1000, 2)
|
||||
|
||||
_log_info(
|
||||
@@ -348,6 +372,13 @@ class WordTranslator:
|
||||
except WordProcessorError:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_log_error(
|
||||
"word_translation_unexpected_error",
|
||||
file_name=input_path.name,
|
||||
error=str(e),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
raise WordProcessorError(
|
||||
code=WordProcessorError.DOCX_READ_ERROR,
|
||||
details={"file_name": input_path.name, "error": str(e)},
|
||||
@@ -445,15 +476,506 @@ class WordTranslator:
|
||||
def _collect_from_body(
|
||||
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
) -> None:
|
||||
"""Collect all text elements from document body."""
|
||||
"""Collect all text elements from document body.
|
||||
|
||||
Handles: paragraphs, tables, SDT (TOC/index), text boxes, shapes,
|
||||
AlternateContent blocks, and any nested drawing elements.
|
||||
"""
|
||||
count_before = len(text_elements)
|
||||
|
||||
# Pass 1: walk direct body children
|
||||
for element in document.element.body:
|
||||
if isinstance(element, CT_P):
|
||||
paragraph = Paragraph(element, document)
|
||||
self._collect_from_element(element, document, text_elements)
|
||||
|
||||
pass1_count = len(text_elements) - count_before
|
||||
|
||||
# Pass 2: find ALL <w:txbxContent> in the entire body XML tree.
|
||||
# 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)
|
||||
|
||||
pass2_count = len(text_elements) - count_before - pass1_count
|
||||
|
||||
# Pass 3: footnotes and endnotes
|
||||
self._collect_from_footnotes(document, text_elements)
|
||||
self._collect_from_endnotes(document, text_elements)
|
||||
|
||||
total = len(text_elements) - count_before
|
||||
_log_info(
|
||||
"word_collection_summary",
|
||||
body_runs=pass1_count,
|
||||
textbox_runs=pass2_count,
|
||||
total_collected=total,
|
||||
)
|
||||
|
||||
def _collect_from_element(
|
||||
self, element, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
) -> None:
|
||||
"""Recursively collect from any element type."""
|
||||
if isinstance(element, CT_P):
|
||||
paragraph = Paragraph(element, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
elif isinstance(element, CT_Tbl):
|
||||
table = Table(element, document)
|
||||
self._collect_from_table(table, text_elements)
|
||||
elif element.tag == qn("w:sdt"):
|
||||
self._collect_from_sdt(element, document, text_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)
|
||||
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)
|
||||
elif isinstance(child, CT_Tbl):
|
||||
table = Table(child, document)
|
||||
self._collect_from_table(table, text_elements)
|
||||
|
||||
def _collect_from_textboxes(
|
||||
self, root, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
) -> None:
|
||||
"""Find and collect text from ALL <w:txbxContent> elements in the XML tree.
|
||||
|
||||
This catches text in:
|
||||
- Rectangles / rounded rectangles / any shape with text
|
||||
- Text boxes
|
||||
- Callouts
|
||||
- WordArt (if it has text content)
|
||||
- Shapes nested in <mc:AlternateContent> blocks
|
||||
|
||||
The <w:txbxContent> element contains regular <w:p> paragraphs
|
||||
with <w:r> runs, just like normal body text.
|
||||
"""
|
||||
# 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)
|
||||
elif isinstance(child, CT_Tbl):
|
||||
table = Table(child, document)
|
||||
self._collect_from_table(table, text_elements)
|
||||
|
||||
def _collect_from_sdt(
|
||||
self, sdt_element, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
) -> None:
|
||||
"""Collect text from Structured Document Tags (TOC, index, content controls).
|
||||
|
||||
SDT XML structure:
|
||||
<w:sdt>
|
||||
<w:sdtPr>...</w:sdtPr>
|
||||
<w:sdtContent>
|
||||
<w:p>...</w:p> <!-- paragraphs -->
|
||||
<w:tbl>...</w:tbl> <!-- tables -->
|
||||
</w:sdtContent>
|
||||
</w:sdt>
|
||||
"""
|
||||
sdt_content = sdt_element.find(qn("w:sdtContent"))
|
||||
if sdt_content is None:
|
||||
return
|
||||
|
||||
for child in sdt_content:
|
||||
if isinstance(child, CT_P):
|
||||
paragraph = Paragraph(child, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
elif isinstance(element, CT_Tbl):
|
||||
table = Table(element, document)
|
||||
elif isinstance(child, CT_Tbl):
|
||||
table = Table(child, document)
|
||||
self._collect_from_table(table, text_elements)
|
||||
|
||||
def _collect_from_footnotes(
|
||||
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
) -> None:
|
||||
"""Collect text from footnotes."""
|
||||
try:
|
||||
footnotes_part = document.part.package.part_related_by(
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes"
|
||||
) if hasattr(document.part, 'package') else None
|
||||
except Exception:
|
||||
footnotes_part = None
|
||||
|
||||
if footnotes_part is None:
|
||||
# Fallback: try direct XML access
|
||||
try:
|
||||
footnotes_element = document.element.find(qn("w:footnotes"))
|
||||
if footnotes_element is not None:
|
||||
for child in footnotes_element:
|
||||
if isinstance(child, CT_P):
|
||||
paragraph = Paragraph(child, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
footnotes_xml = etree.fromstring(footnotes_part.blob)
|
||||
for child in footnotes_xml:
|
||||
if child.tag == qn("w:footnote"):
|
||||
for para_elem in child.findall(qn("w:p")):
|
||||
paragraph = Paragraph(para_elem, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
except Exception as e:
|
||||
_log_error("word_footnotes_parse_error", error=str(e))
|
||||
|
||||
def _collect_from_endnotes(
|
||||
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
) -> None:
|
||||
"""Collect text from endnotes."""
|
||||
try:
|
||||
endnotes_part = document.part.package.part_related_by(
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes"
|
||||
) if hasattr(document.part, 'package') else None
|
||||
except Exception:
|
||||
endnotes_part = None
|
||||
|
||||
if endnotes_part is None:
|
||||
try:
|
||||
endnotes_element = document.element.find(qn("w:endnotes"))
|
||||
if endnotes_element is not None:
|
||||
for child in endnotes_element:
|
||||
if isinstance(child, CT_P):
|
||||
paragraph = Paragraph(child, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
endnotes_xml = etree.fromstring(endnotes_part.blob)
|
||||
for child in endnotes_xml:
|
||||
if child.tag == qn("w:endnote"):
|
||||
for para_elem in child.findall(qn("w:p")):
|
||||
paragraph = Paragraph(para_elem, document)
|
||||
self._collect_from_paragraph(paragraph, text_elements)
|
||||
except Exception as e:
|
||||
_log_error("word_endnotes_parse_error", error=str(e))
|
||||
|
||||
def _collect_from_charts(
|
||||
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
) -> None:
|
||||
"""Collect text from embedded charts (chart titles, axis labels, series names).
|
||||
|
||||
Charts are stored as separate XML parts in the .docx ZIP archive.
|
||||
The chart XML uses DrawingML namespaces for text content.
|
||||
"""
|
||||
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
|
||||
try:
|
||||
# Access the raw ZIP to find chart parts
|
||||
docx_path = document.part.package.main_document_part.partname
|
||||
package = document.part.package
|
||||
|
||||
# Find all chart relationship targets
|
||||
for rel_type, rels in (package.rels or {}).items():
|
||||
pass # python-docx doesn't expose this cleanly
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# More reliable: open the .docx as a ZIP and parse chart XML directly
|
||||
try:
|
||||
# Get the original file path from the document
|
||||
input_file = None
|
||||
# Try to recover the file path — document object doesn't store it directly
|
||||
# We'll handle charts in translate_file() instead where we have the path
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _collect_charts_from_zip(
|
||||
self, input_path: Path, text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
chart_translations: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Parse chart XML from the .docx ZIP and collect translatable text.
|
||||
|
||||
Args:
|
||||
input_path: Path to the .docx file
|
||||
text_elements: List to append (text, setter) tuples
|
||||
chart_translations: List to store chart translation metadata for later re-injection
|
||||
"""
|
||||
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(input_path, 'r') as zf:
|
||||
chart_files = [name for name in zf.namelist() if name.startswith('word/charts/') and name.endswith('.xml')]
|
||||
|
||||
for chart_file in chart_files:
|
||||
try:
|
||||
chart_xml = etree.fromstring(zf.read(chart_file))
|
||||
|
||||
# Collect from <c:title><c:tx><a:rich> or <c:tx><a:strRef>
|
||||
for tag in ['c:title', 'c:cat', 'c:val']:
|
||||
for parent_elem in chart_xml.iter(f'{{{ _NS_C }}}{tag}' if not tag.startswith('{') else tag):
|
||||
# Direct rich text: <a:rich><a:p><a:r><a:t>
|
||||
for t_elem in parent_elem.iter(f'{{{_NS_A}}}t'):
|
||||
if t_elem.text and t_elem.text.strip():
|
||||
# Store reference for setter
|
||||
entry = {
|
||||
'chart_file': chart_file,
|
||||
'element_path': self._get_element_path(t_elem),
|
||||
'original': t_elem.text.strip(),
|
||||
}
|
||||
chart_translations.append(entry)
|
||||
|
||||
def make_chart_setter(entries, idx):
|
||||
def setter(text):
|
||||
entries[idx]['translated'] = text.strip()
|
||||
return setter
|
||||
|
||||
text_elements.append(
|
||||
(t_elem.text.strip(), make_chart_setter(chart_translations, len(chart_translations) - 1))
|
||||
)
|
||||
|
||||
# Series names in <c:ser><c:tx><c:strRef><c:f> or <c:v>
|
||||
for ser_elem in chart_xml.iter(f'{{{_NS_C}}}ser'):
|
||||
for v_elem in ser_elem.iter(f'{{{_NS_C}}}v'):
|
||||
if v_elem.text and v_elem.text.strip() and not v_elem.text.strip().replace('.', '').replace('-', '').isdigit():
|
||||
entry = {
|
||||
'chart_file': chart_file,
|
||||
'element_path': self._get_element_path(v_elem),
|
||||
'original': v_elem.text.strip(),
|
||||
}
|
||||
chart_translations.append(entry)
|
||||
|
||||
def make_chart_val_setter(entries, idx):
|
||||
def setter(text):
|
||||
entries[idx]['translated'] = text.strip()
|
||||
return setter
|
||||
|
||||
text_elements.append(
|
||||
(v_elem.text.strip(), make_chart_val_setter(chart_translations, len(chart_translations) - 1))
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_log_error("word_chart_parse_error", chart_file=chart_file, error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
_log_error("word_charts_zip_error", error=str(e))
|
||||
|
||||
def _get_element_path(self, element) -> str:
|
||||
"""Get a unique XPath-like path for an element within its document."""
|
||||
path_parts = []
|
||||
current = element
|
||||
while current is not None:
|
||||
parent = current.getparent()
|
||||
if parent is None:
|
||||
break
|
||||
idx = list(parent).index(current)
|
||||
tag = current.tag.split('}')[-1] if '}' in current.tag else current.tag
|
||||
path_parts.append(f"{tag}[{idx}]")
|
||||
current = parent
|
||||
return '/'.join(reversed(path_parts))
|
||||
|
||||
def _apply_chart_translations(self, input_path: Path, output_path: Path, chart_translations: List[Dict[str, Any]]) -> None:
|
||||
"""Re-inject chart translations into the .docx ZIP.
|
||||
|
||||
Modifies chart XML files in-place and rewrites the ZIP.
|
||||
"""
|
||||
if not chart_translations:
|
||||
return
|
||||
|
||||
# Only proceed if at least one translation exists
|
||||
translated_entries = [e for e in chart_translations if 'translated' in e and e['translated']]
|
||||
if not translated_entries:
|
||||
return
|
||||
|
||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||
|
||||
# Group by chart file
|
||||
chart_files_to_update: Dict[str, List[Dict]] = {}
|
||||
for entry in translated_entries:
|
||||
cf = entry['chart_file']
|
||||
if cf not in chart_files_to_update:
|
||||
chart_files_to_update[cf] = []
|
||||
chart_files_to_update[cf].append(entry)
|
||||
|
||||
try:
|
||||
# Read all ZIP entries
|
||||
with zipfile.ZipFile(output_path, 'r') as zf_in:
|
||||
existing_entries = zf_in.namelist()
|
||||
|
||||
# Create new ZIP in memory
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf_out:
|
||||
for item in existing_entries:
|
||||
data = zf_in.read(item)
|
||||
|
||||
if item in chart_files_to_update:
|
||||
# Parse, update, re-serialize this chart XML
|
||||
try:
|
||||
chart_xml = etree.fromstring(data)
|
||||
|
||||
for entry in chart_files_to_update[item]:
|
||||
# Find all <a:t> or <c:v> elements and match by original text
|
||||
tag_to_find = f'{{{_NS_A}}}t'
|
||||
# Try both a:t and c:v
|
||||
for t_elem in chart_xml.iter(tag_to_find):
|
||||
if t_elem.text and t_elem.text.strip() == entry['original']:
|
||||
t_elem.text = entry['translated']
|
||||
break
|
||||
else:
|
||||
for t_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
|
||||
if t_elem.text and t_elem.text.strip() == entry['original']:
|
||||
t_elem.text = entry['translated']
|
||||
break
|
||||
|
||||
data = etree.tostring(chart_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
except Exception as e:
|
||||
_log_error("word_chart_update_error", chart_file=item, error=str(e))
|
||||
|
||||
zf_out.writestr(item, data)
|
||||
|
||||
# Replace the output file with the updated ZIP
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(buf.getvalue())
|
||||
|
||||
_log_info("word_charts_translated", chart_files=len(chart_files_to_update), translations=len(translated_entries))
|
||||
|
||||
except Exception as e:
|
||||
_log_error("word_chart_zip_rewrite_error", error=str(e))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SmartArt / Diagram support
|
||||
# ------------------------------------------------------------------
|
||||
_NS_DGM = "http://schemas.openxmlformats.org/drawingml/2006/diagram"
|
||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
|
||||
def _collect_diagrams_from_zip(
|
||||
self,
|
||||
input_path: Path,
|
||||
text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
diagram_translations: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""Parse SmartArt diagram XML from the .docx ZIP and collect translatable text.
|
||||
|
||||
SmartArt text lives in ``word/diagrams/data*.xml`` inside the ZIP.
|
||||
Each diagram data file contains ``<dgm:pt>`` elements with ``<a:t>``
|
||||
text nodes.
|
||||
"""
|
||||
_TAG_A_T = f"{{{self._NS_A}}}t"
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(input_path, 'r') as zf:
|
||||
diag_files = [
|
||||
n for n in zf.namelist()
|
||||
if n.startswith('word/diagrams/data') and n.endswith('.xml')
|
||||
]
|
||||
|
||||
for diag_file in diag_files:
|
||||
try:
|
||||
diag_xml = etree.fromstring(zf.read(diag_file))
|
||||
|
||||
for t_elem in diag_xml.iter(_TAG_A_T):
|
||||
if t_elem.text and t_elem.text.strip():
|
||||
original = t_elem.text.strip()
|
||||
|
||||
# Skip numeric-only or very short tokens
|
||||
if original.replace('.', '').replace('-', '').replace(',', '').isdigit():
|
||||
continue
|
||||
if len(original) <= 1:
|
||||
continue
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
'diag_file': diag_file,
|
||||
'element_path': self._get_element_path(t_elem),
|
||||
'original': original,
|
||||
}
|
||||
diagram_translations.append(entry)
|
||||
|
||||
def _make_diag_setter(
|
||||
entries: List[Dict[str, Any]], idx: int
|
||||
):
|
||||
def setter(text: str) -> None:
|
||||
entries[idx]['translated'] = text.strip()
|
||||
return setter
|
||||
|
||||
text_elements.append(
|
||||
(original, _make_diag_setter(diagram_translations, len(diagram_translations) - 1))
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_log_error("word_diagram_parse_error", diag_file=diag_file, error=str(e))
|
||||
|
||||
if diagram_translations:
|
||||
_log_info(
|
||||
"word_diagram_collection",
|
||||
diagram_files=len(diag_files),
|
||||
text_count=len(diagram_translations),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_log_error("word_diagrams_zip_error", error=str(e))
|
||||
|
||||
def _apply_diagram_translations(
|
||||
self,
|
||||
output_path: Path,
|
||||
diagram_translations: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""Re-inject SmartArt/diagram translations into the .docx ZIP.
|
||||
|
||||
Modifies diagram data XML files in-place and rewrites the ZIP.
|
||||
"""
|
||||
if not diagram_translations:
|
||||
return
|
||||
|
||||
translated_entries = [e for e in diagram_translations if 'translated' in e and e['translated']]
|
||||
if not translated_entries:
|
||||
return
|
||||
|
||||
_TAG_A_T = f"{{{self._NS_A}}}t"
|
||||
|
||||
# Group by diagram file
|
||||
diag_files_to_update: Dict[str, List[Dict]] = {}
|
||||
for entry in translated_entries:
|
||||
df = entry['diag_file']
|
||||
if df not in diag_files_to_update:
|
||||
diag_files_to_update[df] = []
|
||||
diag_files_to_update[df].append(entry)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(output_path, 'r') as zf_in:
|
||||
existing_entries = zf_in.namelist()
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf_out:
|
||||
for item in existing_entries:
|
||||
data = zf_in.read(item)
|
||||
|
||||
if item in diag_files_to_update:
|
||||
try:
|
||||
diag_xml = etree.fromstring(data)
|
||||
|
||||
for entry in diag_files_to_update[item]:
|
||||
for t_elem in diag_xml.iter(_TAG_A_T):
|
||||
if t_elem.text and t_elem.text.strip() == entry['original']:
|
||||
t_elem.text = entry['translated']
|
||||
break
|
||||
|
||||
data = etree.tostring(diag_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
except Exception as e:
|
||||
_log_error("word_diagram_update_error", diag_file=item, error=str(e))
|
||||
|
||||
zf_out.writestr(item, data)
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(buf.getvalue())
|
||||
|
||||
_log_info(
|
||||
"word_diagrams_translated",
|
||||
diagram_files=len(diag_files_to_update),
|
||||
translations=len(translated_entries),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_log_error("word_diagram_zip_rewrite_error", error=str(e))
|
||||
|
||||
def _collect_from_paragraph(
|
||||
self,
|
||||
paragraph: Paragraph,
|
||||
@@ -464,27 +986,51 @@ class WordTranslator:
|
||||
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.
|
||||
|
||||
Handles runs both as direct children of <w:p> AND inside <w:hyperlink>
|
||||
elements (used for TOC entries, cross-references, and bookmarks links).
|
||||
"""
|
||||
if not paragraph.text.strip():
|
||||
# Check full paragraph text including nested content (hyperlinks, etc.)
|
||||
full_text = ''.join(
|
||||
t.text or '' for t in paragraph._p.iter(qn('w:t'))
|
||||
).strip()
|
||||
if not full_text:
|
||||
return
|
||||
|
||||
# Collect from direct child runs
|
||||
for run in paragraph.runs:
|
||||
if run.text and run.text.strip():
|
||||
original = run.text
|
||||
# Capture leading/trailing whitespace that must survive translation.
|
||||
leading = original[: len(original) - len(original.lstrip())]
|
||||
trailing = original[len(original.rstrip()) :]
|
||||
stripped = original.strip()
|
||||
self._append_run_translation(run, text_elements)
|
||||
|
||||
def make_setter(r, lead: str, trail: str):
|
||||
def setter(text: str) -> None:
|
||||
# Strip any whitespace the translator may have added/removed
|
||||
# and reapply the original boundary whitespace.
|
||||
r.text = lead + text.strip() + trail
|
||||
# Collect from runs inside <w:hyperlink> elements
|
||||
# (TOC entries, cross-references — python-docx's paragraph.runs skips these)
|
||||
for hl in paragraph._p.iter(qn('w:hyperlink')):
|
||||
for r_elem in hl.findall(qn('w:r')):
|
||||
run = Run(r_elem, paragraph)
|
||||
if run.text and run.text.strip():
|
||||
self._append_run_translation(run, text_elements)
|
||||
|
||||
return setter
|
||||
def _append_run_translation(
|
||||
self,
|
||||
run,
|
||||
text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||
) -> None:
|
||||
"""Extract translatable text from a Run and append a (text, setter) tuple."""
|
||||
original = run.text
|
||||
# Capture leading/trailing whitespace that must survive translation.
|
||||
leading = original[: len(original) - len(original.lstrip())]
|
||||
trailing = original[len(original.rstrip()) :]
|
||||
stripped = original.strip()
|
||||
|
||||
text_elements.append((stripped, make_setter(run, leading, trailing)))
|
||||
def make_setter(r, lead: str, trail: str):
|
||||
def setter(text: str) -> None:
|
||||
# Strip any whitespace the translator may have added/removed
|
||||
# and reapply the original boundary whitespace.
|
||||
r.text = lead + text.strip() + trail
|
||||
|
||||
return setter
|
||||
|
||||
text_elements.append((stripped, make_setter(run, leading, trailing)))
|
||||
|
||||
def _collect_from_table(
|
||||
self, table: Table, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||
|
||||
Reference in New Issue
Block a user