Skip to content

Commit e8e3df2

Browse files
committed
fix: render translated style tags and formula placeholders as upstream parses them
Precise (BabelDOC) only parses <style id='N'>...</style> and {vN}. When the LLM translated the tag name (<样式 id='1'>) or requoted it, the tag was typeset as literal text. Rewrite any numeric-id tag to the canonical form and collapse {{vN}} before BabelDOC parses the output, and take the text layer from the parsed composition instead of the raw output. Fast (pdf2zh) only typesets \{\s*v([\d\s]+)\}. Collapse {{vN}} and resolve {v*} when the source paragraph has exactly one formula; otherwise keep the source. The frontend restore now drops unknown formula IDs, matching upstream. Sidecar schema is bumped to 2; cached pages from older schemas whose raw output contains placeholders upstream cannot parse are retranslated on next open.
1 parent 644adff commit e8e3df2

6 files changed

Lines changed: 325 additions & 49 deletions

File tree

‎server/kernels/python/kernel-worker.py‎

Lines changed: 113 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,75 @@ def emit_precise_progress(event):
329329
)
330330

331331

332+
# BabelDOC's OpenAI-style rich-text placeholders are <style id='N'>...</style>, and its
333+
# parser only accepts that literal tag with single quotes. LLMs sometimes translate the
334+
# tag name (<样式 id='1'>) or requote it. Only tags that carry a numeric id are treated as
335+
# placeholders; their closing tags are matched by the same name.
336+
PRECISE_RICH_TEXT_LEFT = re.compile(
337+
r"<\s*([^\s<>/='\"]+)\s+id\s*=\s*(['\"]?)\s*(\d+)\s*\2\s*>", re.IGNORECASE
338+
)
339+
340+
341+
def precise_canonical_rich_text_tags(text):
342+
"""Rewrite placeholder tags to the form BabelDOC's parse_translate_output matches."""
343+
if not isinstance(text, str):
344+
return text
345+
# BabelDOC reads the inner {vN} of {{vN}} and would leave the outer braces as prose.
346+
if "{" in text:
347+
text = re.sub(r"\{+(\s*v\s*\d+\s*)\}+", r"{\1}", text, flags=re.IGNORECASE)
348+
if "<" not in text:
349+
return text
350+
names = {match.group(1) for match in PRECISE_RICH_TEXT_LEFT.finditer(text)}
351+
if not names:
352+
return text
353+
text = PRECISE_RICH_TEXT_LEFT.sub(lambda match: f"<style id='{match.group(3)}'>", text)
354+
closing = "|".join(re.escape(name) for name in sorted(names, key=len, reverse=True))
355+
return re.sub(rf"<\s*/\s*(?:{closing})\s*>", "</style>", text, flags=re.IGNORECASE)
356+
357+
358+
def precise_composition_text(obj):
359+
"""Plain text of a BabelDOC paragraph composition: styled runs and formulas inline."""
360+
if obj is None:
361+
return ""
362+
for name in ("pdf_line", "pdf_formula", "pdf_same_style_characters"):
363+
value = getattr(obj, name, None)
364+
if value is not None:
365+
return "".join(
366+
precise_composition_text(c) for c in getattr(value, "pdf_character", []) or []
367+
)
368+
for name in ("pdf_character", "pdf_same_style_unicode_characters"):
369+
value = getattr(obj, name, None)
370+
if value is not None:
371+
return precise_composition_text(value)
372+
# PdfCharacter stores char_unicode; PdfSameStyleUnicodeCharacters stores unicode.
373+
return getattr(obj, "char_unicode", None) or getattr(obj, "unicode", None) or ""
374+
375+
376+
# pdf2zh's converter reads formulas with this pattern and skips IDs it does not own.
377+
FAST_FORMULA_PLACEHOLDER = re.compile(r"\{\s*v([\d\s]+)\}", re.IGNORECASE)
378+
379+
380+
def fast_formula_placeholders(text, source):
381+
"""Give pdf2zh's typesetter only formula placeholder shapes its parser reads."""
382+
if not isinstance(text, str) or "{" not in text:
383+
return text
384+
# {{v0}} would otherwise typeset its outer braces as prose.
385+
text = re.sub(r"\{+(\s*v[\d\s]+)\}+", r"{\1}", text, flags=re.IGNORECASE)
386+
# {v*} is prompt notation, not an upstream formula ID. Recover only when the
387+
# source paragraph identifies exactly one original run; otherwise keep the source.
388+
wildcard = re.compile(r"\{+\s*v\s*\*\s*\}+", re.IGNORECASE)
389+
if wildcard.search(text):
390+
ids = {
391+
int(match.replace(" ", ""))
392+
for match in FAST_FORMULA_PLACEHOLDER.findall(source or "")
393+
if match.strip()
394+
}
395+
if len(ids) != 1:
396+
return source
397+
text = wildcard.sub(lambda _: "{v%d}" % next(iter(ids)), text)
398+
return text
399+
400+
332401
def fast_paragraph_indent(text, x, x0, language):
333402
"""Replace inherited source indentation with two CJK full-width spaces."""
334403
# Protected tables and formulas are emitted as one placeholder run. Their
@@ -819,27 +888,21 @@ def capture(v):
819888
raw_source, raw_target = v["sstk"][index], v["news"][index]
820889
variables = ["".join(c.get_text() for c in chars) for chars in v["var"]]
821890

