@@ -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+
332401def 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 ,
0 commit comments