diff --git a/frontend/src/app/dashboard/DashboardSidebar.tsx b/frontend/src/app/dashboard/DashboardSidebar.tsx
index 53939af..46b89ab 100644
--- a/frontend/src/app/dashboard/DashboardSidebar.tsx
+++ b/frontend/src/app/dashboard/DashboardSidebar.tsx
@@ -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() {
)}
- {/* Memento promo section */}
+ {/* Memento promo section — hidden for paying users */}
+ {!isPaid && (
+ )}
{/* User section */}
diff --git a/frontend/src/app/dashboard/constants.ts b/frontend/src/app/dashboard/constants.ts
index cc98dc9..a46cf7e 100644
--- a/frontend/src/app/dashboard/constants.ts
+++ b/frontend/src/app/dashboard/constants.ts
@@ -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 },
];
diff --git a/frontend/src/app/dashboard/translate/page.tsx b/frontend/src/app/dashboard/translate/page.tsx
index ee27b54..0f91891 100644
--- a/frontend/src/app/dashboard/translate/page.tsx
+++ b/frontend/src/app/dashboard/translate/page.tsx
@@ -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 = {
@@ -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(null);
const replaceInputRef = useRef(null);
const dropzoneInputRef = useRef(null);
@@ -742,8 +745,8 @@ export default function TranslatePage() {
- {/* ── MEMENTO PROMO BANNER ──────────────────────────────── */}
- {(showUpload || showConfiguring || showFailed) && (
+ {/* ── MEMENTO PROMO BANNER — hidden for paying users ────── */}
+ {!isPaid && (showUpload || showConfiguring || showFailed) && (
{
});
});
- 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();
});
});
diff --git a/services/providers/deepseek_provider.py b/services/providers/deepseek_provider.py
index 1699e20..b73f5af 100644
--- a/services/providers/deepseek_provider.py
+++ b/services/providers/deepseek_provider.py
@@ -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:
diff --git a/services/providers/minimax_provider.py b/services/providers/minimax_provider.py
index 06af4ae..495e1bc 100644
--- a/services/providers/minimax_provider.py
+++ b/services/providers/minimax_provider.py
@@ -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:
diff --git a/services/providers/openai_provider.py b/services/providers/openai_provider.py
index 3520893..02cd9e1 100644
--- a/services/providers/openai_provider.py
+++ b/services/providers/openai_provider.py
@@ -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(
diff --git a/tests/test_chart_translation_prompt.py b/tests/test_chart_translation_prompt.py
new file mode 100644
index 0000000..b8ecb31
--- /dev/null
+++ b/tests/test_chart_translation_prompt.py
@@ -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.")
diff --git a/tests/test_excel_chart_sheet_refs.py b/tests/test_excel_chart_sheet_refs.py
new file mode 100644
index 0000000..4722356
--- /dev/null
+++ b/tests/test_excel_chart_sheet_refs.py
@@ -0,0 +1,138 @@
+"""Regression tests for the bug where translated Excel chart 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 strings."""
+ sers = "\n".join(
+ f"{ref}"
+ f"{ref}"
+ f"{ref}"
+ for ref in series_refs
+ )
+ return f"""
+
+ {sers}
+
+"""
+
+
+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.")
diff --git a/translators/excel_translator.py b/translators/excel_translator.py
index b1b8fa6..9f8de44 100644
--- a/translators/excel_translator.py
+++ b/translators/excel_translator.py
@@ -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 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 `` 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 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 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 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 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 "!"
+ 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 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.