Le defaut le plus sournois du pipeline etait silencieux : de l'arabe livre pour une cible persane (meme ecriture, mauvaise langue) passe inapercu et part chez le lecteur. Desormais chaque lot traduit est verifie par le detecteur d'ecritures, et chaque segment fautif est redemande une fois au moteur avec une consigne renforcee (nom de la langue + lettres specifiques, ex. persan پ چ ژ گ). La seconde tentative ne remplace la premiere que si elle passe le meme controle. - services/quality/script_detector.py : extraction d'un controle script_issue() reutilisable ; detect_arabic_variant signale des ormais un long texte en ecriture arabe sans aucune lettre specifique de la langue cible (arabe pur livre pour du persan/ourdou/pachto) - translators/segments.py : retry_wrong_script() + construction de la consigne renforcee, sans jamais faire echouer le travail - Word, Excel, PDF : branchement apres la memoire de traduction et les validations humaines ; le texte inchange (chiffres, noms propres) ne declenche jamais de retentative - 14 tests nouveaux (tests/test_translators/test_script_retry.py)
1811 lines
73 KiB
Python
1811 lines
73 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
|
|
from core.languages import is_rtl
|
|
|
|
# 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",
|
|
"ps": "Arial",
|
|
"ku": "Arial",
|
|
"sd": "Arial",
|
|
"ug": "Arial",
|
|
"yi": "Arial",
|
|
"dv": "Arial",
|
|
"ckb": "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)
|
|
|
|
|
|
# --- OOXML schema order ---------------------------------------------------
|
|
# w:pPr, w:rPr and w:tblPr require their children in a FIXED order.
|
|
# Appending at the end produces out-of-order XML that Word "repairs" on
|
|
# open (with a repair prompt in some versions). Each RTL element is
|
|
# therefore inserted just before its first successor element, per the
|
|
# CT_PPr / CT_RPr / CT_TblPr sequences of the schema.
|
|
|
|
# Elements that must come AFTER w:bidi inside w:pPr.
|
|
_PPR_BIDI_SUCCESSORS = (
|
|
qn("w:adjustRightInd"),
|
|
qn("w:snapToGrid"),
|
|
qn("w:spacing"),
|
|
qn("w:ind"),
|
|
qn("w:contextualSpacing"),
|
|
qn("w:mirrorIndents"),
|
|
qn("w:suppressOverlap"),
|
|
qn("w:jc"),
|
|
qn("w:textDirection"),
|
|
qn("w:textAlignment"),
|
|
qn("w:textboxTightWrap"),
|
|
qn("w:outlineLvl"),
|
|
qn("w:divId"),
|
|
qn("w:cnfStyle"),
|
|
qn("w:rPr"),
|
|
qn("w:sectPr"),
|
|
qn("w:pPrChange"),
|
|
)
|
|
# Elements that must come AFTER w:rtl inside w:rPr.
|
|
_RPR_RTL_SUCCESSORS = (
|
|
qn("w:cs"),
|
|
qn("w:em"),
|
|
qn("w:lang"),
|
|
qn("w:eastAsianLayout"),
|
|
qn("w:specVanish"),
|
|
qn("w:oMath"),
|
|
qn("w:rPrChange"),
|
|
)
|
|
# Elements that must come AFTER w:bidiVisual inside w:tblPr.
|
|
_TBLPR_BIDIVISUAL_SUCCESSORS = (
|
|
qn("w:tblStyleRowBandSize"),
|
|
qn("w:tblStyleColBandSize"),
|
|
qn("w:tblW"),
|
|
qn("w:jc"),
|
|
qn("w:tblCellSpacing"),
|
|
qn("w:tblInd"),
|
|
qn("w:tblBorders"),
|
|
qn("w:shd"),
|
|
qn("w:tblLayout"),
|
|
qn("w:tblCellMar"),
|
|
qn("w:tblLook"),
|
|
qn("w:tblCaption"),
|
|
qn("w:tblDescription"),
|
|
qn("w:tblPrChange"),
|
|
)
|
|
|
|
|
|
def _insert_ordered(parent, child, successors) -> None:
|
|
"""Insert child into parent at its schema position.
|
|
|
|
The child is placed just before the first existing element whose tag
|
|
is listed in ``successors`` (the elements that must come after it in
|
|
the OOXML sequence); appended at the end when no successor exists.
|
|
Works on python-docx oxml elements and plain lxml elements alike.
|
|
"""
|
|
for existing in parent:
|
|
if existing.tag in successors:
|
|
existing.addprevious(child)
|
|
return
|
|
parent.append(child)
|
|
|
|
|
|
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:rPr/w:rtl → run-level RTL marker for each run
|
|
|
|
w:jc is deliberately left untouched. Under w:bidi, Word interprets
|
|
w:jc LOGICALLY (start/end), not visually: "left" means the start —
|
|
the RIGHT edge for an RTL paragraph — and "right" means the end, the
|
|
LEFT edge. Forcing jc="right" therefore aligns paragraphs to the
|
|
visual left and overrides style-level centering, which is exactly
|
|
the mis-rendering reported for centered Persian text. With bidi
|
|
alone: unaligned paragraphs start at the right edge, "left" resolves
|
|
to the right edge, and center/justify stay centered/justified — the
|
|
same markup Word itself writes for RTL documents.
|
|
|
|
Every element is inserted at its schema position inside w:pPr/w:rPr
|
|
(OOXML requires a fixed child order; appended-at-the-end elements
|
|
make Word flag the file for repair).
|
|
"""
|
|
pPr = paragraph._p.get_or_add_pPr()
|
|
|
|
if pPr.find(qn("w:bidi")) is None:
|
|
_insert_ordered(pPr, OxmlElement("w:bidi"), _PPR_BIDI_SUCCESSORS)
|
|
|
|
for run in paragraph.runs:
|
|
rPr = run._r.get_or_add_rPr()
|
|
if rPr.find(qn("w:rtl")) is None:
|
|
_insert_ordered(rPr, OxmlElement("w:rtl"), _RPR_RTL_SUCCESSORS)
|
|
|
|
|
|
def _set_table_rtl(tbl_element) -> None:
|
|
"""
|
|
Mark a table as visually right-to-left.
|
|
|
|
w:bidiVisual inside w:tblPr flips the column order at render time:
|
|
the first column displays rightmost. Works on python-docx CT_Tbl
|
|
elements and on plain lxml elements (notes/comments trees) alike.
|
|
"""
|
|
tblPr = tbl_element.find(qn("w:tblPr"))
|
|
if tblPr is None:
|
|
tblPr = OxmlElement("w:tblPr")
|
|
tbl_element.insert(0, tblPr)
|
|
if tblPr.find(qn("w:bidiVisual")) is None:
|
|
_insert_ordered(
|
|
tblPr, OxmlElement("w:bidiVisual"), _TBLPR_BIDIVISUAL_SUCCESSORS
|
|
)
|
|
|
|
|
|
def _apply_bidi_to_part_tree(root) -> int:
|
|
"""
|
|
Switch every paragraph, run and table of a footnotes/endnotes/comments
|
|
tree to RTL.
|
|
|
|
These parts are rewritten as raw XML after the document save (they
|
|
live outside python-docx's object model), so the RTL direction is
|
|
injected into the same in-memory tree just before write-back:
|
|
w:bidi on every paragraph, w:rtl on every run, w:bidiVisual on the
|
|
tables these parts may contain.
|
|
|
|
Returns the number of paragraphs switched to RTL.
|
|
"""
|
|
switched = 0
|
|
for p in root.iter(qn("w:p")):
|
|
pPr = p.find(qn("w:pPr"))
|
|
if pPr is None:
|
|
pPr = OxmlElement("w:pPr")
|
|
p.insert(0, pPr)
|
|
if pPr.find(qn("w:bidi")) is None:
|
|
_insert_ordered(pPr, OxmlElement("w:bidi"), _PPR_BIDI_SUCCESSORS)
|
|
switched += 1
|
|
for r in p.iter(qn("w:r")):
|
|
rPr = r.find(qn("w:rPr"))
|
|
if rPr is None:
|
|
rPr = OxmlElement("w:rPr")
|
|
r.insert(0, rPr)
|
|
if rPr.find(qn("w:rtl")) is None:
|
|
_insert_ordered(rPr, OxmlElement("w:rtl"), _RPR_RTL_SUCCESSORS)
|
|
for tbl in root.iter(qn("w:tbl")):
|
|
_set_table_rtl(tbl)
|
|
return switched
|
|
|
|
|
|
def _apply_rtl_to_document(document: Document) -> None:
|
|
"""Apply RTL direction to every paragraph and section in the document."""
|
|
body = document.element.body
|
|
|
|
# Every paragraph of the body tree in ONE pass: top-level paragraphs,
|
|
# table cells (nested tables included), text boxes (w:txbxContent)
|
|
# and SDT content all receive w:bidi + run-level w:rtl.
|
|
for p in body.iter(qn("w:p")):
|
|
_set_paragraph_rtl(Paragraph(p, document))
|
|
|
|
# Tables: w:bidiVisual flips the visual column order. iter() covers
|
|
# nested tables too.
|
|
for tbl in body.iter(qn("w:tbl")):
|
|
_set_table_rtl(tbl)
|
|
|
|
# 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"))
|
|
|
|
# Same six header/footer zones the text collection translates
|
|
# (default, first page, even pages) — a translated first-page
|
|
# header must not stay left-to-right. Some may be missing
|
|
# depending on the python-docx version: getattr defensively.
|
|
headers_footers = (
|
|
section.header,
|
|
section.footer,
|
|
getattr(section, "first_page_header", None),
|
|
getattr(section, "first_page_footer", None),
|
|
getattr(section, "even_page_header", None),
|
|
getattr(section, "even_page_footer", None),
|
|
)
|
|
for hf in headers_footers:
|
|
if hf is None:
|
|
continue
|
|
# Touch paragraphs first so a linked/missing definition is
|
|
# created exactly like the paragraph walk below would.
|
|
paras = hf.paragraphs
|
|
root = getattr(hf, "_element", None)
|
|
if root is None and paras:
|
|
root = paras[0]._p.getroottree().getroot()
|
|
if root is not None:
|
|
# Full story tree: paragraphs, tables (bidiVisual) and
|
|
# text boxes inside the header/footer.
|
|
for p in root.iter(qn("w:p")):
|
|
_set_paragraph_rtl(Paragraph(p, document))
|
|
for tbl in root.iter(qn("w:tbl")):
|
|
_set_table_rtl(tbl)
|
|
else:
|
|
for para in paras:
|
|
_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, rtl=is_rtl(target_language)
|
|
)
|
|
|
|
# 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 is_rtl(target_language):
|
|
_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
|
|
|
|
# Wrong-script guard: re-ask once (with a reinforced instruction)
|
|
# for translations delivered in the wrong alphabet — e.g. Arabic
|
|
# delivered for a Persian target, which looks almost right to a
|
|
# non-reader and ships unnoticed.
|
|
from translators.segments import retry_wrong_script
|
|
|
|
def _retry_translate(retry_texts, hint):
|
|
if self._provider is not None:
|
|
return self._translate_with_provider(
|
|
retry_texts, target_language, source_language, extra_prompt=hint
|
|
)
|
|
return self._translate_with_legacy(
|
|
retry_texts, target_language, source_language, extra_prompt=hint
|
|
)
|
|
|
|
translated, _script_fixed = retry_wrong_script(
|
|
texts, translated, target_language, _retry_translate
|
|
)
|
|
|
|
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,
|
|
extra_prompt: Optional[str] = None,
|
|
) -> 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)
|
|
if extra_prompt:
|
|
custom_prompt = (
|
|
f"{custom_prompt}\n\n{extra_prompt}" if custom_prompt else extra_prompt
|
|
)
|
|
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,
|
|
extra_prompt: Optional[str] = None,
|
|
) -> List[str]:
|
|
"""Fallback to legacy translation_service for backward compatibility.
|
|
|
|
The legacy service has no custom-prompt parameter: extra_prompt is
|
|
accepted for signature parity but cannot be forwarded — the retry
|
|
still happens, just without the reinforced instruction.
|
|
"""
|
|
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,
|
|
rtl: bool = False,
|
|
) -> 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, rtl=rtl)
|
|
self._collect_from_endnotes(document, text_elements, post_save_callbacks, rtl=rtl)
|
|
self._collect_from_comments(document, text_elements, post_save_callbacks, rtl=rtl)
|
|
|
|
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,
|
|
rtl: bool = False,
|
|
) -> 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:
|
|
if rtl:
|
|
_apply_bidi_to_part_tree(footnotes_xml)
|
|
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,
|
|
rtl: bool = False,
|
|
) -> 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:
|
|
if rtl:
|
|
_apply_bidi_to_part_tree(endnotes_xml)
|
|
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,
|
|
rtl: bool = False,
|
|
) -> 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:
|
|
if rtl:
|
|
_apply_bidi_to_part_tree(comments_xml)
|
|
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, keeping 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()
|