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

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:
2026-07-15 22:04:40 +02:00
parent 8f96ddfe71
commit 07c4c12e6a
10 changed files with 351 additions and 23 deletions

View File

@@ -18,6 +18,7 @@ export function DashboardSidebar() {
const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const isPaid = !!user?.tier && user.tier !== 'free';
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return (
@@ -68,7 +69,8 @@ export function DashboardSidebar() {
</div>
)}
{/* Memento promo section */}
{/* Memento promo section — hidden for paying users */}
{!isPaid && (
<a
href={t('memento.url', { defaultValue: 'https://memento-note.com/' })}
target="_blank"
@@ -88,6 +90,7 @@ export function DashboardSidebar() {
</button>
</div>
</a>
)}
{/* User section */}
<div className="border-t border-black/5 dark:border-white/5">

View File

@@ -1,4 +1,4 @@
import { FileText, Key, BookText, User, type LucideIcon } from 'lucide-react';
import { FileText, BookText, User, type LucideIcon } from 'lucide-react';
export interface NavItem {
labelKey: string;
@@ -11,5 +11,6 @@ export const baseNavItems: NavItem[] = [
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
{ labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true },
{ labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
// API Keys nav item temporarily removed per request — uncomment to restore.
// { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
];

View File

@@ -20,6 +20,7 @@ import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
import { API_BASE } from '@/lib/config';
import { cn } from '@/lib/utils';
import { useUser } from '../useUser';
/* ── helpers ─────────────────────────────────────────────────────── */
const FILE_ICONS: Record<string, React.ElementType> = {
@@ -74,6 +75,8 @@ export default function TranslatePage() {
const submit = useTranslationSubmit();
const { error: showError } = useNotification();
const { t } = useI18n();
const { data: currentUser } = useUser();
const isPaid = !!currentUser?.tier && currentUser.tier !== 'free';
const lastErrorRef = useRef<string | null>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
const dropzoneInputRef = useRef<HTMLInputElement>(null);
@@ -742,8 +745,8 @@ export default function TranslatePage() {
</div>
</div>
{/* ── MEMENTO PROMO BANNER ──────────────────────────────── */}
{(showUpload || showConfiguring || showFailed) && (
{/* ── MEMENTO PROMO BANNER — hidden for paying users ────── */}
{!isPaid && (showUpload || showConfiguring || showFailed) && (
<a
href={t('memento.url', { defaultValue: 'https://memento-note.com/' })}
target="_blank"

View File

@@ -10,10 +10,15 @@ describe('baseNavItems', () => {
});
});
it('should flag glossaries and apiKeys as proOnly', () => {
it('should flag glossaries as proOnly', () => {
const glossaries = baseNavItems.find(item => item.href === '/dashboard/glossaries');
const apiKeys = baseNavItems.find(item => item.href === '/dashboard/api-keys');
expect(glossaries?.proOnly).toBe(true);
expect(apiKeys?.proOnly).toBe(true);
});
it('should currently omit apiKeys nav item (temporarily removed)', () => {
// The apiKeys nav item is temporarily commented out in constants.ts.
// If you re-enable it, update this test to assert its presence.
const apiKeys = baseNavItems.find(item => item.href === '/dashboard/api-keys');
expect(apiKeys).toBeUndefined();
});
});

View File

@@ -31,8 +31,11 @@ Rules:
- Translate ONLY the text, do not add explanations or notes
- Preserve the original formatting, line breaks, and structure
- Maintain the original tone and style
- For technical terms, use the standard translation in the target language
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""
- Translate technical terms, jargon, and labels using the standard target-language equivalent
- Chart elements (titles, axis labels, legend entries, category labels, series names) MUST always be translated, even when they look like a title or a proper noun
- Translate month and weekday abbreviations (Jan → janvier, Mon → lundi) to the target language
- Keep ONLY real proper nouns unchanged: people's names, place names, company names (Google, Microsoft), and product names (GitHub, HuggingFace)
- Do not invent content; if a term is an acronym with no translation (API, URL, HTTP, JSON), keep it as-is"""
def _get_language_name(code: str) -> str:

View File

@@ -31,8 +31,11 @@ Rules:
- Translate ONLY the text, do not add explanations or notes
- Preserve the original formatting, line breaks, and structure
- Maintain the original tone and style
- For technical terms, use the standard translation in the target language
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""
- Translate technical terms, jargon, and labels using the standard target-language equivalent
- Chart elements (titles, axis labels, legend entries, category labels, series names) MUST always be translated, even when they look like a title or a proper noun
- Translate month and weekday abbreviations (Jan → janvier, Mon → lundi) to the target language
- Keep ONLY real proper nouns unchanged: people's names, place names, company names (Google, Microsoft), and product names (GitHub, HuggingFace)
- Do not invent content; if a term is an acronym with no translation (API, URL, HTTP, JSON), keep it as-is"""
def _get_language_name(code: str) -> str:

View File

@@ -101,8 +101,11 @@ Rules:
- Translate ONLY the text, do not add explanations or notes
- Preserve the original formatting, line breaks, and structure
- Maintain the original tone and style
- For technical terms, use the standard translation in the target language
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""
- Translate technical terms, jargon, and labels using the standard target-language equivalent
- Chart elements (titles, axis labels, legend entries, category labels, series names) MUST always be translated, even when they look like a title or a proper noun
- Translate month and weekday abbreviations (Jan → janvier, Mon → lundi) to the target language
- Keep ONLY real proper nouns unchanged: people's names, place names, company names (Google, Microsoft), and product names (GitHub, HuggingFace)
- Do not invent content; if a term is an acronym with no translation (API, URL, HTTP, JSON), keep it as-is"""
def _build_system_prompt(

View File

@@ -0,0 +1,50 @@
"""Verify all 3 provider prompts are identical and contain chart-translation rules.
This is a regression test for the bug where chart titles/legends/category labels
were left untranslated because the system prompt was too ambiguous about
"proper nouns or brand names".
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from services.providers.deepseek_provider import DEFAULT_TRANSLATION_PROMPT as P1
from services.providers.minimax_provider import DEFAULT_TRANSLATION_PROMPT as P3
from services.providers.openai_provider import DEFAULT_TRANSLATION_PROMPT as P2
REQUIRED_PHRASES = [
"Chart elements",
"MUST always be translated",
"Translate month and weekday abbreviations",
"Jan",
"janvier",
"Keep ONLY real proper nouns",
"Google, Microsoft",
"API, URL, HTTP, JSON",
]
def test_all_prompts_identical():
assert P1 == P2 == P3, "All 3 provider prompts must be identical"
def test_prompt_contains_required_rules():
for phrase in REQUIRED_PHRASES:
assert phrase in P1, f"Missing rule: {phrase!r}"
def test_prompt_drops_old_ambiguous_rule():
# The old "If the text contains proper nouns or brand names" rule was
# the root cause of chart labels not being translated. The new prompt
# uses a more specific rule that lists what counts as a proper noun.
assert "If the text contains proper nouns or brand names" not in P1, (
"Old ambiguous rule still present — chart labels will be skipped again"
)
if __name__ == "__main__":
test_all_prompts_identical()
test_prompt_contains_required_rules()
test_prompt_drops_old_ambiguous_rule()
print("All prompt regression tests passed.")

View File

@@ -0,0 +1,138 @@
"""Regression tests for the bug where translated Excel chart <c:f> references
kept pointing at the original (now-renamed) sheet name, causing the chart
to render as zero/empty series after translation.
This is a real bug: see e.g. test_excel.xlsx where the chart references
'Sales 2024'!$D$2:$D$61 but the sheet is renamed to "Chiffre d'affaires
par produit" — the chart then shows 0 for every data point.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from lxml import etree
from translators.excel_translator import ExcelTranslator
NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
def _build_chart_xml(series_refs):
"""Helper: build a chart XML with the given <c:f> strings."""
sers = "\n".join(
f"<c:ser><c:tx><c:strRef><c:f>{ref}</c:f></c:strRef></c:tx>"
f"<c:cat><c:strRef><c:f>{ref}</c:f></c:strRef></c:cat>"
f"<c:val><c:numRef><c:f>{ref}</c:f></c:numRef></c:val></c:ser>"
for ref in series_refs
)
return f"""<c:chartSpace xmlns:c="{NS_C}" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<c:chart>
<c:plotArea><c:lineChart>{sers}</c:lineChart></c:plotArea>
</c:chart>
</c:chartSpace>"""
def test_quoted_sheet_name_with_apostrophe_in_new_name():
r = ExcelTranslator._rewrite_sheet_ref(
"'Sales 2024'!$D$2:$D$61",
{"Sales 2024": "Chiffre d'affaires par produit"},
)
assert r == "'Chiffre d''affaires par produit'!$D$2:$D$61", f"got: {r!r}"
def test_unquoted_sheet_name_simple():
r = ExcelTranslator._rewrite_sheet_ref(
"Sheet1!$A$1",
{"Sheet1": "Feuille1"},
)
assert r == "Feuille1!$A$1", f"got: {r!r}"
def test_new_name_with_space_gets_quoted():
r = ExcelTranslator._rewrite_sheet_ref(
"Sheet1!$A$1",
{"Sheet1": "Ma Feuille"},
)
assert r == "'Ma Feuille'!$A$1", f"got: {r!r}"
def test_name_not_in_mapping_unchanged():
r = ExcelTranslator._rewrite_sheet_ref(
"Other!$A$1",
{"Sheet1": "Feuille1"},
)
assert r == "Other!$A$1", f"got: {r!r}"
def test_empty_mapping_is_noop():
r = ExcelTranslator._rewrite_sheet_ref("Sheet1!$A$1", {})
assert r == "Sheet1!$A$1", f"got: {r!r}"
def test_no_bang_is_noop():
r = ExcelTranslator._rewrite_sheet_ref("just_a_label", {"x": "y"})
assert r == "just_a_label", f"got: {r!r}"
def test_real_chart_xml_all_refs_rewritten():
chart_xml = etree.fromstring(_build_chart_xml(["Sheet1!$B$1"]))
n = ExcelTranslator._rewrite_sheet_refs_in_chart(chart_xml, {"Sheet1": "Ma Feuille"})
assert n == 3, f"expected 3 rewrites, got {n}"
refs = [f.text for f in chart_xml.iter(f"{{{NS_C}}}f")]
assert refs == [
"'Ma Feuille'!$B$1",
"'Ma Feuille'!$B$1",
"'Ma Feuille'!$B$1",
], f"got: {refs}"
def test_real_chart_xml_apostrophe_escaped_in_new_name():
chart_xml = etree.fromstring(_build_chart_xml(["Sheet1!$B$1"]))
n = ExcelTranslator._rewrite_sheet_refs_in_chart(
chart_xml, {"Sheet1": "Chiffre d'affaires"}
)
assert n == 3
refs = [f.text for f in chart_xml.iter(f"{{{NS_C}}}f")]
assert all(r == "'Chiffre d''affaires'!$B$1" for r in refs), f"got: {refs}"
def test_partial_mapping_leaves_other_refs_intact():
"""When only one sheet is renamed, refs to other sheets must stay."""
chart_xml = etree.fromstring(
_build_chart_xml(["Sheet1!$A$1", "Sheet2!$B$1"])
)
n = ExcelTranslator._rewrite_sheet_refs_in_chart(
chart_xml, {"Sheet1": "Feuille1"}
)
# 3 refs to Sheet1 (one series) are rewritten; 3 refs to Sheet2 stay.
assert n == 3, f"expected 3 rewrites, got {n}"
refs = [f.text for f in chart_xml.iter(f"{{{NS_C}}}f")]
assert "Feuille1!$A$1" in refs
assert "Sheet2!$B$1" in refs
def test_multiple_series_renamed():
"""When several sheets are renamed, refs to any of them get updated."""
chart_xml = etree.fromstring(
_build_chart_xml(["Sheet1!$A$1", "Sheet2!$B$1", "Sheet3!$C$1"])
)
n = ExcelTranslator._rewrite_sheet_refs_in_chart(
chart_xml,
{"Sheet1": "F1", "Sheet2": "F2", "Sheet3": "F3"},
)
assert n == 9, f"expected 9 rewrites (3 series x 3 refs each), got {n}"
if __name__ == "__main__":
test_quoted_sheet_name_with_apostrophe_in_new_name()
test_unquoted_sheet_name_simple()
test_new_name_with_space_gets_quoted()
test_name_not_in_mapping_unchanged()
test_empty_mapping_is_noop()
test_no_bang_is_noop()
test_real_chart_xml_all_refs_rewritten()
test_real_chart_xml_apostrophe_escaped_in_new_name()
test_partial_mapping_leaves_other_refs_intact()
test_multiple_series_renamed()
print("All chart sheet-ref rewrite tests passed.")

View File

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