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:
@@ -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.
|
||||
|
||||
852
translators/pdf_translator.py
Normal file
852
translators/pdf_translator.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -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
168
translators/watermark.py
Normal 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)
|
||||
@@ -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