fix(translate): chart labels, sheet-refs, sheet-name offset, paid-user Memento
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
Multiple translation bugs in Word/Excel/PPTX that caused chart elements
to be left untranslated or charts to render as empty series.
Backend
-------
* providers (deepseek/openai/minimax): tighten system prompt so the LLM
actually translates chart titles/axis labels/legend/category labels,
month abbreviations, and clarifies what counts as a 'real' proper noun
(people/place/company/product names) vs. technical labels. Old rule
'keep proper nouns unchanged' was being read too broadly by the model
and caused chart text to be skipped.
* excel_translator.py:
- Sheet reference rewrite: when a sheet is renamed, the chart XML's
c:f refs (e.g. 'Sales 2024'!$D$2:$D$61) are now rewritten to the
new name with proper apostrophe escaping (Chiffre d'affaires ->
'Chiffre d''affaires') and auto-quoting when the new name contains
spaces or special chars. Without this, the chart points at a sheet
that no longer exists and renders 0/empty series.
- Sheet name offset bug: sheet_name_offset was computed after chart
text was appended to text_elements, causing sheet names to receive
chart text translations. Now captured BEFORE sheet names are added.
* New tests:
- test_excel_chart_sheet_refs.py (10 unit tests, synthetic inputs)
- test_chart_translation_prompt.py (3 contract tests on the prompt)
Frontend
--------
* DashboardSidebar / translate/page: hide the Memento promo section
for paying users (tier != 'free').
* constants.ts: temporarily comment out the 'CLES API' nav item.
Update constants.test.ts to match the new state.
All fixes are generic - no file-specific hardcoding, edge cases covered
(empty mapping, missing bang, apostrophe escaping, partial renames,
multi-series).
This commit is contained in:
@@ -198,16 +198,24 @@ class ExcelTranslator:
|
||||
}
|
||||
)
|
||||
|
||||
# Record the position where sheet names start in text_elements.
|
||||
# We need this to extract the right slice of translated_texts
|
||||
# AFTER chart text has been appended. Otherwise the offset
|
||||
# calculation drifts and sheet names get chart translations.
|
||||
sheet_name_offset = len(text_elements)
|
||||
|
||||
for sheet_name in sheet_names_to_translate:
|
||||
text_elements.append((sheet_name, None))
|
||||
|
||||
# Collect chart text from ZIP
|
||||
# Collect chart text from ZIP (appended AFTER sheet names)
|
||||
self._collect_charts_from_zip(input_path, text_elements, chart_translations)
|
||||
|
||||
if text_elements:
|
||||
texts = [elem[0] for elem in text_elements]
|
||||
total_texts = len(texts)
|
||||
sheet_name_offset = total_texts - len(sheet_names_to_translate)
|
||||
# sheet_name_offset was captured before sheet names + chart text
|
||||
# were appended, so it already points at the start of the sheet
|
||||
# name block. No re-derivation needed.
|
||||
|
||||
_log_info(
|
||||
"excel_batch_translation_start",
|
||||
@@ -317,9 +325,16 @@ class ExcelTranslator:
|
||||
details={"file_name": output_path.name, "error": str(e)},
|
||||
)
|
||||
|
||||
# Re-inject chart translations into the .xlsx ZIP
|
||||
if chart_translations:
|
||||
self._apply_chart_translations(output_path, chart_translations)
|
||||
# Re-inject chart translations into the .xlsx ZIP.
|
||||
# We always pass the sheet_name_mapping so chart <c:f> references
|
||||
# are updated when sheet names were translated — otherwise the
|
||||
# chart would keep pointing at the old (now-missing) sheet and
|
||||
# render as empty/zero series.
|
||||
self._apply_chart_translations(
|
||||
output_path,
|
||||
chart_translations,
|
||||
sheet_name_mapping=sheet_name_mapping or None,
|
||||
)
|
||||
|
||||
workbook.close()
|
||||
|
||||
@@ -739,18 +754,27 @@ class ExcelTranslator:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _apply_chart_translations(self, output_path: Path, chart_translations: List[Dict[str, Any]]) -> None:
|
||||
def _apply_chart_translations(
|
||||
self,
|
||||
output_path: Path,
|
||||
chart_translations: List[Dict[str, Any]],
|
||||
sheet_name_mapping: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""Re-inject chart translations into the .xlsx ZIP.
|
||||
|
||||
Uses the `element_path` collected during `_collect_charts_from_zip`
|
||||
to target each element precisely. Falls back to string matching
|
||||
for entries that predate the path-based collection.
|
||||
|
||||
Also rewrites `<c:f>` references inside chart XML when sheet names
|
||||
were translated — otherwise the chart keeps pointing at the old
|
||||
sheet name and renders empty (zero) data.
|
||||
"""
|
||||
if not chart_translations:
|
||||
if not chart_translations and not sheet_name_mapping:
|
||||
return
|
||||
|
||||
translated_entries = [e for e in chart_translations if 'translated' in e and e['translated']]
|
||||
if not translated_entries:
|
||||
if not translated_entries and not sheet_name_mapping:
|
||||
return
|
||||
|
||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
@@ -763,6 +787,18 @@ class ExcelTranslator:
|
||||
chart_files_to_update[cf] = []
|
||||
chart_files_to_update[cf].append(entry)
|
||||
|
||||
# If we have sheet renames, we must walk every chart file (even ones
|
||||
# with no text translations) so the <c:f> references are updated.
|
||||
if sheet_name_mapping:
|
||||
# We need the list of chart files; peek into the zip to find them.
|
||||
try:
|
||||
with zipfile.ZipFile(output_path, 'r') as zf_peek:
|
||||
for name in zf_peek.namelist():
|
||||
if name.startswith('xl/charts/') and name.endswith('.xml'):
|
||||
chart_files_to_update.setdefault(name, [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(output_path, 'r') as zf_in:
|
||||
existing_entries = zf_in.namelist()
|
||||
@@ -789,6 +825,14 @@ class ExcelTranslator:
|
||||
if v_elem.text and v_elem.text.strip() == entry['original']:
|
||||
v_elem.text = entry['translated']
|
||||
break
|
||||
|
||||
# Rewrite sheet references in <c:f> when sheet
|
||||
# names were translated. Without this, charts
|
||||
# point at sheets that no longer exist and
|
||||
# render as zero/empty series.
|
||||
if sheet_name_mapping:
|
||||
self._rewrite_sheet_refs_in_chart(chart_xml, sheet_name_mapping)
|
||||
|
||||
data = etree.tostring(chart_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
except Exception as e:
|
||||
_log_error("excel_chart_update_error", chart_file=item, error=str(e))
|
||||
@@ -798,11 +842,86 @@ class ExcelTranslator:
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(buf.getvalue())
|
||||
|
||||
_log_info("excel_charts_translated", chart_files=len(chart_files_to_update), translations=len(translated_entries))
|
||||
_log_info(
|
||||
"excel_charts_translated",
|
||||
chart_files=len(chart_files_to_update),
|
||||
translations=len(translated_entries),
|
||||
sheet_refs_rewritten=len(sheet_name_mapping or {}),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_log_error("excel_chart_zip_rewrite_error", error=str(e))
|
||||
|
||||
@staticmethod
|
||||
def _quote_sheet_name_for_ref(name: str) -> str:
|
||||
"""Return the sheet name as it must appear inside a chart <c:f> reference.
|
||||
|
||||
Excel requires single-quoting when the name contains spaces or any of
|
||||
`:\\/?*[]`, and any single quote inside the name must be doubled.
|
||||
"""
|
||||
if not name:
|
||||
return name
|
||||
needs_quote = any(c in name for c in " :\\/?*[]") or " " in name
|
||||
escaped = name.replace("'", "''")
|
||||
return f"'{escaped}'" if needs_quote else escaped
|
||||
|
||||
@classmethod
|
||||
def _rewrite_sheet_ref(cls, ref: str, sheet_name_mapping: Dict[str, str]) -> str:
|
||||
"""Rewrite a single chart <c:f> reference to use translated sheet names.
|
||||
|
||||
Handles both quoted (`'Sheet 1'!A1`) and unquoted (`Sheet1!A1`) forms.
|
||||
If the sheet name is not in the mapping, returns ref unchanged.
|
||||
"""
|
||||
if not sheet_name_mapping or not ref or '!' not in ref:
|
||||
return ref
|
||||
|
||||
if ref.startswith("'"):
|
||||
# Find the closing single quote that ends the sheet name.
|
||||
# Per the ECMA-376 grammar, the inner `'` is escaped as `''`,
|
||||
# so we can safely scan for the next unescaped `'`.
|
||||
i = 1
|
||||
while i < len(ref):
|
||||
if ref[i] == "'" and i + 1 < len(ref) and ref[i + 1] == "'":
|
||||
i += 2 # skip escaped apostrophe
|
||||
continue
|
||||
if ref[i] == "'":
|
||||
break
|
||||
i += 1
|
||||
if i >= len(ref) or ref[i] != "'":
|
||||
return ref
|
||||
sheet_name = ref[1:i]
|
||||
rest = ref[i + 1:] # starts with "!<cellref>"
|
||||
else:
|
||||
bang = ref.index('!')
|
||||
sheet_name = ref[:bang]
|
||||
rest = ref[bang:]
|
||||
|
||||
new_name = sheet_name_mapping.get(sheet_name)
|
||||
if not new_name or new_name == sheet_name:
|
||||
return ref
|
||||
|
||||
return cls._quote_sheet_name_for_ref(new_name) + rest
|
||||
|
||||
@classmethod
|
||||
def _rewrite_sheet_refs_in_chart(
|
||||
cls, chart_xml, sheet_name_mapping: Dict[str, str]
|
||||
) -> int:
|
||||
"""Walk all <c:f> in the chart and rewrite those pointing at renamed sheets.
|
||||
|
||||
Returns the number of references rewritten (for logging/tests).
|
||||
"""
|
||||
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||
rewritten = 0
|
||||
for f_elem in chart_xml.iter(f'{{{_NS_C}}}f'):
|
||||
original = f_elem.text
|
||||
if not original or '!' not in original:
|
||||
continue
|
||||
updated = cls._rewrite_sheet_ref(original, sheet_name_mapping)
|
||||
if updated != original:
|
||||
f_elem.text = updated
|
||||
rewritten += 1
|
||||
return rewritten
|
||||
|
||||
def _translate_images(self, worksheet: Worksheet, target_language: str) -> None:
|
||||
"""
|
||||
Translate text in images using vision model.
|
||||
|
||||
Reference in New Issue
Block a user