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:
2026-05-10 11:43:28 +02:00
parent 16ac7ca2b9
commit ce8e150a61
110 changed files with 6935 additions and 4301 deletions

View File

@@ -10,10 +10,13 @@ import re
import tempfile
import os
import time
import zipfile
import io
import concurrent.futures
from pathlib import Path
from typing import Dict, Set, List, Tuple, Optional, Callable, Any
from lxml import etree
from openpyxl import load_workbook
from openpyxl.worksheet.worksheet import Worksheet
from openpyxl.cell.cell import Cell
@@ -172,10 +175,13 @@ class ExcelTranslator:
text_elements: List[Tuple[str, Callable[[str], None]]] = []
sheet_names_to_translate = []
chart_translations: List[Dict[str, Any]] = []
for sheet_idx, sheet_name in enumerate(workbook.sheetnames):
worksheet = workbook[sheet_name]
self._collect_from_worksheet(worksheet, text_elements)
# Collect header/footer text
self._collect_from_header_footer(worksheet, text_elements)
sheet_names_to_translate.append(sheet_name)
# Emit progress after each sheet collection (ensures < 500ms latency)
@@ -193,6 +199,9 @@ class ExcelTranslator:
for sheet_name in sheet_names_to_translate:
text_elements.append((sheet_name, None))
# Collect chart text from ZIP
self._collect_charts_from_zip(input_path, text_elements, chart_translations)
if text_elements:
texts = [elem[0] for elem in text_elements]
total_texts = len(texts)
@@ -298,6 +307,10 @@ class ExcelTranslator:
details={"file_name": output_path.name, "error": str(e)},
)
# Re-inject chart translations into the .xlsx ZIP
if chart_translations:
self._apply_chart_translations(output_path, chart_translations)
workbook.close()
processing_time_ms = round((time.time() - start_time) * 1000, 2)
@@ -477,6 +490,159 @@ class ExcelTranslator:
text_elements.append((original_value, make_setter(cell)))
def _collect_from_header_footer(
self, worksheet: Worksheet, text_elements: List[Tuple[str, Callable[[str], None]]]
) -> None:
"""Collect text from worksheet headers and footers.
Headers/footers can contain text like "Page &P of &N" or "Confidential - &D".
We translate the static text portions, preserving the &X codes.
"""
for section in worksheet.oddHeader, worksheet.oddFooter, worksheet.evenHeader, worksheet.evenFooter, worksheet.firstHeader, worksheet.firstFooter:
if section is None:
continue
# openpyxl Header/Footer sections have .left, .center, .right attributes
for attr in ('left', 'center', 'right'):
text = getattr(section, attr, None)
if text and isinstance(text, str) and text.strip():
# Extract translatable text (remove &X codes for translation, keep structure)
import re as _re
# Split on &X codes (like &P, &N, &D, &F, &A, etc.)
parts = _re.split(r'(&[A-Za-z])', text)
for i, part in enumerate(parts):
if part and not part.startswith('&') and part.strip():
original = part.strip()
def make_hf_setter(sec, attribute, idx):
def setter(translated):
current = getattr(sec, attribute, '') or ''
parts_local = _re.split(r'(&[A-Za-z])', current)
if idx < len(parts_local):
parts_local[idx] = translated
setattr(sec, attribute, ''.join(parts_local))
return setter
text_elements.append((original, make_hf_setter(section, attr, i)))
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 .xlsx ZIP and collect translatable text."""
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
try:
with zipfile.ZipFile(input_path, 'r') as zf:
chart_files = [name for name in zf.namelist() if name.startswith('xl/charts/') and name.endswith('.xml')]
for chart_file in chart_files:
try:
chart_xml = etree.fromstring(zf.read(chart_file))
seen_texts: set = set()
# Collect from <a:t> elements (titles, axis labels, legend text)
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
if t_elem.text and t_elem.text.strip() and t_elem.text.strip() not in seen_texts:
seen_texts.add(t_elem.text.strip())
entry = {
'chart_file': chart_file,
'original': t_elem.text.strip(),
'translated': None,
}
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))
)
# Collect from <c:v> elements (category names, series names)
for v_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
text = v_elem.text
if text and text.strip() and not text.strip().replace('.', '').replace('-', '').replace(',', '').isdigit():
if text.strip() not in seen_texts:
seen_texts.add(text.strip())
entry = {
'chart_file': chart_file,
'original': text.strip(),
'translated': None,
}
chart_translations.append(entry)
def make_chart_v_setter(entries, idx):
def setter(text):
entries[idx]['translated'] = text.strip()
return setter
text_elements.append(
(text.strip(), make_chart_v_setter(chart_translations, len(chart_translations) - 1))
)
except Exception as e:
_log_error("excel_chart_parse_error", chart_file=chart_file, error=str(e))
except Exception as e:
_log_error("excel_charts_zip_error", error=str(e))
def _apply_chart_translations(self, output_path: Path, chart_translations: List[Dict[str, Any]]) -> None:
"""Re-inject chart translations into the .xlsx ZIP."""
if not chart_translations:
return
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"
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:
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 chart_files_to_update:
try:
chart_xml = etree.fromstring(data)
for entry in chart_files_to_update[item]:
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
if t_elem.text and t_elem.text.strip() == entry['original']:
t_elem.text = entry['translated']
break
else:
for v_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
if v_elem.text and v_elem.text.strip() == entry['original']:
v_elem.text = entry['translated']
break
data = etree.tostring(chart_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
except Exception as e:
_log_error("excel_chart_update_error", chart_file=item, error=str(e))
zf_out.writestr(item, data)
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
_log_info("excel_charts_translated", chart_files=len(chart_files_to_update), translations=len(translated_entries))
except Exception as e:
_log_error("excel_chart_zip_rewrite_error", error=str(e))
def _translate_images(self, worksheet: Worksheet, target_language: str) -> None:
"""
Translate text in images using vision model.