891+
# Mirror the typesetter: unknown formula IDs are dropped, as upstream does.
892+
def formula(match):
893+
digits = match.group(1).replace(" ", "")
894+
return variables[int(digits)] if digits and int(digits) < len(variables) else ""
895+
822896
def restore(text):
823-
return re.sub(
824-
r"\{+\s*v(\d+)\s*\}+",
825-
lambda m: variables[int(m[1])] if int(m[1]) < len(variables) else m[0],
826-
text,
827-
)
897+
return FAST_FORMULA_PLACEHOLDER.sub(formula, text)
828898

829899
display_target, _ = fast_paragraph_indent(
830-
raw_target, paragraph.x, paragraph.x0, output_language
900+
fast_formula_placeholders(raw_target, raw_source),
901+
paragraph.x,
902+
paragraph.x0,
903+
output_language,
831904
)
832905
text, translation = restore(raw_source), restore(display_target)
833-
# {v*} is prompt notation, not an upstream formula ID. Recover only
834-
# when the source paragraph identifies exactly one original run.
835-
source_ids = set(int(m) for m in re.findall(r"\{+\s*v(\d+)\s*\}+", raw_source))
836-
wildcard = re.compile(r"\{+\s*v\s*\*\s*\}+", re.IGNORECASE)
837-
if wildcard.search(translation):
838-
if len(source_ids) == 1 and next(iter(source_ids)) < len(variables):
839-
replacement = variables[next(iter(source_ids))]
840-
translation = wildcard.sub(lambda _: replacement, translation)
841-
else:
842-
translation = text
843906
if text.strip():
844907
values = [item for item in v["ops_vals"] if item.get("type").value == "text"]
845908
low = min(
@@ -1012,6 +1075,7 @@ def visit_AnnAssign(self, node):
10121075
if isinstance(node.target, ast.Name) and node.target.id == "cstk":
10131076
self.indent_count += 1
10141077
return [
1078+
*ast.parse("new = _preview_formula_placeholders(new, sstk[id])").body,
10151079
*ast.parse("new, x = _preview_indent(new, x, x0, _preview_language)").body,
10161080
node,
10171081
]
@@ -1063,6 +1127,7 @@ def visit_For(self, node):
10631127
"PDFMATHREADER_PARAGRAPH_CONTINUATION_TOKEN", ""
10641128
),
10651129
"_preview_indent": fast_paragraph_indent,
1130+
"_preview_formula_placeholders": fast_formula_placeholders,
10661131
"_preview_wrap_advance": fast_typography_advance,
10671132
"_preview_italic_prose": fast_italic_prose_characters,
10681133
"_preview_identity": id,
@@ -1098,23 +1163,12 @@ def visit_For(self, node):
10981163
from babeldoc.format.pdf.document_il.midend.typesetting import Typesetting
10991164

11001165
pending = []
1166+
# Parsed translation text per paragraph, taken before typesetting splits it into lines.
1167+
parsed = {}
11011168

11021169
def values(rect):
11031170
return (rect.x, rect.y, rect.x2, rect.y2)
11041171

1105-
def content(obj):
1106-
if obj is None:
1107-
return ""
1108-
for name in ("pdf_line", "pdf_formula", "pdf_same_style_characters"):
1109-
value = getattr(obj, name, None)
1110-
if value is not None:
1111-
return "".join(content(c) for c in getattr(value, "pdf_character", []))
1112-
for name in ("pdf_character", "pdf_same_style_unicode_characters"):
1113-
value = getattr(obj, name, None)
1114-
if value is not None:
1115-
return getattr(value, "unicode", "") or ""
1116-
return getattr(obj, "unicode", "") or ""
1117-
11181172
def wrap_translate(original):
11191173
def translate(self, docs):
11201174
for page in docs.page:
@@ -1146,24 +1200,47 @@ def translate(self, docs):
11461200

