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 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">
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
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:
|
||||
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