View File

@@ -0,0 +1,852 @@
"""
PDF Document Translation Module — Layout-Preserving
Primary strategy (layout mode):
Use PyMuPDF (fitz) for direct, in-place text replacement on each page.
For each page:
1. Extract text blocks with positions, fonts, sizes, colors
2. Translate each block as a unit (preserving context within block)
3. Redact original text area
4. Write translated text at the same position, auto-adjusting font size
This preserves:
- Page structure, images, vector graphics, backgrounds
- Text positions within original bounding boxes
- Approximate font styling (size, color)
Fallback:
If PyMuPDF direct editing fails, falls back to the pdf2docx pipeline
(PDF → DOCX → WordTranslator → PDF via LibreOffice).
Text-only mode:
Extract text, translate, generate a clean formatted PDF via reportlab.
"""
import time
import shutil
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional, Callable, List
from core.logging import get_logger
logger = get_logger(__name__)
# Minimum readable font size (points)
MIN_FONT_SIZE = 4.5
# Font size reduction factor when text overflows its bounding box
FONT_SHRINK_FACTOR = 0.87
# RTL language codes
RTL_LANGUAGES = frozenset({"ar", "he", "fa", "ur", "ku", "ps", "ug", "sd", "yi", "dv", "ckb"})
class PDFTranslator:
"""Translates PDF files with layout preservation using PyMuPDF."""
_FONT_SEARCH_PATHS = [
"/usr/share/fonts/opentype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSans.ttc",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans[Noto].ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/app/fonts/NotoSans-Regular.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/msyh.ttc",
"/System/Library/Fonts/Helvetica.ttc",
]
def __init__(self, provider=None):
self._provider = provider
self._font_path: Optional[str] = None
def _get_font_path(self) -> Optional[str]:
"""Resolve a Unicode-capable TTF/OTF font file."""
if self._font_path is not None:
return self._font_path
for p in self._FONT_SEARCH_PATHS:
if Path(p).exists():
self._font_path = p
return p
logger.warning("no_unicode_font_found")
return None
def translate_file(
self,
input_path: Path,
output_path: Path,
target_language: str,
source_language: str = "auto",
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
pdf_mode: str = "layout",
) -> Path:
input_path = Path(input_path)
output_path = Path(output_path)
self._validate_file(input_path)
if pdf_mode == "text_only":
return self._translate_text_only(
input_path, output_path, target_language, source_language, progress_callback
)
return self._translate_preserve_layout(
input_path, output_path, target_language, source_language, progress_callback
)
# ------------------------------------------------------------------ #
# LAYOUT MODE — PyMuPDF in-place text replacement
# ------------------------------------------------------------------ #
def _translate_preserve_layout(
self,
input_path: Path,
output_path: Path,
target_language: str,
source_language: str,
progress_callback,
) -> Path:
"""Translate PDF preserving layout via PyMuPDF direct text replacement."""
start_time = time.time()
try:
import fitz
except ImportError:
logger.warning("pymupdf_missing_fallback_docx")
return self._translate_preserve_layout_fallback(
input_path, output_path, target_language, source_language, progress_callback
)
doc = fitz.open(str(input_path))
total_pages = len(doc)
if total_pages == 0:
doc.close()
raise RuntimeError("PDF has no pages.")
font_path = self._get_font_path()
logger.info(
"pdf_layout_start",
pages=total_pages,
file=input_path.name,
font=font_path or "built-in",
)
if progress_callback:
progress_callback({
"current": 1, "total": total_pages,
"phase": "extracting",
"paragraph": 1, "total_paragraphs": total_pages,
})
try:
result_path = self._process_pages_inplace(
doc, total_pages, output_path,
target_language, source_language,
font_path, progress_callback,
)
processing_time_ms = round((time.time() - start_time) * 1000, 2)
logger.info(
"pdf_layout_success",
pages=total_pages,
processing_time_ms=processing_time_ms,
output=str(result_path),
)
return result_path
except Exception as e:
doc.close()
logger.warning("inplace_failed_fallback", error=str(e))
return self._translate_preserve_layout_fallback(
input_path, output_path, target_language, source_language, progress_callback
)
def _process_pages_inplace(
self,
doc,
total_pages: int,
output_path: Path,
target_language: str,
source_language: str,
font_path: Optional[str],
progress_callback,
) -> Path:
"""Core PyMuPDF in-place processing — one page at a time."""
import fitz
is_rtl = target_language.lower() in RTL_LANGUAGES
total_blocks = 0
translated_blocks = 0
for page_num in range(total_pages):
page = doc[page_num]
raw_blocks = self._extract_text_blocks(page)
if not raw_blocks:
if progress_callback:
pct = int(30 + 65 * (page_num + 1) / total_pages)
progress_callback({
"current": page_num + 1, "total": total_pages,
"phase": f"Page {page_num + 1}/{total_pages} (no text)",
"paragraph": page_num + 1,
"total_paragraphs": total_pages,
"progress_override": pct,
})
continue
# Merge adjacent blocks that form a single paragraph
blocks = self._merge_adjacent_blocks(raw_blocks, page.rect)
total_blocks += len(blocks)
# Phase 1: translate all blocks on this page
for block in blocks:
original = block["text"]
if not original.strip():
continue
try:
translated = self._translate_single(
original, target_language, source_language
)
if translated and translated.strip():
block["translated"] = translated
translated_blocks += 1
else:
logger.warning(
"block_translation_empty",
page=page_num + 1,
text_preview=original[:60],
)
except Exception as e:
logger.warning(
"block_translation_failed",
page=page_num + 1,
error=str(e),
)
# Phase 2: redact original text areas
for block in blocks:
if block.get("translated"):
for sub_rect in block["sub_bboxes"]:
page.add_redact_annot(fitz.Rect(sub_rect), fill=(1, 1, 1))
page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE)
# Phase 3: write translated text
for block in blocks:
if block.get("translated"):
self._write_translated_block(
page, block, font_path, is_rtl
)
if progress_callback:
pct = int(30 + 65 * (page_num + 1) / total_pages)
progress_callback({
"current": page_num + 1, "total": total_pages,
"phase": f"Translating page {page_num + 1}/{total_pages}",
"paragraph": page_num + 1,
"total_paragraphs": total_pages,
"progress_override": pct,
})
logger.info(
"pdf_blocks_processed",
total_blocks=total_blocks,
translated_blocks=translated_blocks,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(output_path), garbage=4, deflate=True)
doc.close()
return output_path
def _extract_text_blocks(self, page) -> List[Dict]:
"""Extract text blocks with position, font, and color information."""
import fitz
blocks = []
data = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
for block in data.get("blocks", []):
if block.get("type") != 0:
continue
lines = block.get("lines", [])
if not lines:
continue
line_parts = []
spans_info = []
for line in lines:
span_parts = []
for span in line.get("spans", []):
text = span.get("text", "")
if text:
span_parts.append(text)
spans_info.append({
"size": span.get("size", 12),
"font": span.get("font", "Helvetica"),
"color": span.get("color", 0),
"flags": span.get("flags", 0),
"origin": span.get("origin", (0, 0)),
})
if span_parts:
line_parts.append("".join(span_parts))
full_text = "\n".join(line_parts).strip()
if not full_text:
continue
avg_size = (
sum(s["size"] for s in spans_info) / len(spans_info)
if spans_info
else 12.0
)
first_color = spans_info[0]["color"] if spans_info else 0
is_bold = any(s["flags"] & 16 for s in spans_info)
is_italic = any(s["flags"] & 2 for s in spans_info)
blocks.append({
"bbox": tuple(block["bbox"]),
"text": full_text,
"font_size": round(avg_size, 1),
"color": first_color,
"is_bold": is_bold,
"is_italic": is_italic,
"line_count": len(line_parts),
"translated": None,
"sub_bboxes": [tuple(block["bbox"])],
})
return blocks
def _merge_adjacent_blocks(
self, blocks: List[Dict], page_rect
) -> List[Dict]:
"""Merge consecutive text blocks that form a single paragraph.
Blocks are merged when they:
- Have the same (or very close) font size
- Are vertically adjacent (gap < 1.5× line height)
- Have the same x-origin (left-aligned) or same width
This produces larger bounding boxes for better translation context
and prevents excessive font-size reduction for multi-line paragraphs.
"""
if len(blocks) <= 1:
return blocks
merged = []
current = dict(blocks[0])
for next_block in blocks[1:]:
should_merge = self._should_merge_blocks(current, next_block)
if should_merge:
# Merge: combine text and expand bounding box
current["text"] += "\n" + next_block["text"]
current["line_count"] += next_block["line_count"]
# Expand bbox to cover both
x0 = min(current["bbox"][0], next_block["bbox"][0])
y0 = min(current["bbox"][1], next_block["bbox"][1])
x1 = max(current["bbox"][2], next_block["bbox"][2])
y1 = max(current["bbox"][3], next_block["bbox"][3])
current["bbox"] = (x0, y0, x1, y1)
current["sub_bboxes"].extend(next_block["sub_bboxes"])
else:
merged.append(current)
current = dict(next_block)
merged.append(current)
return merged
def _should_merge_blocks(self, a: Dict, b: Dict) -> bool:
"""Check if two blocks should be merged into one paragraph."""
a_bbox = a["bbox"]
b_bbox = b["bbox"]
# Must have similar font size (within 20%)
if abs(a["font_size"] - b["font_size"]) > max(a["font_size"], b["font_size"]) * 0.2:
return False
# Block b must start soon after block a ends vertically
vertical_gap = b_bbox[1] - a_bbox[3]
line_height = a["font_size"] * 1.4
if vertical_gap < 0 or vertical_gap > line_height * 1.5:
return False
# Similar horizontal position (within 15pt)
if abs(a_bbox[0] - b_bbox[0]) > 15:
return False
# Don't merge if widths are very different (likely different columns)
a_width = a_bbox[2] - a_bbox[0]
b_width = b_bbox[2] - b_bbox[0]
if a_width > 0 and abs(b_width - a_width) / a_width > 0.5:
return False
return True
def _write_translated_block(
self,
page,
block: Dict,
font_path: Optional[str],
is_rtl: bool,
) -> None:
"""Write translated text into the block's bounding box.
Priority: respect original font size as much as possible.
Strategy:
1. Try original rect at original font size.
2. Expand bbox to page margins (same font size).
3. Expand bbox vertically downward (same font size).
4. Only THEN shrink font as a last resort, with a floor of 70% original.
"""
import fitz
original_rect = fitz.Rect(block["bbox"])
translated = block["translated"]
target_size = block["font_size"]
color = self._int_to_rgb(block["color"])
align = fitz.TEXT_ALIGN_RIGHT if is_rtl else fitz.TEXT_ALIGN_LEFT
fontname = None
fontfile = font_path
# Step 1: original rect, original size
size = target_size
rc = self._try_insert(page, original_rect, translated, size, fontname, fontfile, color, align)
if rc is not None and rc >= 0:
return
# Step 2: expand to page margins (horizontal)
page_rect = page.rect
margin = 18
expanded_h = fitz.Rect(
max(original_rect.x0, page_rect.x0 + margin),
original_rect.y0,
min(original_rect.x1, page_rect.x1 - margin),
original_rect.y1,
)
if expanded_h.width > original_rect.width:
rc = self._try_insert(page, expanded_h, translated, size, fontname, fontfile, color, align)
if rc is not None and rc >= 0:
return
# Step 3: expand vertically (allow text to flow down)
max_expand_y = min(page_rect.y1 - margin - original_rect.y1, original_rect.height * 1.5)
expanded = fitz.Rect(
expanded_h.x0,
expanded_h.y0,
expanded_h.x1,
expanded_h.y1 + max_expand_y,
)
if expanded.height > expanded_h.height:
rc = self._try_insert(page, expanded, translated, size, fontname, fontfile, color, align)
if rc is not None and rc >= 0:
return
# Step 4: shrink font — but never below 70% of original
min_size = max(target_size * 0.70, MIN_FONT_SIZE)
rect = expanded
for attempt in range(8):
size *= FONT_SHRINK_FACTOR
if size < min_size:
size = min_size
rc = self._try_insert(page, rect, translated, size, fontname, fontfile, color, align)
break
rc = self._try_insert(page, rect, translated, size, fontname, fontfile, color, align)
if rc is not None and rc >= 0:
return
# Last resort
if rc is None or rc < 0:
try:
page.insert_textbox(
rect,
translated,
fontsize=min_size,
fontname=fontname or "helv",
fontfile=fontfile,
color=color,
align=align,
overlay=True,
)
except Exception as e:
logger.warning("textbox_final_failed", error=str(e))
def _try_insert(
self, page, rect, text, fontsize, fontname, fontfile, color, align
):
"""Attempt insert_textbox, returns rc or None on error."""
try:
return page.insert_textbox(
rect,
text,
fontsize=fontsize,
fontname=fontname,
fontfile=fontfile,
color=color,
align=align,
overlay=True,
)
except Exception:
return None
@staticmethod
def _int_to_rgb(color_int: int) -> tuple:
"""Convert integer color (0xRRGGBB) to (r, g, b) float tuple."""
r = ((color_int >> 16) & 0xFF) / 255.0
g = ((color_int >> 8) & 0xFF) / 255.0
b = (color_int & 0xFF) / 255.0
return (r, g, b)
# ------------------------------------------------------------------ #
# FALLBACK — pdf2docx → WordTranslator → LibreOffice
# ------------------------------------------------------------------ #
def _translate_preserve_layout_fallback(
self,
input_path: Path,
output_path: Path,
target_language: str,
source_language: str,
progress_callback,
) -> Path:
"""Fallback: PDF → DOCX (pdf2docx) → WordTranslator → PDF (LibreOffice)."""
start_time = time.time()
try:
if progress_callback:
progress_callback({
"current": 1, "total": 3,
"phase": "converting",
"paragraph": 1, "total_paragraphs": 3,
})
docx_path = self._convert_pdf_to_docx(input_path)
if progress_callback:
progress_callback({
"current": 2, "total": 3,
"phase": "translating",
"paragraph": 2, "total_paragraphs": 3,
})
from translators.word_translator import WordTranslator
translated_docx = output_path.with_suffix(".docx")
wt = WordTranslator(provider=self._provider)
wt.translate_file(
docx_path, translated_docx,
target_language, source_language,
progress_callback=None,
)
if progress_callback:
progress_callback({
"current": 3, "total": 3,
"phase": "converting_back",
"paragraph": 3, "total_paragraphs": 3,
})
final_path = self._convert_docx_to_pdf(translated_docx, output_path)
for tmp in [docx_path, translated_docx]:
if tmp.exists() and tmp != final_path:
try:
tmp.unlink()
except Exception:
pass
processing_time_ms = round((time.time() - start_time) * 1000, 2)
logger.info(
"pdf_layout_fallback_success",
file_name=input_path.name,
processing_time_ms=processing_time_ms,
output=str(final_path),
)
return final_path
except Exception as e:
logger.error("pdf_layout_fallback_error", file=str(input_path), error=str(e))
raise
def _convert_pdf_to_docx(self, pdf_path: Path) -> Path:
"""Convert PDF to DOCX using pdf2docx."""
try:
from pdf2docx import Converter
except ImportError:
raise RuntimeError("pdf2docx is not installed")
docx_path = pdf_path.with_suffix(".docx")
cv = Converter(str(pdf_path))
try:
cv.convert(str(docx_path))
finally:
cv.close()
if not docx_path.exists() or docx_path.stat().st_size == 0:
raise RuntimeError("PDF conversion produced empty output")
from docx import Document
doc = Document(str(docx_path))
total_text = "".join(p.text for p in doc.paragraphs).strip()
if not total_text:
raise RuntimeError("PDF appears to be scanned or contains only images")
logger.info("pdf_converted_to_docx", pages=len(doc.paragraphs))
return docx_path
def _convert_docx_to_pdf(self, docx_path: Path, target_pdf: Path) -> Path:
"""Convert DOCX → PDF using LibreOffice headless."""
try:
result = subprocess.run(
[
"libreoffice", "--headless", "--convert-to", "pdf",
"--outdir", str(target_pdf.parent),
str(docx_path),
],
capture_output=True,
text=True,
timeout=120,
)
expected_pdf = docx_path.with_suffix(".pdf")
if expected_pdf.exists() and expected_pdf.stat().st_size > 0:
if expected_pdf != target_pdf:
shutil.move(str(expected_pdf), str(target_pdf))
logger.info("docx_to_pdf_success")
return target_pdf
logger.warning("docx_to_pdf_no_output", stderr=result.stderr)
except FileNotFoundError:
logger.warning("libreoffice_not_found")
except subprocess.TimeoutExpired:
logger.warning("libreoffice_timeout")
except Exception as e:
logger.warning("docx_to_pdf_failed", error=str(e))
docx_output = target_pdf.with_suffix(".docx")
if docx_path != docx_output and docx_path.exists():
shutil.move(str(docx_path), str(docx_output))
return docx_output
# ------------------------------------------------------------------ #
# MODE: text_only — extract text, translate, clean PDF output
# ------------------------------------------------------------------ #
def _translate_text_only(
self,
input_path: Path,
output_path: Path,
target_language: str,
source_language: str,
progress_callback,
) -> Path:
"""Extract text from PDF, translate, output as a clean formatted PDF."""
import fitz
start_time = time.time()
doc = fitz.open(str(input_path))
total_pages = len(doc)
if total_pages == 0:
doc.close()
raise RuntimeError("PDF has no pages.")
logger.info("pdf_text_only_start", pages=total_pages, file=input_path.name)
pages_text = []
for page_num in range(total_pages):
page = doc[page_num]
text = page.get_text("text").strip()
pages_text.append(text)
doc.close()
non_empty_indices = [i for i, t in enumerate(pages_text) if t]
if progress_callback:
progress_callback({
"current": 1, "total": 3,
"phase": "translating",
"paragraph": 1, "total_paragraphs": 3,
})
translated_pages = list(pages_text)
for seq, page_idx in enumerate(non_empty_indices):
text = pages_text[page_idx]
if not text.strip():
continue
try:
translated = self._translate_single(text, target_language, source_language)
if translated and translated.strip():
translated_pages[page_idx] = translated
else:
logger.warning("page_translation_empty", page=page_idx + 1)
except Exception as e:
logger.warning("page_translation_failed", page=page_idx + 1, error=str(e))
if progress_callback:
pct = int(30 + 60 * (seq + 1) / len(non_empty_indices))
progress_callback({
"current": seq + 1,
"total": len(non_empty_indices),
"phase": f"Translating page {page_idx + 1}/{total_pages}",
"paragraph": seq + 1,
"total_paragraphs": len(non_empty_indices),
"progress_override": pct,
})
final_path = output_path.with_suffix(".pdf")
self._generate_clean_pdf(translated_pages, final_path, target_language)
processing_time_ms = round((time.time() - start_time) * 1000, 2)
logger.info(
"pdf_text_only_success",
file_name=input_path.name,
pages=total_pages,
processing_time_ms=processing_time_ms,
)
return final_path
def _generate_clean_pdf(
self, pages_text: List[str], output_path: Path, target_language: str = "en"
) -> None:
"""Generate a clean, well-formatted PDF from translated page texts."""
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib.enums import TA_LEFT, TA_JUSTIFY, TA_RIGHT
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.lib.styles import getSampleStyleSheet
is_rtl = target_language.lower() in RTL_LANGUAGES
alignment = TA_RIGHT if is_rtl else TA_JUSTIFY
styles = getSampleStyleSheet()
pdf_doc = SimpleDocTemplate(
str(output_path),
pagesize=A4,
leftMargin=25 * mm,
rightMargin=25 * mm,
topMargin=25 * mm,
bottomMargin=25 * mm,
)
body_style = ParagraphStyle(
"BodyText_Custom",
parent=styles["Normal"],
fontSize=11,
leading=16,
spaceAfter=6,
alignment=alignment,
textColor=colors.HexColor("#1a1a1a"),
)
page_number_style = ParagraphStyle(
"PageNumber",
parent=styles["Normal"],
fontSize=9,
textColor=colors.HexColor("#999999"),
alignment=TA_LEFT,
)
elements = []
for i, page_text in enumerate(pages_text):
if not page_text.strip():
continue
if len(pages_text) > 1:
elements.append(Paragraph(f"— Page {i + 1}", page_number_style))
elements.append(Spacer(1, 8))
for para_text in page_text.split("\n"):
para_text = para_text.strip()
if not para_text:
elements.append(Spacer(1, 4))
continue
safe = (
para_text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
try:
elements.append(Paragraph(safe, body_style))
except Exception:
elements.append(
Paragraph(
para_text.encode("ascii", "replace").decode(),
body_style,
)
)
if i < len(pages_text) - 1:
elements.append(PageBreak())
if not elements:
raise RuntimeError("No text content to generate PDF")
pdf_doc.build(elements)
# ------------------------------------------------------------------ #
# Shared helpers
# ------------------------------------------------------------------ #
def _translate_single(
self, text: str, target_language: str, source_language: str
) -> str:
"""Translate a single text string."""
if self._provider is not None:
try:
result = self._provider.translate(text, target_language, source_language)
if result and result.strip():
return result
except Exception as e:
logger.warning("provider_single_failed", error=str(e))
from services.translation_service import translation_service
try:
return translation_service.translate_text(text, target_language, source_language)
except Exception as e:
logger.warning("legacy_single_failed", error=str(e))
return text
def _translate_batch(
self, texts: List[str], target_language: str, source_language: str
) -> List[str]:
"""Translate a batch of texts."""
if self._provider is not None:
try:
return self._provider.translate_batch(texts, target_language, source_language)
except Exception as e:
logger.warning("provider_translate_failed", error=str(e))
from services.translation_service import translation_service
try:
return translation_service.translate_batch(texts, target_language, source_language)
except Exception as e:
logger.warning("legacy_translate_failed", error=str(e))
return texts
def _validate_file(self, file_path: Path) -> None:
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path.name}")
if file_path.suffix.lower() != ".pdf":
raise ValueError(f"Expected .pdf file, got {file_path.suffix}")
with open(file_path, "rb") as f:
header = f.read(5)
if header[:4] != b"%PDF":
raise ValueError("File does not appear to be a valid PDF.")
pdf_translator = PDFTranslator()