11471201
ILTranslator.translate = wrap_translate(ILTranslator.translate)
11481202
ILTranslatorLLMOnly.translate = wrap_translate(ILTranslatorLLMOnly.translate)
1203+
original_parse_output = ILTranslator.parse_translate_output
1204+
1205+
# Both translators funnel LLM output through this parser; it typesets each
1206+
# <style id='N'> run with that source span's original PDF style.
1207+
def parse_translate_output(self, input_text, output, *positional, **kwargs):
1208+
return original_parse_output(
1209+
self,
1210+
input_text,
1211+
precise_canonical_rich_text_tags(output),
1212+
*positional,
1213+
**kwargs,
1214+
)
1215+
1216+
ILTranslator.parse_translate_output = parse_translate_output
1217+
original_post_translate = ILTranslator.post_translate_paragraph
1218+
1219+
def post_translate_paragraph(self, paragraph, *positional, **kwargs):
1220+
changed = original_post_translate(self, paragraph, *positional, **kwargs)
1221+
if changed:
1222+
parsed[id(paragraph)] = "".join(
1223+
precise_composition_text(c) for c in paragraph.pdf_paragraph_composition
1224+
)
1225+
return changed
1226+
1227+
ILTranslator.post_translate_paragraph = post_translate_paragraph
11491228
original_typeset = Typesetting.typesetting_document
11501229

