Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 2m14s
Foundations:
- TranslationSegment model + migration f7e8d9c0b1a2 (segments, workspaces,
workspace_members, glossaries.workspace_id)
- SegmentRecorder injected into all 4 translators: unique (source,
translation) pairs captured per job and persisted (best-effort)
- set_segment_overrides: human-reviewed translations applied verbatim on
rebuild — top priority over TM and provider, zero API calls
Review API (routes/review_routes.py):
- GET /translations/{id}/segments (owner or job token)
- PATCH /segments/{id} edit/approve — feeds the per-user TM so approved
translations are reused in later jobs
- POST /translations/{id}/rebuild — rebuild document with reviewed text
- GET/POST /translations/{id}/xliff — XLIFF 1.2 export/import (edited
segments export their reviewed text)
Review editor (frontend /dashboard/reviews/[jobId]):
- side-by-side source/translation table, inline edit, approve (single or
all), rebuild & download (auth blob), XLIFF export/import, 13 locales
- 'Relire et corriger' link on the translation-complete screen
Team workspaces (routes/workspace_routes.py + /dashboard/teams):
- Workspace/WorkspaceMember models, roles owner/admin/member
- create (Business plan), list with seat usage, invite by email with
seat-limit enforcement (Business=5, Enterprise unlimited), removal
- shared glossaries: workspace members can use a glossary shared to their
workspace (access check extended)
Tests: 1184 passed / 0 failed (11 new: recorder, overrides, docx
capture->rebuild e2e, XLIFF structure/escaping, seats, workspace CRUD,
shared glossary access)
1613 lines
66 KiB
Python
1613 lines
66 KiB
Python
"""
|
|
Word Document Translation Module
|
|
Translates Word files while preserving all formatting, styles, tables, and images
|
|
OPTIMIZED: Uses batch translation for 5-10x faster processing
|
|
|
|
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
|
|
|
|
# Languages written right-to-left
|
|
RTL_LANGUAGES: frozenset = frozenset(
|
|
{"ar", "he", "fa", "ur", "ku", "ps", "ug", "sd", "yi", "dv", "ckb"}
|
|
)
|
|
|
|
# East-Asian / complex-script font hints: when the target language uses
|
|
# glyphs a Latin theme font lacks, Word falls back to a substitute —
|
|
# setting the eastAsia (CJK) or cs (Arabic script) typeface keeps the
|
|
# rendering consistent across runs.
|
|
CJK_EASTASIA_FONTS: dict = {
|
|
"zh": "SimSun",
|
|
"zh-CN": "SimSun",
|
|
"zh-TW": "PMingLiU",
|
|
"ja": "Yu Mincho",
|
|
"ko": "Batang",
|
|
}
|
|
CS_FONTS: dict = {
|
|
"ar": "Arial",
|
|
"he": "Arial",
|
|
"fa": "Arial",
|
|
"ur": "Arial",
|
|
}
|
|
|
|
|
|
def _font_hints_for_target(target_language: str):
|
|
"""(eastAsia_font, cs_font) hints for the target language, if any."""
|
|
code = (target_language or "").strip()
|
|
base = code.split("-")[0].lower()
|
|
return CJK_EASTASIA_FONTS.get(code) or CJK_EASTASIA_FONTS.get(base), CS_FONTS.get(base)
|
|
|
|
|
|
def _apply_font_hints(document: Document, target_language: str) -> None:
|
|
"""Set eastAsia/cs typeface hints on every run for CJK/Arabic targets.
|
|
|
|
Blanket application is safe: the hint only affects the glyphs of that
|
|
script, which Latin text does not contain.
|
|
"""
|
|
eastasia, cs = _font_hints_for_target(target_language)
|
|
if not eastasia and not cs:
|
|
return
|
|
|
|
runs = []
|
|
for para in document.paragraphs:
|
|
runs.extend(para.runs)
|
|
for table in document.tables:
|
|
for row in table.rows:
|
|
for cell in row.cells:
|
|
for para in cell.paragraphs:
|
|
runs.extend(para.runs)
|
|
for section in document.sections:
|
|
for hf in (section.header, section.footer):
|
|
for para in hf.paragraphs:
|
|
runs.extend(para.runs)
|
|
|
|
hinted = 0
|
|
for run in runs:
|
|
rPr = run._r.get_or_add_rPr()
|
|
rFonts = rPr.find(qn("w:rFonts"))
|
|
if rFonts is None:
|
|
rFonts = OxmlElement("w:rFonts")
|
|
rPr.insert(0, rFonts)
|
|
if eastasia and not rFonts.get(qn("w:eastAsia")):
|
|
rFonts.set(qn("w:eastAsia"), eastasia)
|
|
hinted += 1
|
|
if cs and not rFonts.get(qn("w:cs")):
|
|
rFonts.set(qn("w:cs"), cs)
|
|
hinted += 1
|
|
|
|
if hinted:
|
|
from core.logging import get_logger as _gl
|
|
_gl(__name__).info("word_font_hints_applied", runs=hinted, eastasia=eastasia, cs=cs)
|
|
|
|
|
|
from core.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
_HAS_STRUCTLOG = True
|
|
|
|
|
|
def _log_info(event: str, **kwargs):
|
|
"""Log info with structlog or standard logging compatibility."""
|
|
if _HAS_STRUCTLOG:
|
|
logger.info(event, **kwargs)
|
|
else:
|
|
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
|
|
logger.info(msg)
|
|
|
|
|
|
def _log_error(event: str, **kwargs):
|
|
"""Log error with structlog or standard logging compatibility."""
|
|
if _HAS_STRUCTLOG:
|
|
logger.error(event, **kwargs)
|
|
else:
|
|
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
|
|
logger.error(msg)
|
|
|
|
|
|
def _set_paragraph_rtl(paragraph: Paragraph) -> None:
|
|
"""
|
|
Enable RTL mode on a paragraph and all its runs.
|
|
|
|
Sets:
|
|
- w:pPr/w:bidi → paragraph text direction = RTL
|
|
- w:pPr/w:jc → mirrored alignment (left→right), ONLY when the
|
|
paragraph has no explicit alignment — centered/justified titles
|
|
must not be forced right-aligned.
|
|
- w:rPr/w:rtl → run-level RTL marker for each run
|
|
"""
|
|
pPr = paragraph._p.get_or_add_pPr()
|
|
|
|
if pPr.find(qn("w:bidi")) is None:
|
|
pPr.append(OxmlElement("w:bidi"))
|
|
|
|
jc = pPr.find(qn("w:jc"))
|
|
explicit_alignment = jc is not None and jc.get(qn("w:val")) not in (None, "", "left")
|
|
if not explicit_alignment:
|
|
if jc is None:
|
|
jc = OxmlElement("w:jc")
|
|
pPr.append(jc)
|
|
jc.set(qn("w:val"), "right")
|
|
|
|
for run in paragraph.runs:
|
|
rPr = run._r.get_or_add_rPr()
|
|
if rPr.find(qn("w:rtl")) is None:
|
|
rPr.append(OxmlElement("w:rtl"))
|
|
|
|
|
|
def _apply_rtl_to_document(document: Document) -> None:
|
|
"""Apply RTL direction to every paragraph and section in the document."""
|
|
# Body paragraphs
|
|
for para in document.paragraphs:
|
|
_set_paragraph_rtl(para)
|
|
# Body tables
|
|
for table in document.tables:
|
|
for row in table.rows:
|
|
for cell in row.cells:
|
|
for para in cell.paragraphs:
|
|
_set_paragraph_rtl(para)
|
|
# Headers, footers, and section-level RTL (page layout direction)
|
|
for section in document.sections:
|
|
# Set the section (page) direction to RTL so Word renders margins,
|
|
# columns and page numbering from right to left.
|
|
sectPr = section._sectPr
|
|
if sectPr.find(qn("w:bidi")) is None:
|
|
sectPr.append(OxmlElement("w:bidi"))
|
|
|
|
for hf in (section.header, section.footer):
|
|
for para in hf.paragraphs:
|
|
_set_paragraph_rtl(para)
|
|
for table in hf.tables:
|
|
for row in table.rows:
|
|
for cell in row.cells:
|
|
for para in cell.paragraphs:
|
|
_set_paragraph_rtl(para)
|
|
|
|
|
|
class WordProcessorError(Exception):
|
|
"""Exception for Word processing errors with structured error codes."""
|
|
|
|
INVALID_FORMAT = "INVALID_FORMAT"
|
|
DOCX_CORRUPTED = "DOCX_CORRUPTED"
|
|
DOCX_READ_ERROR = "DOCX_READ_ERROR"
|
|
DOCX_WRITE_ERROR = "DOCX_WRITE_ERROR"
|
|
DOCX_TOO_LARGE = "DOCX_TOO_LARGE"
|
|
|
|
ERROR_MESSAGES = {
|
|
INVALID_FORMAT: "Format de fichier non supporte. Utilisez .docx.",
|
|
DOCX_CORRUPTED: "Le document Word est corrompu ou illisible.",
|
|
DOCX_READ_ERROR: "Erreur lors de la lecture du document Word.",
|
|
DOCX_WRITE_ERROR: "Erreur lors de la creation du document traduit.",
|
|
DOCX_TOO_LARGE: "Le fichier est trop volumineux (max 50 Mo).",
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
code: str,
|
|
message: Optional[str] = None,
|
|
details: Optional[Dict[str, Any]] = None,
|
|
):
|
|
self.code = code
|
|
self.message = message or self.ERROR_MESSAGES.get(code, "Erreur inconnue")
|
|
self.details = details or {}
|
|
super().__init__(self.message)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert error to dictionary format for API responses."""
|
|
result = {"error": self.code, "message": self.message}
|
|
if self.details:
|
|
result["details"] = self.details
|
|
return result
|
|
|
|
|
|
class WordTranslator:
|
|
"""
|
|
Handles translation of Word documents with strict formatting preservation.
|
|
|
|
Uses the new TranslationProvider interface for improved error handling
|
|
and fallback chain support.
|
|
"""
|
|
|
|
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.
|
|
|
|
Args:
|
|
provider: TranslationProvider instance for translations.
|
|
If None, will use fallback to legacy translation_service.
|
|
"""
|
|
self._provider = provider
|
|
self._custom_prompt: Optional[str] = None
|
|
self._translation_stats = {"attempted": 0, "changed": 0}
|
|
self._tm_scope = None # set via set_tm_scope (per-user translation memory)
|
|
|
|
def set_provider(self, provider: TranslationProvider) -> None:
|
|
"""Set the translation provider."""
|
|
self._provider = provider
|
|
|
|
def set_custom_prompt(self, prompt: Optional[str]) -> None:
|
|
"""Set custom system prompt for LLM providers."""
|
|
self._custom_prompt = prompt
|
|
|
|
def set_tm_scope(self, user_id: Optional[str], prompt: Optional[str] = None) -> None:
|
|
"""Enable the per-user translation memory for this job."""
|
|
from services.translation_tm import TMScope
|
|
|
|
self._tm_scope = TMScope.from_prompt(user_id, prompt or self._custom_prompt)
|
|
|
|
def set_segment_recorder(self, recorder) -> None:
|
|
"""Attach a segment recorder (review foundation)."""
|
|
self._segment_recorder = recorder
|
|
|
|
def set_segment_overrides(self, overrides) -> None:
|
|
"""Human-reviewed translations applied verbatim on rebuild."""
|
|
self._segment_overrides = overrides or {}
|
|
|
|
def get_recorded_segments(self):
|
|
recorder = getattr(self, "_segment_recorder", None)
|
|
return recorder.get_pairs() if recorder is not None else []
|
|
|
|
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,
|
|
translate_images: bool = False,
|
|
) -> Path:
|
|
"""
|
|
Translate a Word document while preserving all formatting and structure.
|
|
Uses batch translation for improved performance.
|
|
|
|
Args:
|
|
input_path: Path to input Word file
|
|
output_path: Path for translated output file
|
|
target_language: Target language code (e.g., 'fr', 'en')
|
|
source_language: Source language code (default: auto-detect)
|
|
progress_callback: Optional callback for progress updates
|
|
Receives dict with: element, total_elements, runs_translated
|
|
|
|
Returns:
|
|
Path to translated file
|
|
|
|
Raises:
|
|
WordProcessorError: If file is invalid, corrupted, or processing fails
|
|
"""
|
|
start_time = time.time()
|
|
|
|
input_path = Path(input_path)
|
|
output_path = Path(output_path)
|
|
|
|
self._validate_file(input_path)
|
|
|
|
try:
|
|
document = Document(input_path)
|
|
except Exception as e:
|
|
raise WordProcessorError(
|
|
code=WordProcessorError.DOCX_CORRUPTED,
|
|
details={"file_name": input_path.name, "error": str(e)},
|
|
)
|
|
|
|
try:
|
|
runs_translated = 0
|
|
|
|
text_elements: List[Tuple[str, Callable[[str], None]]] = []
|
|
chart_translations: List[Dict[str, Any]] = []
|
|
diagram_translations: List[Dict[str, Any]] = []
|
|
# Callbacks to run AFTER document.save() to write back parts
|
|
# that python-docx doesn't manage (footnotes, endnotes).
|
|
post_save_callbacks: List[Callable[[Path], None]] = []
|
|
|
|
self._collect_from_body(document, text_elements, post_save_callbacks)
|
|
|
|
# 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):
|
|
self._collect_from_section(section, text_elements)
|
|
total_elements = len(text_elements)
|
|
|
|
if progress_callback:
|
|
progress_callback(
|
|
{
|
|
"current": section_idx + 1,
|
|
"total": total_sections,
|
|
"paragraph": section_idx + 1,
|
|
"total_paragraphs": total_sections,
|
|
"runs_translated": runs_translated,
|
|
"phase": "collecting",
|
|
}
|
|
)
|
|
|
|
if text_elements:
|
|
texts = [elem[0] for elem in text_elements]
|
|
total_elements = len(text_elements)
|
|
_log_info(
|
|
"word_batch_translation_start",
|
|
file_name=input_path.name,
|
|
text_count=len(texts),
|
|
target_lang=target_language,
|
|
)
|
|
|
|
# Split into chunks and translate them IN PARALLEL using a thread
|
|
# pool. Each worker handles one chunk independently, making
|
|
# full use of available CPU/network concurrency. Progress is
|
|
# reported as chunks complete (out-of-order completions are
|
|
# fine — the tracker only moves forward).
|
|
CHUNK_SIZE = 15
|
|
MAX_WORKERS = 6
|
|
chunks = [
|
|
(i, texts[i : i + CHUNK_SIZE])
|
|
for i in range(0, total_elements, CHUNK_SIZE)
|
|
]
|
|
translated_texts: List[str] = [""] * total_elements
|
|
completed_items = [0] # mutable counter shared across threads
|
|
|
|
def _translate_chunk(
|
|
chunk_idx: int, chunk: List[str]
|
|
) -> Tuple[int, List[str]]:
|
|
result = self._batch_translate(chunk, target_language, source_language)
|
|
return chunk_idx, result
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
future_map = {
|
|
pool.submit(_translate_chunk, idx, chunk): (idx, chunk)
|
|
for idx, chunk in chunks
|
|
}
|
|
for future in concurrent.futures.as_completed(future_map):
|
|
chunk_idx, translated_chunk = future.result()
|
|
start = chunk_idx
|
|
for j, t in enumerate(translated_chunk):
|
|
translated_texts[start + j] = t
|
|
completed_items[0] += len(translated_chunk)
|
|
if progress_callback:
|
|
done = min(completed_items[0], total_elements)
|
|
progress_callback(
|
|
{
|
|
"current": done,
|
|
"total": total_elements,
|
|
"paragraph": done,
|
|
"total_paragraphs": total_elements,
|
|
"runs_translated": runs_translated,
|
|
"phase": "translating",
|
|
}
|
|
)
|
|
|
|
# Apply translations (fast — just text assignment)
|
|
for i, ((original_text, setter), translated) in enumerate(
|
|
zip(text_elements, translated_texts)
|
|
):
|
|
if translated is not None and setter is not None:
|
|
try:
|
|
setter(translated)
|
|
runs_translated += 1
|
|
except Exception as e:
|
|
_log_error(
|
|
"word_setter_error",
|
|
error=str(e),
|
|
index=i,
|
|
)
|
|
|
|
# Apply RTL layout when the target language is written right-to-left.
|
|
if target_language.lower() in RTL_LANGUAGES:
|
|
_apply_rtl_to_document(document)
|
|
|
|
# CJK / Arabic-script font hints so Word renders the target
|
|
# script with a proper typeface instead of per-run fallbacks.
|
|
_apply_font_hints(document, target_language)
|
|
|
|
if progress_callback:
|
|
progress_callback(
|
|
{
|
|
"current": total_elements if text_elements else total_sections,
|
|
"total": total_elements if text_elements else total_sections,
|
|
"paragraph": total_sections,
|
|
"total_paragraphs": total_sections,
|
|
"runs_translated": runs_translated,
|
|
"phase": "complete",
|
|
}
|
|
)
|
|
|
|
if translate_images:
|
|
try:
|
|
self._translate_images(document, target_language)
|
|
except Exception as e:
|
|
_log_error("word_document_images_failed", error=str(e))
|
|
|
|
try:
|
|
document.save(output_path)
|
|
except Exception as e:
|
|
raise WordProcessorError(
|
|
code=WordProcessorError.DOCX_WRITE_ERROR,
|
|
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)
|
|
|
|
# Run post-save callbacks (e.g. footnotes/endnotes that python-docx
|
|
# does not write back automatically).
|
|
for callback in post_save_callbacks:
|
|
try:
|
|
callback(output_path)
|
|
except Exception as cb_err:
|
|
_log_error("word_post_save_callback_error", error=str(cb_err))
|
|
|
|
processing_time_ms = round((time.time() - start_time) * 1000, 2)
|
|
|
|
_log_info(
|
|
"word_translation_success",
|
|
file_name=input_path.name,
|
|
runs_translated=runs_translated,
|
|
source_lang=source_language,
|
|
target_lang=target_language,
|
|
processing_time_ms=processing_time_ms,
|
|
)
|
|
|
|
return output_path
|
|
|
|
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)},
|
|
)
|
|
|
|
def _validate_file(self, file_path: Path) -> None:
|
|
"""Validate file format and size."""
|
|
if not file_path.exists():
|
|
raise WordProcessorError(
|
|
code=WordProcessorError.DOCX_READ_ERROR,
|
|
message=f"Fichier introuvable: {file_path.name}",
|
|
details={"file_name": file_path.name},
|
|
)
|
|
|
|
if file_path.suffix.lower() != ".docx":
|
|
raise WordProcessorError(
|
|
code=WordProcessorError.INVALID_FORMAT,
|
|
details={
|
|
"file_name": file_path.name,
|
|
"extension": file_path.suffix,
|
|
"expected": ".docx",
|
|
},
|
|
)
|
|
|
|
with open(file_path, "rb") as f:
|
|
header = f.read(4)
|
|
if header[:2] != self.DOCX_MAGIC_BYTES:
|
|
raise WordProcessorError(
|
|
code=WordProcessorError.INVALID_FORMAT,
|
|
details={"file_name": file_path.name, "reason": "Invalid file header"},
|
|
)
|
|
|
|
file_size_mb = file_path.stat().st_size / (1024 * 1024)
|
|
if file_size_mb > self.MAX_FILE_SIZE_MB:
|
|
raise WordProcessorError(
|
|
code=WordProcessorError.DOCX_TOO_LARGE,
|
|
details={
|
|
"file_name": file_path.name,
|
|
"size_mb": round(file_size_mb, 2),
|
|
"max_mb": self.MAX_FILE_SIZE_MB,
|
|
},
|
|
)
|
|
|
|
def _batch_translate(
|
|
self, texts: List[str], target_language: str, source_language: str = "auto"
|
|
) -> List[str]:
|
|
"""
|
|
Batch translate using new provider interface.
|
|
|
|
Args:
|
|
texts: List of texts to translate
|
|
target_language: Target language code
|
|
source_language: Source language code
|
|
|
|
Returns:
|
|
List of translated texts (same order as input)
|
|
"""
|
|
if not texts:
|
|
return []
|
|
|
|
non_empty = [t for t in texts if t and t.strip()]
|
|
self._translation_stats["attempted"] += len(non_empty)
|
|
|
|
from services.translation_tm import translate_with_tm
|
|
|
|
provider_name = (
|
|
self._provider.get_name() if hasattr(self._provider, "get_name")
|
|
else type(self._provider).__name__
|
|
) if self._provider is not None else "legacy"
|
|
|
|
if self._provider is not None:
|
|
def _do_translate(miss_texts):
|
|
return self._translate_with_provider(
|
|
miss_texts, target_language, source_language
|
|
)
|
|
else:
|
|
def _do_translate(miss_texts):
|
|
return self._translate_with_legacy(
|
|
miss_texts, target_language, source_language
|
|
)
|
|
|
|
from translators.segments import apply_overrides
|
|
|
|
# Reviewer overrides (approved/edited segments) win over everything:
|
|
# no TM lookup, no provider call, zero drift from the reviewed text.
|
|
ov_hits, ov_misses = apply_overrides(
|
|
texts, getattr(self, "_segment_overrides", None)
|
|
)
|
|
miss_texts = [texts[i] for i in ov_misses]
|
|
|
|
if miss_texts:
|
|
# Translation memory: reuse this user's previous translations
|
|
# (identical context/prompt) before hitting the provider.
|
|
miss_translated = translate_with_tm(
|
|
miss_texts, target_language, source_language,
|
|
provider_name, getattr(self, "_tm_scope", None), _do_translate,
|
|
)
|
|
else:
|
|
miss_translated = []
|
|
|
|
translated = []
|
|
miss_pos = 0
|
|
for i in range(len(texts)):
|
|
if i in ov_hits:
|
|
translated.append(ov_hits[i])
|
|
else:
|
|
translated.append(
|
|
miss_translated[miss_pos]
|
|
if miss_pos < len(miss_translated)
|
|
else texts[i]
|
|
)
|
|
miss_pos += 1
|
|
|
|
recorder = getattr(self, "_segment_recorder", None)
|
|
if recorder is not None:
|
|
recorder.record_pairs(zip(texts, translated))
|
|
|
|
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
|
|
self._translation_stats["changed"] += changed
|
|
|
|
return translated
|
|
|
|
def get_translation_stats(self) -> dict:
|
|
return dict(self._translation_stats)
|
|
|
|
def _translate_with_provider(
|
|
self, texts: List[str], target_language: str, source_language: str
|
|
) -> List[str]:
|
|
"""Translate using the TranslationProvider.translate_batch() interface."""
|
|
from services.providers.base import TranslationProvider as NewTranslationProvider
|
|
|
|
is_new_style = False
|
|
if isinstance(self._provider, NewTranslationProvider):
|
|
is_new_style = True
|
|
elif hasattr(self._provider, "__class__") and self._provider.__class__.__name__ in (
|
|
"MockTranslationProvider",
|
|
"Mock",
|
|
"MagicMock",
|
|
):
|
|
is_new_style = True
|
|
|
|
if is_new_style:
|
|
from services.providers.schemas import TranslationRequest
|
|
custom_prompt = getattr(self, "_custom_prompt", None)
|
|
metadata = {"custom_prompt": custom_prompt} if custom_prompt else None
|
|
|
|
requests = [
|
|
TranslationRequest(
|
|
text=t,
|
|
target_language=target_language,
|
|
source_language=source_language,
|
|
metadata=metadata,
|
|
)
|
|
for t in texts
|
|
]
|
|
responses = self._provider.translate_batch(requests)
|
|
translated = [resp.translated_text for resp in responses]
|
|
else:
|
|
translated = self._provider.translate_batch(texts, target_language, source_language)
|
|
|
|
# Fallback: keep original text for any empty/failed result
|
|
return [
|
|
t if (t and t.strip()) else orig
|
|
for t, orig in zip(translated, texts)
|
|
]
|
|
|
|
def _translate_with_legacy(
|
|
self, texts: List[str], target_language: str, source_language: str
|
|
) -> List[str]:
|
|
"""Fallback to legacy translation_service for backward compatibility."""
|
|
from services.translation_service import translation_service
|
|
|
|
_log_info(
|
|
"word_using_legacy_service",
|
|
text_count=len(texts),
|
|
target_lang=target_language,
|
|
)
|
|
|
|
return translation_service.translate_batch(
|
|
texts, target_language, source_language
|
|
)
|
|
|
|
def _collect_from_body(
|
|
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
|
|
post_save_callbacks: List[Callable[[Path], None]] = None,
|
|
) -> None:
|
|
"""Collect all text elements from document body.
|
|
|
|
Handles: paragraphs, tables, SDT (TOC/index), text boxes, shapes,
|
|
AlternateContent blocks, and any nested drawing elements.
|
|
|
|
A single ``seen_run_elements`` set is shared by every collector so
|
|
runs living in text boxes are never collected twice (the paragraph
|
|
walk descends into w:txbxContent too).
|
|
"""
|
|
count_before = len(text_elements)
|
|
seen_run_elements: set = set()
|
|
|
|
# Pass 1: walk direct body children
|
|
for element in document.element.body:
|
|
self._collect_from_element(
|
|
element, document, text_elements, seen_run_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, seen_run_elements
|
|
)
|
|
|
|
pass2_count = len(text_elements) - count_before - pass1_count
|
|
|
|
# Pass 3: footnotes, endnotes and comments (live in separate parts)
|
|
if post_save_callbacks is None:
|
|
post_save_callbacks = []
|
|
self._collect_from_footnotes(document, text_elements, post_save_callbacks)
|
|
self._collect_from_endnotes(document, text_elements, post_save_callbacks)
|
|
self._collect_from_comments(document, text_elements, post_save_callbacks)
|
|
|
|
total = len(text_elements) - count_before
|
|
_log_info(
|
|
"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]]],
|
|
seen_run_elements: Optional[set] = None,
|
|
) -> None:
|
|
"""Recursively collect from any element type."""
|
|
if isinstance(element, CT_P):
|
|
paragraph = Paragraph(element, document)
|
|
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
|
|
elif isinstance(element, CT_Tbl):
|
|
table = Table(element, document)
|
|
self._collect_from_table(table, text_elements, seen_run_elements)
|
|
elif element.tag == qn("w:sdt"):
|
|
self._collect_from_sdt(element, document, text_elements, seen_run_elements)
|
|
elif element.tag == self._TAG_ALT_CONTENT:
|
|
# <mc:AlternateContent> wraps drawing/shape content
|
|
for part in element:
|
|
self._collect_from_element(part, document, text_elements, seen_run_elements)
|
|
else:
|
|
# For any other container element, recurse into children
|
|
# to catch paragraphs nested in unexpected wrappers
|
|
for child in element:
|
|
if isinstance(child, CT_P):
|
|
paragraph = Paragraph(child, document)
|
|
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
|
|
elif isinstance(child, CT_Tbl):
|
|
table = Table(child, document)
|
|
self._collect_from_table(table, text_elements, seen_run_elements)
|
|
|
|
def _collect_from_textboxes(
|
|
self, root, document: Document,
|
|
text_elements: List[Tuple[str, Callable[[str], None]]],
|
|
seen_run_elements: Optional[set] = 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. Runs already collected
|
|
during the body walk are skipped via ``seen_run_elements``.
|
|
"""
|
|
# Find all w:txbxContent elements anywhere in the tree
|
|
for txbx in root.iter(qn("w:txbxContent")):
|
|
for child in txbx:
|
|
if isinstance(child, CT_P):
|
|
paragraph = Paragraph(child, document)
|
|
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
|
|
elif isinstance(child, CT_Tbl):
|
|
table = Table(child, document)
|
|
self._collect_from_table(table, text_elements, seen_run_elements)
|
|
|
|
def _collect_from_sdt(
|
|
self, sdt_element, document: Document,
|
|
text_elements: List[Tuple[str, Callable[[str], None]]],
|
|
seen_run_elements: Optional[set] = 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, seen_run_elements)
|
|
elif isinstance(child, CT_Tbl):
|
|
table = Table(child, document)
|
|
self._collect_from_table(table, text_elements, seen_run_elements)
|
|
|
|
def _collect_from_footnotes(
|
|
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
|
|
post_save_callbacks: List[Callable[[Path], None]] = None,
|
|
) -> None:
|
|
"""Collect text from footnotes.
|
|
|
|
python-docx 1.x doesn't expose footnotes via `document.part.package`
|
|
(that attribute doesn't exist). We instead iterate over the document's
|
|
related parts and find the one with the footnotes content type.
|
|
|
|
Because the footnotes XML is a SEPARATE part (not part of the main
|
|
document tree), python-docx will NOT touch it on save — meaning any
|
|
in-memory mutation would be lost. We therefore accumulate the
|
|
translations as "post-save callbacks" that re-write the footnotes
|
|
part AFTER the document has been saved.
|
|
"""
|
|
footnotes_xml = self._find_part_by_content_type(
|
|
document,
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
|
|
)
|
|
if footnotes_xml is None:
|
|
return
|
|
|
|
# Collect every <w:t> in the footnotes XML. We store the t_elem
|
|
# references so we can update them post-save.
|
|
for t_elem in footnotes_xml.iter(qn("w:t")):
|
|
original = t_elem.text or ""
|
|
if not original.strip():
|
|
continue
|
|
|
|
def make_t_setter(t):
|
|
def setter(text: str) -> None:
|
|
t.text = text
|
|
return setter
|
|
|
|
text_elements.append((original, make_t_setter(t_elem)))
|
|
|
|
# If we found any footnote text, register a post-save callback
|
|
# that will rewrite the footnotes part with the (now-translated)
|
|
# in-memory XML. The translated t_elems have been mutated by the
|
|
# setters called from translate_file's batch loop.
|
|
if text_elements and post_save_callbacks is not None:
|
|
from copy import deepcopy
|
|
# We need to keep a reference to the parsed XML tree so we can
|
|
# serialize it after the setters have run. The footnote text
|
|
# setters hold references to t_elems inside this tree.
|
|
def write_footnotes_back(output_path: Path) -> None:
|
|
try:
|
|
new_blob = etree.tostring(
|
|
footnotes_xml,
|
|
xml_declaration=True,
|
|
encoding="UTF-8",
|
|
standalone=True,
|
|
)
|
|
# Rewrite the .docx with the updated footnotes part
|
|
tmp_path = output_path.with_suffix(".tmp_foot")
|
|
with zipfile.ZipFile(output_path, "r") as zin, \
|
|
zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
|
|
for item in zin.namelist():
|
|
if item == "word/footnotes.xml":
|
|
zout.writestr(item, new_blob)
|
|
else:
|
|
zout.writestr(item, zin.read(item))
|
|
tmp_path.replace(output_path)
|
|
except Exception as e:
|
|
_log_error("word_footnotes_writeback_error", error=str(e))
|
|
|
|
post_save_callbacks.append(write_footnotes_back)
|
|
|
|
def _find_part_by_content_type(self, document: Document, content_type: str):
|
|
"""
|
|
Find a related XML part by content type (python-docx 1.x compatible).
|
|
Returns the parsed lxml element, or None if not found.
|
|
"""
|
|
try:
|
|
related_parts = getattr(document.part, "related_parts", None) or {}
|
|
for part in related_parts.values():
|
|
if getattr(part, "content_type", "") == content_type:
|
|
return etree.fromstring(part.blob)
|
|
except Exception as e:
|
|
_log_error("word_part_lookup_error", content_type=content_type, error=str(e))
|
|
return None
|
|
|
|
def _collect_from_endnotes(
|
|
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
|
|
post_save_callbacks: List[Callable[[Path], None]] = None,
|
|
) -> None:
|
|
"""Collect text from endnotes (python-docx 1.x compatible).
|
|
|
|
See `_collect_from_footnotes` for why we need post-save callbacks.
|
|
"""
|
|
endnotes_xml = self._find_part_by_content_type(
|
|
document,
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
|
|
)
|
|
if endnotes_xml is None:
|
|
return
|
|
|
|
for t_elem in endnotes_xml.iter(qn("w:t")):
|
|
original = t_elem.text or ""
|
|
if not original.strip():
|
|
continue
|
|
|
|
def make_t_setter(t):
|
|
def setter(text: str) -> None:
|
|
t.text = text
|
|
return setter
|
|
|
|
text_elements.append((original, make_t_setter(t_elem)))
|
|
|
|
if text_elements and post_save_callbacks is not None:
|
|
def write_endnotes_back(output_path: Path) -> None:
|
|
try:
|
|
new_blob = etree.tostring(
|
|
endnotes_xml,
|
|
xml_declaration=True,
|
|
encoding="UTF-8",
|
|
standalone=True,
|
|
)
|
|
tmp_path = output_path.with_suffix(".tmp_end")
|
|
with zipfile.ZipFile(output_path, "r") as zin, \
|
|
zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
|
|
for item in zin.namelist():
|
|
if item == "word/endnotes.xml":
|
|
zout.writestr(item, new_blob)
|
|
else:
|
|
zout.writestr(item, zin.read(item))
|
|
tmp_path.replace(output_path)
|
|
except Exception as e:
|
|
_log_error("word_endnotes_writeback_error", error=str(e))
|
|
|
|
post_save_callbacks.append(write_endnotes_back)
|
|
|
|
def _collect_from_comments(
|
|
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
|
|
post_save_callbacks: List[Callable[[Path], None]] = None,
|
|
) -> None:
|
|
"""Collect text from comments/balloons (word/comments.xml part).
|
|
|
|
Same mechanism as footnotes: the comments part is separate from the
|
|
main document tree, so translations are written back after save.
|
|
"""
|
|
comments_xml = self._find_part_by_content_type(
|
|
document,
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
|
|
)
|
|
if comments_xml is None:
|
|
return
|
|
|
|
collected = 0
|
|
for t_elem in comments_xml.iter(qn("w:t")):
|
|
original = t_elem.text or ""
|
|
if not original.strip():
|
|
continue
|
|
|
|
def make_t_setter(t):
|
|
def setter(text: str) -> None:
|
|
t.text = text
|
|
return setter
|
|
|
|
text_elements.append((original, make_t_setter(t_elem)))
|
|
collected += 1
|
|
|
|
if collected and post_save_callbacks is not None:
|
|
def write_comments_back(output_path: Path) -> None:
|
|
try:
|
|
new_blob = etree.tostring(
|
|
comments_xml,
|
|
xml_declaration=True,
|
|
encoding="UTF-8",
|
|
standalone=True,
|
|
)
|
|
tmp_path = output_path.with_suffix(".tmp_com")
|
|
with zipfile.ZipFile(output_path, "r") as zin, \
|
|
zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
|
|
for item in zin.namelist():
|
|
if item == "word/comments.xml":
|
|
zout.writestr(item, new_blob)
|
|
else:
|
|
zout.writestr(item, zin.read(item))
|
|
tmp_path.replace(output_path)
|
|
except Exception as e:
|
|
_log_error("word_comments_writeback_error", error=str(e))
|
|
|
|
post_save_callbacks.append(write_comments_back)
|
|
|
|
def _collect_from_charts(
|
|
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
|
|
) -> None:
|
|
"""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.
|
|
|
|
Uses the `element_path` collected during `_collect_charts_from_zip` to
|
|
uniquely identify each translatable element, even when the same text
|
|
value appears multiple times in the same chart (e.g. two series both
|
|
labelled "Revenue"). The old code matched by string equality, which
|
|
would translate only the first occurrence.
|
|
"""
|
|
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"
|
|
|
|
# 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:
|
|
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]:
|
|
target = self._find_element_by_path(chart_xml, entry.get('element_path', ''))
|
|
if target is not None:
|
|
target.text = entry['translated']
|
|
else:
|
|
# Fallback: try to find by tag + original text (for
|
|
# entries that predate the path-based collection)
|
|
tag_to_find = f'{{{_NS_A}}}t'
|
|
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)
|
|
|
|
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))
|
|
|
|
def _find_element_by_path(self, root, path: str):
|
|
"""
|
|
Navigate the XML tree using an XPath-like path produced by
|
|
`_get_element_path`. Returns None if the path is empty or invalid
|
|
(e.g. the tree structure changed between collect and apply).
|
|
"""
|
|
if not path:
|
|
return None
|
|
try:
|
|
current = root
|
|
for segment in path.split('/'):
|
|
# Format: "tagname[index]"
|
|
if '[' not in segment or not segment.endswith(']'):
|
|
return None
|
|
tag_name, idx_str = segment[:-1].split('[', 1)
|
|
try:
|
|
idx = int(idx_str)
|
|
except ValueError:
|
|
return None
|
|
children = list(current)
|
|
# The path used `list(parent).index(current)` which counts ALL
|
|
# children, so we use a simple position lookup.
|
|
if idx < 0 or idx >= len(children):
|
|
return None
|
|
candidate = children[idx]
|
|
# Verify tag name (with or without namespace) to avoid silent
|
|
# mis-navigation if the tree changed.
|
|
candidate_local_tag = candidate.tag.split('}')[-1] if '}' in candidate.tag else candidate.tag
|
|
if candidate_local_tag != tag_name:
|
|
return None
|
|
current = candidate
|
|
return current
|
|
except Exception:
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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))
|
|
|
|
@staticmethod
|
|
def _rpr_signature(run_element) -> str:
|
|
"""Formatting signature of a run: serialized rPr XML (or "")."""
|
|
rpr = run_element.find(qn("w:rPr"))
|
|
if rpr is None:
|
|
return ""
|
|
import lxml.etree as _et
|
|
|
|
return _et.tostring(rpr, encoding="unicode")
|
|
|
|
def _collect_from_paragraph(
|
|
self,
|
|
paragraph: Paragraph,
|
|
text_elements: List[Tuple[str, Callable[[str], None]]],
|
|
seen_run_elements: Optional[set] = None,
|
|
) -> None:
|
|
"""Collect text from paragraph runs, preserving inter-run whitespace.
|
|
|
|
Adjacent runs sharing the SAME parent element and the SAME run
|
|
formatting (rPr) are merged into ONE translation unit: the sentence
|
|
is translated whole — not fragment by fragment — and the result is
|
|
written into the first run while the sibling runs are blanked.
|
|
This is what keeps mid-sentence bold spans ("This is *very*
|
|
important") coherent in the target language, like DeepL's inline
|
|
tag handling.
|
|
|
|
Note: python-docx's `paragraph.runs` only returns DIRECT child <w:r>
|
|
elements, not those inside <w:hyperlink> (used for TOC entries,
|
|
cross-references, bookmark links). We therefore iterate the full
|
|
XML tree to find every <w:r> and deduplicate by element identity —
|
|
`seen_run_elements` is shared across paragraphs so runs living in
|
|
text boxes (also collected by _collect_from_textboxes) are not
|
|
translated twice.
|
|
"""
|
|
# Check full paragraph text including nested content (hyperlinks, etc.)
|
|
full_text = ''.join(
|
|
t.text or '' for t in paragraph._p.iter(qn('w:t'))
|
|
).strip()
|
|
if not full_text:
|
|
return
|
|
|
|
if seen_run_elements is None:
|
|
seen_run_elements = set()
|
|
|
|
# Every <w:r> in the paragraph tree, in document order, deduplicated
|
|
# by element identity (paragraph.runs and the manual iter overlap).
|
|
ordered_runs = []
|
|
for r_elem in paragraph._p.iter(qn('w:r')):
|
|
if id(r_elem) in seen_run_elements:
|
|
continue
|
|
seen_run_elements.add(id(r_elem))
|
|
ordered_runs.append(r_elem)
|
|
|
|
# Merge adjacent runs: same parent + same formatting signature.
|
|
# Merging never crosses a parent boundary, so runs belonging to
|
|
# different hyperlinks stay separate units.
|
|
group: list = [] # list of r_elems
|
|
group_signature: Optional[str] = None
|
|
|
|
def _flush_group():
|
|
combined = "".join(
|
|
(t.text or "")
|
|
for r in group
|
|
for t in r.findall(qn("w:t"))
|
|
)
|
|
if not combined.strip():
|
|
return
|
|
non_empty = [r for r in group if r.findall(qn("w:t"))]
|
|
if len(non_empty) == 1:
|
|
run = Run(non_empty[0], paragraph)
|
|
self._append_run_translation(run, text_elements)
|
|
return
|
|
leading = combined[: len(combined) - len(combined.lstrip())]
|
|
trailing = combined[len(combined.rstrip()):]
|
|
stripped = combined.strip()
|
|
if not stripped:
|
|
return
|
|
|
|
first = non_empty[0]
|
|
|
|
def make_group_setter(first_r, siblings, lead: str, trail: str):
|
|
def setter(text: str) -> None:
|
|
from docx.text.run import Run as _Run
|
|
|
|
run = _Run(first_r, paragraph)
|
|
# Reapply the group's boundary whitespace so words are
|
|
# never concatenated with the next differently-formatted
|
|
# run ("This is quite" + "very" → "quite very").
|
|
run.text = lead + text.strip() + trail
|
|
# Blank the merged siblings: the whole sentence now
|
|
# lives in the first run (formatting is identical).
|
|
for sib in siblings:
|
|
for t_elem in sib.findall(qn("w:t")):
|
|
t_elem.text = ""
|
|
|
|
return setter
|
|
|
|
siblings = non_empty[1:]
|
|
text_elements.append(
|
|
(stripped, make_group_setter(first, siblings, leading, trailing))
|
|
)
|
|
|
|
for r_elem in ordered_runs:
|
|
# Whitespace-only runs join the group: dropping them would
|
|
# concatenate words ("Hello" + " " + "World" → "HelloWorld").
|
|
# They carry no w:t text, so a group of only whitespace runs is
|
|
# skipped at flush time by the strip() check.
|
|
signature = self._rpr_signature(r_elem)
|
|
same_parent = (
|
|
group and group[-1].getparent() is r_elem.getparent()
|
|
)
|
|
if group and same_parent and signature == group_signature:
|
|
group.append(r_elem)
|
|
else:
|
|
_flush_group()
|
|
group = [r_elem]
|
|
group_signature = signature
|
|
_flush_group()
|
|
|
|
def _append_run_translation(
|
|
self,
|
|
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()
|
|
|
|
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]]],
|
|
seen_run_elements: Optional[set] = None,
|
|
) -> None:
|
|
"""Collect text from table cells."""
|
|
for row in table.rows:
|
|
for cell in row.cells:
|
|
for paragraph in cell.paragraphs:
|
|
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
|
|
for nested_table in cell.tables:
|
|
self._collect_from_table(nested_table, text_elements, seen_run_elements)
|
|
|
|
def _collect_from_section(
|
|
self, section: Section, text_elements: List[Tuple[str, Callable[[str], None]]]
|
|
) -> None:
|
|
"""Collect text from headers and footers."""
|
|
headers_footers = [
|
|
section.header,
|
|
section.footer,
|
|
section.first_page_header,
|
|
section.first_page_footer,
|
|
section.even_page_header,
|
|
section.even_page_footer,
|
|
]
|
|
|
|
for hf in headers_footers:
|
|
if hf:
|
|
for paragraph in hf.paragraphs:
|
|
self._collect_from_paragraph(paragraph, text_elements)
|
|
for table in hf.tables:
|
|
self._collect_from_table(table, text_elements)
|
|
|
|
def _translate_images(self, document: Document, target_language: str) -> None:
|
|
"""Extract and translate text from images in Word document.
|
|
Inserts the translated text as a caption paragraph under each image."""
|
|
try:
|
|
inline_shapes = getattr(document, "inline_shapes", [])
|
|
_log_info("word_image_translation_start", count=len(inline_shapes))
|
|
|
|
for idx, shape in enumerate(inline_shapes):
|
|
# Type 3 is picture, type 12 is linked picture
|
|
if not (hasattr(shape, "type") and shape.type in (3, 12)):
|
|
continue
|
|
|
|
try:
|
|
image = getattr(shape, "image", None)
|
|
if not image:
|
|
continue
|
|
|
|
image_data = image.blob
|
|
ext = getattr(image, "ext", "png") or "png"
|
|
|
|
import tempfile
|
|
import os
|
|
with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as tmp:
|
|
tmp.write(image_data)
|
|
tmp_path = tmp.name
|
|
|
|
translated_text = self._translate_image_text(tmp_path, target_language)
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except:
|
|
pass
|
|
|
|
if translated_text and translated_text.strip():
|
|
parent = shape._inline.getparent()
|
|
while parent is not None and parent.tag != qn("w:p"):
|
|
parent = parent.getparent()
|
|
|
|
if parent is not None:
|
|
p_elem = parent
|
|
new_p_elem = OxmlElement("w:p")
|
|
p_elem.addnext(new_p_elem)
|
|
|
|
from docx.text.paragraph import Paragraph
|
|
new_p = Paragraph(new_p_elem, document)
|
|
|
|
from docx.shared import Pt, RGBColor
|
|
run = new_p.add_run(f" [Image translation: {translated_text.strip()}] ")
|
|
run.font.italic = True
|
|
run.font.size = Pt(9)
|
|
run.font.color.rgb = RGBColor(128, 128, 128)
|
|
|
|
_log_info("word_image_translation_added", index=idx)
|
|
except Exception as shape_err:
|
|
_log_error("word_image_shape_translation_error", index=idx, error=str(shape_err))
|
|
except Exception as e:
|
|
_log_error("word_image_processing_error", error=str(e))
|
|
|
|
def _translate_image_text(
|
|
self, image_path: str, target_language: str
|
|
) -> str:
|
|
"""Translate image using active provider or legacy service."""
|
|
if self._provider and hasattr(self._provider, "translate_image"):
|
|
try:
|
|
return self._provider.translate_image(image_path, target_language)
|
|
except Exception as e:
|
|
_log_error("word_image_translation_provider_error", error=str(e))
|
|
|
|
from services.translation_service import translation_service
|
|
# Temporarily enable translate_images flag on translation_service to bypass the hardcoded check
|
|
old_val = getattr(translation_service, "translate_images", False)
|
|
try:
|
|
translation_service.translate_images = True
|
|
if hasattr(translation_service, "translate_image"):
|
|
return translation_service.translate_image(image_path, target_language)
|
|
except Exception as e:
|
|
_log_error("word_image_translation_legacy_error", error=str(e))
|
|
finally:
|
|
translation_service.translate_images = old_val
|
|
return ""
|
|
|
|
|
|
word_translator = WordTranslator()
|