View File

@@ -7,6 +7,8 @@ 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
@@ -313,6 +315,9 @@ class PowerPointTranslator:
details={"file_name": output_path.name, "error": str(e)},
)
# Re-inject chart translations into chart XML parts
self._apply_chart_translations(output_path)
processing_time_ms = round((time.time() - start_time) * 1000, 2)
_log_info(
@@ -439,6 +444,10 @@ class PowerPointTranslator:
for sub_shape in shape.shapes:
self._collect_from_shape(sub_shape, text_elements)
# Chart shapes — text is stored in separate chart XML parts
if shape.shape_type == MSO_SHAPE_TYPE.CHART:
self._collect_from_chart_shape(shape, text_elements)
if hasattr(shape, "shapes"):
try:
for sub_shape in shape.shapes:
@@ -446,6 +455,82 @@ class PowerPointTranslator:
except Exception:
pass
def _collect_from_chart_shape(
self, shape: BaseShape, text_elements: List[Tuple[str, Callable[[str], None]]]
) -> None:
"""Collect translatable text from a chart shape.
Chart text (title, axis titles, series names, data labels) is stored
in a separate chart XML part, not in shape.text_frame.
"""
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
try:
chart_data = shape.chart
# Access the chart XML part through the chart's part
chart_part = chart_data.part
chart_xml = etree.fromstring(chart_part.blob)
# Collect text from <a:t> elements in chart XML
# These include: chart title, axis titles, legend entries, data labels
seen_texts: set = set()
chart_text_entries: List[Dict[str, Any]] = []
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
text = t_elem.text
if text and text.strip() and text.strip() not in seen_texts:
seen_texts.add(text.strip())
entry = {
'element': t_elem,
'original': text.strip(),
'translated': None,
}
chart_text_entries.append(entry)
def make_chart_setter(entries, idx):
def setter(translated_text):
entries[idx]['translated'] = translated_text.strip()
return setter
text_elements.append(
(text.strip(), make_chart_setter(chart_text_entries, len(chart_text_entries) - 1))
)
# Also collect from <c:v> (cell values used as category names)
for v_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
text = v_elem.text
if text and text.strip() and not text.strip().replace('.', '').replace('-', '').replace(',', '').isdigit():
if text.strip() not in seen_texts:
seen_texts.add(text.strip())
entry = {
'element': v_elem,
'original': text.strip(),
'translated': None,
}
chart_text_entries.append(entry)
def make_chart_v_setter(entries, idx):
def setter(translated_text):
entries[idx]['translated'] = translated_text.strip()
return setter
text_elements.append(
(text.strip(), make_chart_v_setter(chart_text_entries, len(chart_text_entries) - 1))
)
# Store chart_part reference and entries for later re-injection
if chart_text_entries:
if not hasattr(self, '_chart_entries'):
self._chart_entries = []
self._chart_entries.append({
'chart_part': chart_part,
'entries': chart_text_entries,
})
except Exception as e:
_log_error("pptx_chart_collect_error", error=str(e))
def _collect_from_text_frame(
self, text_frame, text_elements: List[Tuple[str, Callable[[str], None]]]
) -> None:
@@ -472,5 +557,53 @@ class PowerPointTranslator:
text_elements.append((stripped, make_setter(run, leading, trailing)))
def _apply_chart_translations(self, output_path: Path) -> None:
"""Re-inject chart text translations by modifying chart XML parts in the .pptx ZIP."""
if not hasattr(self, '_chart_entries') or not self._chart_entries:
return
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
total_translated = 0
for chart_data in self._chart_entries:
entries = chart_data['entries']
chart_part = chart_data['chart_part']
translated_entries = [e for e in entries if e.get('translated')]
if not translated_entries:
continue
try:
chart_xml = etree.fromstring(chart_part.blob)
for entry in translated_entries:
# Try to find and update <a:t> elements
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
if t_elem.text and t_elem.text.strip() == entry['original']:
t_elem.text = entry['translated']
total_translated += 1
break
else:
# Try <c:v> elements
for v_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
if v_elem.text and v_elem.text.strip() == entry['original']:
v_elem.text = entry['translated']
total_translated += 1
break
# Update the chart part blob
chart_part._blob = etree.tostring(chart_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
except Exception as e:
_log_error("pptx_chart_update_error", error=str(e))
# Clean up
self._chart_entries = []
if total_translated > 0:
_log_info("pptx_charts_translated", total=total_translated)
pptx_translator = PowerPointTranslator()

168
translators/watermark.py Normal file
View File

@@ -0,0 +1,168 @@
"""
Watermark module for Free-tier output files.
Strategy: Add a subtle, professional footer/header that:
- Clearly marks the file as "Free plan" output
- Doesn't destroy the document's usability
- Motivates upgrade without frustrating the user
DOCX: Footer with light-gray text + link
PPTX: Small text box bottom-right on each slide
XLSX: Footer text on each sheet
"""
from pathlib import Path
from typing import Optional
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.enum.text import WD_ALIGN_PARAGRAPH
from core.logging import get_logger
logger = get_logger(__name__)
WATERMARK_TEXT = "Translated with Office Translator — Free Plan"
WATERMARK_URL = "wordly.art"
WATERMARK_COLOR = "B0B0B0" # Light gray
def add_watermark_docx(output_path: Path) -> None:
"""Add a subtle footer watermark to a DOCX file."""
try:
document = Document(str(output_path))
for section in document.sections:
# Enable footer
footer = section.footer
footer.is_linked_to_previous = False
if not footer.paragraphs:
footer.add_paragraph()
para = footer.paragraphs[0]
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Clear existing content
for run in para.runs:
run.clear()
# Add watermark run
run = para.add_run(WATERMARK_TEXT)
run.font.size = 8 # Pt — small
run.font.color.rgb = None # Will set via XML
# Set color via XML (more reliable)
rPr = run._r.get_or_add_rPr()
color_elem = OxmlElement("w:color")
color_elem.set(qn("w:val"), WATERMARK_COLOR)
rPr.append(color_elem)
document.save(str(output_path))
logger.info("watermark_added", file=str(output_path))
except Exception as e:
logger.error("watermark_docx_error", error=str(e))
# Don't fail the whole translation if watermark fails
def add_watermark_pptx(output_path: Path) -> None:
"""Add a small watermark text box to each slide."""
try:
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
prs = Presentation(str(output_path))
for slide in prs.slides:
# Add text box at bottom-right
left = Inches(5.5)
top = Inches(7.0)
width = Inches(3.5)
height = Inches(0.4)
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.RIGHT
run = p.add_run()
run.text = WATERMARK_TEXT
run.font.size = Pt(7)
run.font.color.rgb = RGBColor(0xB0, 0xB0, 0xB0)
prs.save(str(output_path))
logger.info("watermark_added_pptx", file=str(output_path))
except Exception as e:
logger.error("watermark_pptx_error", error=str(e))
def add_watermark_xlsx(output_path: Path) -> None:
"""Add a footer watermark to each sheet."""
try:
from openpyxl import load_workbook
from openpyxl.worksheet.header_footer import HeaderFooter
wb = load_workbook(str(output_path))
for ws in wb.worksheets:
if ws.sheet_properties.pageSetUpPr is None:
from openpyxl.worksheet.properties import PageSetupProperties
ws.sheet_properties.pageSetUpPr = PageSetupProperties()
ws.oddFooter.center.text = WATERMARK_TEXT
ws.oddFooter.center.font = "Arial,Regular"
ws.oddFooter.center.size = 8
wb.save(str(output_path))
wb.close()
logger.info("watermark_added_xlsx", file=str(output_path))
except Exception as e:
logger.error("watermark_xlsx_error", error=str(e))
def add_watermark_pdf(output_path: Path) -> None:
"""Add a subtle footer watermark on every page of a PDF using PyMuPDF."""
try:
import fitz
doc = fitz.open(str(output_path))
for page in doc:
rect = page.rect
# Position at bottom center
text_point = fitz.Point(
rect.width / 2 - 100,
rect.height - 15
)
page.insert_text(
text_point,
WATERMARK_TEXT,
fontsize=7,
color=(0.69, 0.69, 0.69), # #B0B0B0
fontname="helv",
)
doc.save(str(output_path), incremental=True, encryption=0)
doc.close()
logger.info("watermark_added_pdf", file=str(output_path))
except Exception as e:
logger.error("watermark_pdf_error", error=str(e))
def add_watermark(output_path: Path, file_extension: str) -> None:
"""Apply watermark based on file type."""
ext = file_extension.lower()
if ext == ".docx":
add_watermark_docx(output_path)
elif ext == ".pptx":
add_watermark_pptx(output_path)
elif ext == ".xlsx":
add_watermark_xlsx(output_path)
elif ext == ".pdf":
add_watermark_pdf(output_path)
else:
logger.info("watermark_skipped", extension=ext)

View File

@@ -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]]]