11511230
def typeset(self, docs):
11521231
result = original_typeset(self, docs)
11531232
for para, record in pending:
11541233
record["translationOutput"] = para.unicode
1155-
record["translation"] = (
1156-
"".join(content(c) for c in para.pdf_paragraph_composition)
1157-
or para.unicode
1158-
or record["text"]
1159-
)
1234+
# The PDF shows the parsed runs; para.unicode still holds the raw placeholders.
1235+
record["translation"] = parsed.get(id(para)) or para.unicode or record["text"]
11601236
record["translatedBox"] = box(values(para.box))
11611237
records.append(record)
11621238
pending.clear()
1239+
parsed.clear()
11631240
Path(sidecar).write_text(
11641241
json.dumps(
11651242
{
1166-
"schema": 1,
1243+
"schema": 2,
11671244
"engine": kind,
11681245
"page": selected,
11691246
"width": source_page.rect.width,
@@ -1208,7 +1285,7 @@ async def progress_stream(*positional, **kwargs):
12081285
Path(sidecar).write_text(
12091286
json.dumps(
12101287
{
1211-
"schema": 1,
1288+
"schema": 2,
12121289
"engine": kind,
12131290
"page": selected,
12141291
"width": source_page.rect.width,

‎server/kernels/translation-runner.mjs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createTranslationCache } from '../translation/translation-cache.mjs';
77
import { buildKernelServiceConfig } from './kernel-services.mjs';
88
import { redactDiagnosticText } from '../diagnostics/developer-diagnostics.mjs';
99
import { createRemoteKernel, normalizeRemoteKernelUrl } from './remote-kernel.mjs';
10+
import { hasUnparsedPlaceholders } from '../../shared/translation/rich-text-placeholders.mjs';
1011
import {
1112
isTranslationLanguageSupported,
1213
translationLanguageCode,
@@ -185,11 +186,16 @@ export function createTranslationRunner({
185186
readFile(join(cacheDir, saved.key + '.pdf')),
186187
readFile(join(cacheDir, saved.key + '.layout.json'), 'utf8'),
187188
]);
189+
const { paragraphs, schema } = JSON.parse(metadata);
190+
if (!result.subarray(0, 1024).includes(Buffer.from('%PDF')) || !Array.isArray(paragraphs))
191+
throw Error('Invalid cached page');
192+
// Before schema 2, placeholders upstream cannot parse (<样式 id='N'>, {v*}, {{vN}})
193+
// were typeset literally into the PDF; translate those pages again.
188194
if (
189-
!result.subarray(0, 1024).includes(Buffer.from('%PDF')) ||
190-
!Array.isArray(JSON.parse(metadata).paragraphs)
195+
schema !== 2 &&
196+
paragraphs.some((paragraph) => hasUnparsedPlaceholders(paragraph?.translationOutput))
191197
)
192-
throw Error('Invalid cached page');
198+
throw Error('Cached page contains unparsed placeholders');
193199
if (indexPath === compatibleRequestPath)
194200
await writeIndex(requestPath, {
195201
key: saved.key,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// BabelDOC wraps differently styled source runs as <style id='N'>...</style>. An LLM can
2+
// translate the tag name (<样式 id='1'>), which BabelDOC then leaves in the output text.
3+
const OPENING = /<\s*([^\s<>/='"]+)\s+id\s*=\s*(['"]?)\s*\d+\s*\2\s*>/giu;
4+
5+
export function hasRichTextPlaceholders(text) {
6+
return typeof text === 'string' && new RegExp(OPENING.source, 'iu').test(text);
7+
}
8+
9+
// Drops placeholder tags and keeps their text, as BabelDOC does for unmatched tokens.
10+
export function stripRichTextPlaceholders(text) {
11+
if (!hasRichTextPlaceholders(text)) return text;
12+
const names = new Set([...text.matchAll(OPENING)].map((match) => match[1].toLowerCase()));
13+
return text
14+
.replace(OPENING, '')
15+
.replace(/<\s*\/\s*([^\s<>/='"]+)\s*>/gu, (tag, name) =>
16+
names.has(name.toLowerCase()) ? '' : tag,
17+
);
18+
}
19+
20+
// Upstream parsers read only <style id='N'> (BabelDOC) and {vN} (pdf2zh). Raw output with
21+
// any other placeholder shape was typeset literally by sidecar schemas before 2.
22+
const CANONICAL_STYLE = /^<\s*style\s*id\s*=\s*'\s*\d+\s*'\s*>$/iu;
23+
const MALFORMED_FORMULA = /\{\s*v\s*\*\s*\}|\{\s*\{\s*v[\d\s]+\}\s*\}/iu;
24+
25+
export function hasUnparsedPlaceholders(text) {
26+
if (typeof text !== 'string') return false;
27+
if (MALFORMED_FORMULA.test(text)) return true;
28+
return [...text.matchAll(OPENING)].some(([tag]) => !CANONICAL_STYLE.test(tag));
29+
}
Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
1+
import { stripRichTextPlaceholders } from '../../../shared/translation/rich-text-placeholders.mjs';
2+
13
// Upstream vN tokens refer to original formula/font runs, not literal prose.
24
export function restoreFormulaPlaceholders(block) {
5+
if (typeof block.translation === 'string')
6+
block = { ...block, translation: stripRichTextPlaceholders(block.translation) };
37
const formulas = block.formulaTexts;
48
if (!Array.isArray(formulas) || typeof block.translation !== 'string') return block;
5-
const token = /\{+\s*v\s*(\d+|\*)\s*\}+/gi;
9+
// pdf2zh reads {vN} with spaces allowed inside the digits; doubled braces are one token.
10+
const token = /\{+\s*v\s*([\d\s]+|\*)\s*\}+/gi;
611
const source = typeof block.sourceInput === 'string' ? block.sourceInput : '';
712
const ids = [
8-
...new Set([...source.matchAll(token)].filter((m) => m[1] !== '*').map((m) => Number(m[1]))),
13+
...new Set(
14+
[...source.matchAll(token)]
15+
.filter((m) => m[1] !== '*' && m[1].trim())
16+
.map((m) => Number(m[1].replace(/\s/g, ''))),
17+
),
918
];
1019
const wildcardId = ids.length === 1 ? ids[0] : undefined;
11-
let unresolved = false;
20+
let ambiguous = false;
1221
const translation = block.translation.replace(token, (match, id) => {
13-
const index = id === '*' ? wildcardId : Number(id);
14-
if (index === undefined || typeof formulas[index] !== 'string') {
15-
unresolved = true;
16-
return match;
22+
if (id === '*') {
23+
if (wildcardId === undefined) {
24+
ambiguous = true;
25+
return match;
26+
}
27+
return formulas[wildcardId] ?? '';
1728
}
18-
return formulas[index];
29+
// Upstream skips formula IDs it does not own rather than printing the token.
30+
const formula = formulas[Number(id.replace(/\s/g, ''))];
31+
return typeof formula === 'string' ? formula : '';
1932
});
20-
// A malformed wildcard is ambiguous when multiple original runs exist.
33+
// A wildcard is ambiguous when multiple original runs exist.
2134
// Preserve the original paragraph rather than invent or discard its content.
22-
return { ...block, translation: unresolved ? block.text || block.translation : translation };
35+
return { ...block, translation: ambiguous ? block.text || block.translation : translation };
2336
}

0 commit comments

Comments
 (0)