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:
@@ -18,6 +18,7 @@ export function DashboardSidebar() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
|
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
|
||||||
|
const isPaid = !!user?.tier && user.tier !== 'free';
|
||||||
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
|
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,7 +69,8 @@ export function DashboardSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Memento promo section */}
|
{/* Memento promo section — hidden for paying users */}
|
||||||
|
{!isPaid && (
|
||||||
<a
|
<a
|
||||||
href={t('memento.url', { defaultValue: 'https://memento-note.com/' })}
|
href={t('memento.url', { defaultValue: 'https://memento-note.com/' })}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -88,6 +90,7 @@ export function DashboardSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User section */}
|
{/* User section */}
|
||||||
<div className="border-t border-black/5 dark:border-white/5">
|
<div className="border-t border-black/5 dark:border-white/5">
|
||||||
|
|||||||
@@ -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 {
|
export interface NavItem {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -11,5 +11,6 @@ export const baseNavItems: NavItem[] = [
|
|||||||
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
||||||
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
|
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
|
||||||
{ labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true },
|
{ 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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useNotification } from '@/components/ui/notification';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useUser } from '../useUser';
|
||||||
|
|
||||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||||
const FILE_ICONS: Record<string, React.ElementType> = {
|
const FILE_ICONS: Record<string, React.ElementType> = {
|
||||||
@@ -74,6 +75,8 @@ export default function TranslatePage() {
|
|||||||
const submit = useTranslationSubmit();
|
const submit = useTranslationSubmit();
|
||||||
const { error: showError } = useNotification();
|
const { error: showError } = useNotification();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { data: currentUser } = useUser();
|
||||||
|
const isPaid = !!currentUser?.tier && currentUser.tier !== 'free';
|
||||||
const lastErrorRef = useRef<string | null>(null);
|
const lastErrorRef = useRef<string | null>(null);
|
||||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropzoneInputRef = useRef<HTMLInputElement>(null);
|
const dropzoneInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -742,8 +745,8 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── MEMENTO PROMO BANNER ──────────────────────────────── */}
|
{/* ── MEMENTO PROMO BANNER — hidden for paying users ────── */}
|
||||||
{(showUpload || showConfiguring || showFailed) && (
|
{!isPaid && (showUpload || showConfiguring || showFailed) && (
|
||||||
<a
|
<a
|
||||||
href={t('memento.url', { defaultValue: 'https://memento-note.com/' })}
|
href={t('memento.url', { defaultValue: 'https://memento-note.com/' })}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -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 glossaries = baseNavItems.find(item => item.href === '/dashboard/glossaries');
|
||||||
const apiKeys = baseNavItems.find(item => item.href === '/dashboard/api-keys');
|
|
||||||
expect(glossaries?.proOnly).toBe(true);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ Rules:
|
|||||||
- Translate ONLY the text, do not add explanations or notes
|
- Translate ONLY the text, do not add explanations or notes
|
||||||
- Preserve the original formatting, line breaks, and structure
|
- Preserve the original formatting, line breaks, and structure
|
||||||
- Maintain the original tone and style
|
- Maintain the original tone and style
|
||||||
- For technical terms, use the standard translation in the target language
|
- Translate technical terms, jargon, and labels using the standard target-language equivalent
|
||||||
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""
|
- 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:
|
def _get_language_name(code: str) -> str:
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ Rules:
|
|||||||
- Translate ONLY the text, do not add explanations or notes
|
- Translate ONLY the text, do not add explanations or notes
|
||||||
- Preserve the original formatting, line breaks, and structure
|
- Preserve the original formatting, line breaks, and structure
|
||||||
- Maintain the original tone and style
|
- Maintain the original tone and style
|
||||||
- For technical terms, use the standard translation in the target language
|
- Translate technical terms, jargon, and labels using the standard target-language equivalent
|
||||||
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""
|
- 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:
|
def _get_language_name(code: str) -> str:
|
||||||
|
|||||||
@@ -101,8 +101,11 @@ Rules:
|
|||||||
- Translate ONLY the text, do not add explanations or notes
|
- Translate ONLY the text, do not add explanations or notes
|
||||||
- Preserve the original formatting, line breaks, and structure
|
- Preserve the original formatting, line breaks, and structure
|
||||||
- Maintain the original tone and style
|
- Maintain the original tone and style
|
||||||
- For technical terms, use the standard translation in the target language
|
- Translate technical terms, jargon, and labels using the standard target-language equivalent
|
||||||
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""
|
- 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(
|
def _build_system_prompt(
|
||||||
|
|||||||
50
tests/test_chart_translation_prompt.py
Normal file
50
tests/test_chart_translation_prompt.py
Normal 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.")
|
||||||
138
tests/test_excel_chart_sheet_refs.py
Normal file
138
tests/test_excel_chart_sheet_refs.py
Normal 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.")
|
||||||
@@ -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:
|
for sheet_name in sheet_names_to_translate:
|
||||||
text_elements.append((sheet_name, None))
|
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)
|
self._collect_charts_from_zip(input_path, text_elements, chart_translations)
|
||||||
|
|
||||||
if text_elements:
|
if text_elements:
|
||||||
texts = [elem[0] for elem in text_elements]
|
texts = [elem[0] for elem in text_elements]
|
||||||
total_texts = len(texts)
|
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(
|
_log_info(
|
||||||
"excel_batch_translation_start",
|
"excel_batch_translation_start",
|
||||||
@@ -317,9 +325,16 @@ class ExcelTranslator:
|
|||||||
details={"file_name": output_path.name, "error": str(e)},
|
details={"file_name": output_path.name, "error": str(e)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Re-inject chart translations into the .xlsx ZIP
|
# Re-inject chart translations into the .xlsx ZIP.
|
||||||
if chart_translations:
|
# We always pass the sheet_name_mapping so chart <c:f> references
|
||||||
self._apply_chart_translations(output_path, chart_translations)
|
# 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()
|
workbook.close()
|
||||||
|
|
||||||
@@ -739,18 +754,27 @@ class ExcelTranslator:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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.
|
"""Re-inject chart translations into the .xlsx ZIP.
|
||||||
|
|
||||||
Uses the `element_path` collected during `_collect_charts_from_zip`
|
Uses the `element_path` collected during `_collect_charts_from_zip`
|
||||||
to target each element precisely. Falls back to string matching
|
to target each element precisely. Falls back to string matching
|
||||||
for entries that predate the path-based collection.
|
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
|
return
|
||||||
|
|
||||||
translated_entries = [e for e in chart_translations if 'translated' in e and e['translated']]
|
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
|
return
|
||||||
|
|
||||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
_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] = []
|
||||||
chart_files_to_update[cf].append(entry)
|
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:
|
try:
|
||||||
with zipfile.ZipFile(output_path, 'r') as zf_in:
|
with zipfile.ZipFile(output_path, 'r') as zf_in:
|
||||||
existing_entries = zf_in.namelist()
|
existing_entries = zf_in.namelist()
|
||||||
@@ -789,6 +825,14 @@ class ExcelTranslator:
|
|||||||
if v_elem.text and v_elem.text.strip() == entry['original']:
|
if v_elem.text and v_elem.text.strip() == entry['original']:
|
||||||
v_elem.text = entry['translated']
|
v_elem.text = entry['translated']
|
||||||
break
|
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)
|
data = etree.tostring(chart_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log_error("excel_chart_update_error", chart_file=item, error=str(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:
|
with open(output_path, 'wb') as f:
|
||||||
f.write(buf.getvalue())
|
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:
|
except Exception as e:
|
||||||
_log_error("excel_chart_zip_rewrite_error", error=str(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:
|
def _translate_images(self, worksheet: Worksheet, target_language: str) -> None:
|
||||||
"""
|
"""
|
||||||
Translate text in images using vision model.
|
Translate text in images using vision model.
|
||||||
|
|||||||
Reference in New Issue
Block a user