BabelDOC 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {babeldoc-0.2.0 → babeldoc-0.2.2}/PKG-INFO +3 -1
  2. babeldoc-0.2.2/babeldoc/__init__.py +1 -0
  3. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/assets/assets.py +12 -0
  4. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/assets/embedding_assets_metadata.py +3 -0
  5. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/const.py +6 -1
  6. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/il_version_1.py +6 -0
  7. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/il_version_1.rnc +1 -0
  8. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/il_version_1.rng +5 -0
  9. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/il_version_1.xsd +1 -0
  10. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/midend/il_translator.py +60 -36
  11. babeldoc-0.2.2/babeldoc/document_il/midend/il_translator_llm_only.py +375 -0
  12. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/midend/paragraph_finder.py +7 -0
  13. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/midend/styles_and_formulas.py +4 -4
  14. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/translator/translator.py +65 -0
  15. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/utils/fontmap.py +1 -1
  16. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/docvision/rpc_doclayout.py +4 -4
  17. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/high_level.py +23 -1
  18. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/main.py +1 -1
  19. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/progress_monitor.py +43 -20
  20. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/CONTRIBUTING.md +3 -3
  21. {babeldoc-0.2.0 → babeldoc-0.2.2}/pyproject.toml +4 -2
  22. {babeldoc-0.2.0 → babeldoc-0.2.2}/uv.lock +235 -1
  23. babeldoc-0.2.0/babeldoc/__init__.py +0 -1
  24. {babeldoc-0.2.0 → babeldoc-0.2.2}/.cursorignore +0 -0
  25. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yaml +0 -0
  26. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yaml +0 -0
  27. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/dependabot.yml +0 -0
  28. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/labels.yml +0 -0
  29. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/release-drafter.yml +0 -0
  30. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/workflows/codeql.yml +0 -0
  31. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/workflows/docs.yml +0 -0
  32. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/workflows/labeler.yml +0 -0
  33. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/workflows/publish-to-pypi.yml +0 -0
  34. {babeldoc-0.2.0 → babeldoc-0.2.2}/.github/workflows/test.yml +0 -0
  35. {babeldoc-0.2.0 → babeldoc-0.2.2}/.gitignore +0 -0
  36. {babeldoc-0.2.0 → babeldoc-0.2.2}/.pre-commit-config.yaml +0 -0
  37. {babeldoc-0.2.0 → babeldoc-0.2.2}/LICENSE +0 -0
  38. {babeldoc-0.2.0 → babeldoc-0.2.2}/README.md +0 -0
  39. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/asynchronize/__init__.py +0 -0
  40. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/converter.py +0 -0
  41. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/__init__.py +0 -0
  42. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/babeldoc_exception/BabelDOCException.py +0 -0
  43. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/backend/__init__.py +0 -0
  44. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/backend/pdf_creater.py +0 -0
  45. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/frontend/__init__.py +0 -0
  46. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/frontend/il_creater.py +0 -0
  47. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/midend/__init__.py +0 -0
  48. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/midend/add_debug_information.py +0 -0
  49. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/midend/detect_scanned_file.py +0 -0
  50. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/midend/layout_parser.py +0 -0
  51. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/midend/remove_descent.py +0 -0
  52. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/midend/typesetting.py +0 -0
  53. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/translator/__init__.py +0 -0
  54. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/translator/cache.py +0 -0
  55. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/utils/layout_helper.py +0 -0
  56. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/utils/style_helper.py +0 -0
  57. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/document_il/xml_converter.py +0 -0
  58. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/docvision/README.md +0 -0
  59. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/docvision/__init__.py +0 -0
  60. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/docvision/doclayout.py +0 -0
  61. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/pdfinterp.py +0 -0
  62. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/tools/generate_font_metadata.py +0 -0
  63. {babeldoc-0.2.0 → babeldoc-0.2.2}/babeldoc/translation_config.py +0 -0
  64. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/CODE_OF_CONDUCT.md +0 -0
  65. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/CONTRIBUTOR_REWARD.md +0 -0
  66. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/ImplementationDetails/AsyncTranslate/AsyncTranslate.md +0 -0
  67. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/ImplementationDetails/ILTranslator/ILTranslator.md +0 -0
  68. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/ImplementationDetails/PDFCreation/PDFCreation.md +0 -0
  69. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/ImplementationDetails/PDFParsing/PDFParsing.md +0 -0
  70. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/ImplementationDetails/ParagraphFinding/ParagraphFinding.md +0 -0
  71. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/ImplementationDetails/README.md +0 -0
  72. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/ImplementationDetails/StylesAndFormulas/StylesAndFormulas.md +0 -0
  73. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/ImplementationDetails/Typesetting/Typesetting.md +0 -0
  74. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/README.md +0 -0
  75. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/deploy.sh +0 -0
  76. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-banner.png +0 -0
  77. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-big-logo-darkmode-with-transparent-background.png +0 -0
  78. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-big-logo-darkmode-with-transparent-background.svg +0 -0
  79. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-big-logo-with-transparent-background.png +0 -0
  80. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-big-logo-with-transparent-background.svg +0 -0
  81. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-big-logo.png +0 -0
  82. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-contributor_reward_example.png +0 -0
  83. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-preview.gif +0 -0
  84. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-small-logo-with-transparent-background.png +0 -0
  85. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-small-logo-with-transparent-background.svg +0 -0
  86. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/images/babeldoc-small-logo.png +0 -0
  87. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/index.md +0 -0
  88. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/intro-to-pdf-object.md +0 -0
  89. {babeldoc-0.2.0 → babeldoc-0.2.2}/docs/requirements.txt +0 -0
  90. {babeldoc-0.2.0 → babeldoc-0.2.2}/mkdocs.yml +0 -0
  91. {babeldoc-0.2.0 → babeldoc-0.2.2}/tests/test_translation_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: BabelDOC
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Yet Another Document Translator
5
5
  Project-URL: Homepage, https://github.com/funstory-ai/BabelDOC
6
6
  Project-URL: Issues, https://github.com/funstory-ai/BabelDOC/issues
@@ -26,9 +26,11 @@ Requires-Dist: pdfminer-six>=20240706
26
26
  Requires-Dist: peewee>=3.17.8
27
27
  Requires-Dist: pydantic>=2.10.6
28
28
  Requires-Dist: pymupdf>=1.25.1
29
+ Requires-Dist: python-levenshtein>=0.27.1
29
30
  Requires-Dist: rich>=13.9.4
30
31
  Requires-Dist: scikit-image>=0.25.2
31
32
  Requires-Dist: tenacity>=9.0.0
33
+ Requires-Dist: tiktoken>=0.9.0
32
34
  Requires-Dist: toml>=0.10.2
33
35
  Requires-Dist: tqdm>=4.67.1
34
36
  Requires-Dist: xsdata[cli,lxml,soap]>=24.12
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
@@ -14,6 +14,7 @@ from babeldoc.assets.embedding_assets_metadata import (
14
14
  from babeldoc.assets.embedding_assets_metadata import EMBEDDING_FONT_METADATA
15
15
  from babeldoc.assets.embedding_assets_metadata import FONT_METADATA_URL
16
16
  from babeldoc.assets.embedding_assets_metadata import FONT_URL_BY_UPSTREAM
17
+ from babeldoc.assets.embedding_assets_metadata import TIKTOKEN_CACHES
17
18
  from babeldoc.const import get_cache_file_path
18
19
  from tenacity import retry
19
20
  from tenacity import stop_after_attempt
@@ -292,6 +293,9 @@ async def download_all_fonts_async(client: httpx.AsyncClient | None = None):
292
293
 
293
294
  async def async_warmup():
294
295
  logger.info("Downloading all assets...")
296
+ from tiktoken import encoding_for_model
297
+
298
+ _ = encoding_for_model("gpt-4o")
295
299
  async with httpx.AsyncClient() as client:
296
300
  onnx_task = asyncio.create_task(get_doclayout_onnx_model_path_async(client))
297
301
  font_tasks = asyncio.create_task(download_all_fonts_async(client))
@@ -306,6 +310,7 @@ def generate_all_assets_file_list():
306
310
  result = {}
307
311
  result["fonts"] = []
308
312
  result["models"] = []
313
+ result["tiktoken"] = []
309
314
  for font_file_name in EMBEDDING_FONT_METADATA:
310
315
  result["fonts"].append(
311
316
  {
@@ -313,6 +318,13 @@ def generate_all_assets_file_list():
313
318
  "sha3_256": EMBEDDING_FONT_METADATA[font_file_name]["sha3_256"],
314
319
  }
315
320
  )
321
+ for tiktoken_file, sha3_256 in TIKTOKEN_CACHES.items():
322
+ result["tiktoken"].append(
323
+ {
324
+ "name": tiktoken_file,
325
+ "sha3_256": sha3_256,
326
+ }
327
+ )
316
328
  result["models"].append(
317
329
  {
318
330
  "name": "doclayout_yolo_docstructbench_imgsz1024.onnx",
@@ -2,6 +2,9 @@ DOCLAYOUT_YOLO_DOCSTRUCTBENCH_IMGSZ1024ONNX_SHA3_256 = (
2
2
  "60be061226930524958b5465c8c04af3d7c03bcb0beb66454f5da9f792e3cf2a"
3
3
  )
4
4
 
5
+ TIKTOKEN_CACHES = {
6
+ "fb374d419588a4632f3f557e76b4b70aebbca790": "cb04bcda5782cfbbe77f2f991d92c0ea785d9496ef1137c91dfc3c8c324528d6"
7
+ }
5
8
 
6
9
  FONT_METADATA_URL = {
7
10
  "github": "https://raw.githubusercontent.com/funstory-ai/BabelDOC-Assets/refs/heads/main/font_metadata.json",
@@ -1,8 +1,9 @@
1
+ import os
1
2
  import shutil
2
3
  import subprocess
3
4
  from pathlib import Path
4
5
 
5
- __version__ = "0.2.0"
6
+ __version__ = "0.2.2"
6
7
 
7
8
  CACHE_FOLDER = Path.home() / ".cache" / "babeldoc"
8
9
 
@@ -35,3 +36,7 @@ try:
35
36
  )
36
37
  except (OSError, FileNotFoundError, subprocess.CalledProcessError):
37
38
  WATERMARK_VERSION = f"v{__version__}"
39
+
40
+ TIKTOKEN_CACHE_FOLDER = CACHE_FOLDER / "tiktoken"
41
+ TIKTOKEN_CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
42
+ os.environ["TIKTOKEN_CACHE_DIR"] = str(TIKTOKEN_CACHE_FOLDER)
@@ -750,6 +750,12 @@ class PdfParagraph:
750
750
  "type": "Attribute",
751
751
  },
752
752
  )
753
+ layout_label: str | None = field(
754
+ default=None,
755
+ metadata={
756
+ "type": "Attribute",
757
+ },
758
+ )
753
759
 
754
760
 
755
761
  @dataclass
@@ -114,6 +114,7 @@ PDFParagraph =
114
114
  attribute vertical { xsd:boolean }?,
115
115
  attribute FirstLineIndent { xsd:boolean }?,
116
116
  attribute debug_id { xsd:string }?,
117
+ attribute layout_label { xsd:string }?,
117
118
  Box,
118
119
  PDFStyle,
119
120
  PDFParagraphComposition*
@@ -334,6 +334,11 @@
334
334
  <data type="string"/>
335
335
  </attribute>
336
336
  </optional>
337
+ <optional>
338
+ <attribute name="layout_label">
339
+ <data type="string"/>
340
+ </attribute>
341
+ </optional>
337
342
  <ref name="Box"/>
338
343
  <ref name="PDFStyle"/>
339
344
  <zeroOrMore>
@@ -187,6 +187,7 @@
187
187
  <xs:attribute name="vertical" type="xs:boolean"/>
188
188
  <xs:attribute name="FirstLineIndent" type="xs:boolean"/>
189
189
  <xs:attribute name="debug_id" type="xs:string"/>
190
+ <xs:attribute name="layout_label" type="xs:string"/>
190
191
  </xs:complexType>
191
192
  </xs:element>
192
193
  <xs:element name="pdfParagraphComposition">
@@ -524,6 +524,56 @@ class ILTranslator:
524
524
 
525
525
  return result
526
526
 
527
+ def pre_translate_paragraph(
528
+ self,
529
+ paragraph: PdfParagraph,
530
+ tracker: ParagraphTranslateTracker,
531
+ page_font_map: dict[str, PdfFont],
532
+ xobj_font_map: dict[int, dict[str, PdfFont]],
533
+ ):
534
+ """Pre-translation processing: prepare text for translation."""
535
+ if paragraph.vertical:
536
+ return None, None
537
+ tracker.set_pdf_unicode(paragraph.unicode)
538
+ if paragraph.xobj_id in xobj_font_map:
539
+ page_font_map = xobj_font_map[paragraph.xobj_id]
540
+ translate_input = self.get_translate_input(paragraph, page_font_map)
541
+ if not translate_input:
542
+ return None, None
543
+ tracker.set_input(translate_input.unicode)
544
+ text = translate_input.unicode
545
+ if len(text) < self.translation_config.min_text_length:
546
+ logger.debug(
547
+ f"Text too short to translate, skip. Text: {text}. Paragraph id: {paragraph.debug_id}."
548
+ )
549
+ return None, None
550
+ return text, translate_input
551
+
552
+ def post_translate_paragraph(
553
+ self,
554
+ paragraph: PdfParagraph,
555
+ tracker: ParagraphTranslateTracker,
556
+ translate_input,
557
+ translated_text: str,
558
+ ):
559
+ """Post-translation processing: update paragraph with translated text."""
560
+ tracker.set_output(translated_text)
561
+ if translated_text == translate_input.unicode:
562
+ return False
563
+ paragraph.unicode = translated_text
564
+ paragraph.pdf_paragraph_composition = self.parse_translate_output(
565
+ translate_input, translated_text
566
+ )
567
+ for composition in paragraph.pdf_paragraph_composition:
568
+ if (
569
+ composition.pdf_same_style_unicode_characters
570
+ and composition.pdf_same_style_unicode_characters.pdf_style is None
571
+ ):
572
+ composition.pdf_same_style_unicode_characters.pdf_style = (
573
+ paragraph.pdf_style
574
+ )
575
+ return True
576
+
527
577
  def translate_paragraph(
528
578
  self,
529
579
  paragraph: PdfParagraph,
@@ -532,51 +582,25 @@ class ILTranslator:
532
582
  page_font_map: dict[str, PdfFont] = None,
533
583
  xobj_font_map: dict[int, dict[str, PdfFont]] = None,
534
584
  ):
585
+ """Translate a paragraph using pre and post processing functions."""
535
586
  self.translation_config.raise_if_cancelled()
536
587
  with PbarContext(pbar):
537
588
  try:
538
- if paragraph.vertical:
539
- return
540
-
541
- tracker.set_pdf_unicode(paragraph.unicode)
542
- if paragraph.xobj_id in xobj_font_map:
543
- page_font_map = xobj_font_map[paragraph.xobj_id]
544
- translate_input = self.get_translate_input(paragraph, page_font_map)
545
- if not translate_input:
546
- return
547
-
548
- tracker.set_input(translate_input.unicode)
549
-
550
- text = translate_input.unicode
551
-
552
- if len(text) < self.translation_config.min_text_length:
553
- logger.debug(
554
- f"Text too short to translate, skip. Text: {text}. Paragraph id: {paragraph.debug_id}.",
555
- )
589
+ # Pre-translation processing
590
+ text, translate_input = self.pre_translate_paragraph(
591
+ paragraph, tracker, page_font_map, xobj_font_map
592
+ )
593
+ if text is None:
556
594
  return
557
595
 
596
+ # Perform translation
558
597
  translated_text = self.translate_engine.translate(text)
559
598
  translated_text = re.sub(r"[. 。…,]{20,}", ".", translated_text)
560
599
 
561
- tracker.set_output(translated_text)
562
-
563
- if translated_text == text:
564
- return
565
-
566
- paragraph.unicode = translated_text
567
- paragraph.pdf_paragraph_composition = self.parse_translate_output(
568
- translate_input,
569
- translated_text,
600
+ # Post-translation processing
601
+ self.post_translate_paragraph(
602
+ paragraph, tracker, translate_input, translated_text
570
603
  )
571
- for composition in paragraph.pdf_paragraph_composition:
572
- if (
573
- composition.pdf_same_style_unicode_characters
574
- and composition.pdf_same_style_unicode_characters.pdf_style
575
- is None
576
- ):
577
- composition.pdf_same_style_unicode_characters.pdf_style = (
578
- paragraph.pdf_style
579
- )
580
604
  except Exception as e:
581
605
  logger.exception(
582
606
  f"Error translating paragraph. Paragraph: {paragraph}. Error: {e}. ",
@@ -0,0 +1,375 @@
1
+ import concurrent.futures
2
+ import json
3
+ import logging
4
+ import re
5
+ from pathlib import Path
6
+
7
+ import Levenshtein
8
+ import tiktoken
9
+ from tqdm import tqdm
10
+
11
+ from babeldoc.document_il import Document
12
+ from babeldoc.document_il import Page
13
+ from babeldoc.document_il import PdfFont
14
+ from babeldoc.document_il import PdfParagraph
15
+ from babeldoc.document_il.midend.il_translator import DocumentTranslateTracker
16
+ from babeldoc.document_il.midend.il_translator import ILTranslator
17
+ from babeldoc.document_il.midend.il_translator import PageTranslateTracker
18
+ from babeldoc.document_il.translator.translator import BaseTranslator
19
+ from babeldoc.document_il.utils.fontmap import FontMapper
20
+ from babeldoc.translation_config import TranslationConfig
21
+
22
+ enc = tiktoken.encoding_for_model("gpt-4o")
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class BatchParagraph:
28
+ def __init__(
29
+ self, paragraphs: list[PdfParagraph], page_tracker: PageTranslateTracker
30
+ ):
31
+ self.paragraphs = paragraphs
32
+ self.trackers = [page_tracker.new_paragraph() for _ in paragraphs]
33
+
34
+
35
+ class ILTranslatorLLMOnly:
36
+ stage_name = "Translate Paragraphs"
37
+
38
+ def __init__(
39
+ self,
40
+ translate_engine: BaseTranslator,
41
+ translation_config: TranslationConfig,
42
+ ):
43
+ self.translate_engine = translate_engine
44
+ self.translation_config = translation_config
45
+ self.font_mapper = FontMapper(translation_config)
46
+
47
+ self.il_translator = ILTranslator(
48
+ translate_engine=translate_engine,
49
+ translation_config=translation_config,
50
+ )
51
+
52
+ try:
53
+ self.translate_engine.do_llm_translate(None)
54
+ except NotImplementedError as e:
55
+ raise ValueError("LLM translator not supported") from e
56
+
57
+ def find_title_paragraph(self, docs: Document) -> PdfParagraph | None:
58
+ """Find the first paragraph with layout_label 'title' in the document.
59
+
60
+ Args:
61
+ docs: The document to search in
62
+
63
+ Returns:
64
+ The first title paragraph found, or None if no title paragraph exists
65
+ """
66
+ for page in docs.page:
67
+ for paragraph in page.pdf_paragraph:
68
+ if paragraph.layout_label == "title":
69
+ logger.info(f"Found title paragraph: {paragraph.unicode}")
70
+ return paragraph
71
+ return None
72
+
73
+ def translate(self, docs: Document) -> None:
74
+ tracker = DocumentTranslateTracker()
75
+
76
+ # Try to find the first title paragraph
77
+ title_paragraph = self.find_title_paragraph(docs)
78
+
79
+ # count total paragraph
80
+ total = sum(
81
+ [
82
+ len([p for p in page.pdf_paragraph if p.debug_id is not None])
83
+ for page in docs.page
84
+ ]
85
+ )
86
+ with self.translation_config.progress_monitor.stage_start(
87
+ self.stage_name,
88
+ total,
89
+ ) as pbar:
90
+ with concurrent.futures.ThreadPoolExecutor(
91
+ max_workers=self.translation_config.qps,
92
+ ) as executor2:
93
+ with concurrent.futures.ThreadPoolExecutor(
94
+ max_workers=self.translation_config.qps,
95
+ ) as executor:
96
+ for page in docs.page:
97
+ self.process_page(
98
+ page,
99
+ executor,
100
+ pbar,
101
+ tracker.new_page(),
102
+ title_paragraph,
103
+ executor2,
104
+ )
105
+
106
+ path = self.translation_config.get_working_file_path("translate_tracking.json")
107
+
108
+ if self.translation_config.debug:
109
+ logger.debug(f"save translate tracking to {path}")
110
+ with Path(path).open("w", encoding="utf-8") as f:
111
+ f.write(tracker.to_json())
112
+
113
+ def process_page(
114
+ self,
115
+ page: Page,
116
+ executor: concurrent.futures.ThreadPoolExecutor,
117
+ pbar: tqdm | None = None,
118
+ tracker: PageTranslateTracker = None,
119
+ title_paragraph: PdfParagraph | None = None,
120
+ executor2: concurrent.futures.ThreadPoolExecutor | None = None,
121
+ ):
122
+ self.translation_config.raise_if_cancelled()
123
+ page_font_map = {}
124
+ for font in page.pdf_font:
125
+ page_font_map[font.font_id] = font
126
+ page_xobj_font_map = {}
127
+ for xobj in page.pdf_xobject:
128
+ page_xobj_font_map[xobj.xobj_id] = page_font_map.copy()
129
+ for font in xobj.pdf_font:
130
+ page_xobj_font_map[xobj.xobj_id][font.font_id] = font
131
+
132
+ paragraphs = []
133
+ local_title_paragraph = None
134
+
135
+ total_unicode_counts = 0
136
+ for paragraph in page.pdf_paragraph:
137
+ if paragraph.debug_id is None:
138
+ continue
139
+ # self.translate_paragraph(paragraph, pbar,tracker.new_paragraph(), page_font_map, page_xobj_font_map)
140
+ total_unicode_counts += len(paragraph.unicode)
141
+ paragraphs.append(paragraph)
142
+ if paragraph.layout_label == "title":
143
+ local_title_paragraph = paragraph
144
+
145
+ if total_unicode_counts > 1200 or len(paragraphs) > 4:
146
+ executor.submit(
147
+ self.translate_paragraph,
148
+ BatchParagraph(paragraphs, tracker),
149
+ pbar,
150
+ page_font_map,
151
+ page_xobj_font_map,
152
+ title_paragraph,
153
+ local_title_paragraph,
154
+ executor2,
155
+ )
156
+ paragraphs = []
157
+ total_unicode_counts = 0
158
+
159
+ if paragraphs:
160
+ executor.submit(
161
+ self.translate_paragraph,
162
+ BatchParagraph(paragraphs, tracker),
163
+ pbar,
164
+ page_font_map,
165
+ page_xobj_font_map,
166
+ title_paragraph,
167
+ local_title_paragraph,
168
+ executor2,
169
+ )
170
+
171
+ def translate_paragraph(
172
+ self,
173
+ batch_paragraph: BatchParagraph,
174
+ pbar: tqdm | None = None,
175
+ page_font_map: dict[str, PdfFont] = None,
176
+ xobj_font_map: dict[int, dict[str, PdfFont]] = None,
177
+ title_paragraph: PdfParagraph | None = None,
178
+ local_title_paragraph: PdfParagraph | None = None,
179
+ executor=None,
180
+ ):
181
+ """Translate a paragraph using pre and post processing functions."""
182
+ self.translation_config.raise_if_cancelled()
183
+ try:
184
+ inputs = []
185
+
186
+ for i in range(len(batch_paragraph.paragraphs)):
187
+ paragraph = batch_paragraph.paragraphs[i]
188
+ tracker = batch_paragraph.trackers[i]
189
+ text, translate_input = self.il_translator.pre_translate_paragraph(
190
+ paragraph, tracker, page_font_map, xobj_font_map
191
+ )
192
+ if text is None:
193
+ pbar.advance(1)
194
+ continue
195
+ inputs.append((text, translate_input, paragraph, tracker))
196
+ if not inputs:
197
+ return
198
+ json_format_input = []
199
+
200
+ for id_, input_text in enumerate(inputs):
201
+ json_format_input.append(
202
+ {
203
+ "id": id_,
204
+ "input": input_text[0],
205
+ "layout_label": input_text[2].layout_label,
206
+ }
207
+ )
208
+ json_format_input = json.dumps(
209
+ json_format_input, ensure_ascii=False, indent=2
210
+ )
211
+ llm_input = [
212
+ "You are a professional, authentic machine translation engine."
213
+ ]
214
+
215
+ if title_paragraph:
216
+ llm_input.append(
217
+ f"The first title in the full text: {title_paragraph.unicode}"
218
+ )
219
+ if (
220
+ local_title_paragraph
221
+ and local_title_paragraph.debug_id != title_paragraph.debug_id
222
+ ):
223
+ llm_input.append(
224
+ f"The most similar title in the full text: {local_title_paragraph.unicode}"
225
+ )
226
+ # Create a structured prompt template for LLM translation
227
+ prompt_template = (
228
+ f"""
229
+ You will be given a JSON formatted input containing entries with "id" and "input" fields. Here is the input:
230
+
231
+ ```json
232
+ {json_format_input}
233
+ ```
234
+
235
+ For each entry in the JSON, translate the contents of the "input" field into {self.translation_config.lang_out}.
236
+ Write the translation back into the "output" field for that entry.
237
+
238
+ """
239
+ + """
240
+ Here is an example of the expected format:
241
+
242
+ <example>
243
+ ```json
244
+ Input:
245
+ {
246
+ "id": 1,
247
+ "input": "Source",
248
+ "layout_label": "plain text"
249
+ }
250
+ ```
251
+ Output:
252
+ ```json
253
+ {
254
+ "id": 1,
255
+ "output": "Translation"
256
+ }
257
+ ```
258
+ </example>
259
+
260
+ Please return the translated json directly without wrapping ```json``` tag or include any additional information.
261
+ """
262
+ )
263
+ llm_input.append(prompt_template)
264
+
265
+ final_input = "\n".join(llm_input).strip()
266
+ llm_output = self.translate_engine.llm_translate(final_input)
267
+ llm_output = llm_output.strip()
268
+
269
+ llm_output = self._clean_json_output(llm_output)
270
+
271
+ parsed_output = json.loads(llm_output)
272
+
273
+ translation_results = {item["id"]: item["output"] for item in parsed_output}
274
+
275
+ if len(translation_results) != len(inputs):
276
+ raise Exception(
277
+ f"Translation results length mismatch. Expected: {len(inputs)}, Got: {len(translation_results)}"
278
+ )
279
+
280
+ for id_, output in translation_results.items():
281
+ should_fallback = True
282
+ try:
283
+ if not isinstance(output, str):
284
+ logger.warning(
285
+ f"Translation result is not a string. Output: {output}"
286
+ )
287
+ continue
288
+
289
+ id_ = int(id_) # Ensure id is an integer
290
+ if id_ >= len(inputs):
291
+ logger.warning(f"Invalid id {id_}, skipping")
292
+ continue
293
+
294
+ # Clean up any excessive punctuation in the translated text
295
+ translated_text = re.sub(r"[. 。…,]{20,}", ".", output)
296
+
297
+ # Get the original input for this translation
298
+ translate_input = inputs[id_][1]
299
+
300
+ input_unicode = inputs[id_][2].unicode
301
+ output_unicode = translated_text
302
+
303
+ input_token_count = len(enc.encode(input_unicode))
304
+ output_token_count = len(enc.encode(output_unicode))
305
+
306
+ if not (0.3 < output_token_count / input_token_count < 3):
307
+ logger.warning(
308
+ f"Translation result is too long or too short. Input: {input_token_count}, Output: {output_token_count}"
309
+ )
310
+ continue
311
+
312
+ edit_distance = Levenshtein.distance(input_unicode, output_unicode)
313
+ if edit_distance < 5 and input_token_count > 20:
314
+ logger.warning(
315
+ f"Translation result edit distance is too small. distance: {edit_distance}, input: {input_unicode}, output: {output_unicode}"
316
+ )
317
+ continue
318
+ # Apply the translation to the paragraph
319
+ self.il_translator.post_translate_paragraph(
320
+ inputs[id_][2],
321
+ inputs[id_][3],
322
+ translate_input,
323
+ translated_text,
324
+ )
325
+ if pbar:
326
+ pbar.advance(1)
327
+ should_fallback = False
328
+ except Exception as e:
329
+ logger.exception(f"Error translating paragraph. Error: {e}.")
330
+ # Ignore error and continue
331
+ continue
332
+ finally:
333
+ if should_fallback:
334
+ logger.warning(
335
+ f"Fallback to simple translation. paragraph id: {inputs[id_][2].debug_id}"
336
+ )
337
+ executor.submit(
338
+ self.il_translator.translate_paragraph,
339
+ inputs[id_][2],
340
+ pbar,
341
+ inputs[id_][3],
342
+ page_font_map,
343
+ xobj_font_map,
344
+ )
345
+
346
+ except Exception as e:
347
+ logger.warning(f"Error {e} during translation. try fallback")
348
+ for i in range(len(batch_paragraph.paragraphs)):
349
+ paragraph = batch_paragraph.paragraphs[i]
350
+ tracker = batch_paragraph.trackers[i]
351
+ if paragraph.debug_id is None:
352
+ continue
353
+ executor.submit(
354
+ self.il_translator.translate_paragraph,
355
+ paragraph,
356
+ pbar,
357
+ tracker,
358
+ page_font_map,
359
+ xobj_font_map,
360
+ )
361
+
362
+ def _clean_json_output(self, llm_output: str) -> str:
363
+ # Clean up JSON output by removing common wrapper tags
364
+ llm_output = llm_output.strip()
365
+ if llm_output.startswith("<json>"):
366
+ llm_output = llm_output[6:]
367
+ if llm_output.endswith("</json>"):
368
+ llm_output = llm_output[:-7]
369
+ if llm_output.startswith("```json"):
370
+ llm_output = llm_output[7:]
371
+ if llm_output.startswith("```"):
372
+ llm_output = llm_output[3:]
373
+ if llm_output.endswith("```"):
374
+ llm_output = llm_output[:-3]
375
+ return llm_output.strip()
@@ -177,6 +177,9 @@ class ParagraphFinder:
177
177
  current_paragraph = PdfParagraph(
178
178
  pdf_paragraph_composition=[line],
179
179
  debug_id=generate_base58_id(),
180
+ layout_label=char_layout.name
181
+ if not current_layout
182
+ else current_layout.name,
180
183
  )
181
184
  paragraphs.append(current_paragraph)
182
185
  else:
@@ -212,6 +215,7 @@ class ParagraphFinder:
212
215
  current_paragraph = PdfParagraph(
213
216
  pdf_paragraph_composition=[line],
214
217
  debug_id=generate_base58_id(),
218
+ layout_label=current_layout.name,
215
219
  )
216
220
  self.update_paragraph_data(current_paragraph)
217
221
  paragraphs.append(current_paragraph)
@@ -228,6 +232,7 @@ class ParagraphFinder:
228
232
  current_paragraph = PdfParagraph(
229
233
  pdf_paragraph_composition=[line],
230
234
  debug_id=generate_base58_id(),
235
+ layout_label=current_layout.name,
231
236
  )
232
237
  paragraphs.append(current_paragraph)
233
238
  else:
@@ -412,6 +417,7 @@ class ParagraphFinder:
412
417
  ),
413
418
  unicode="",
414
419
  debug_id=generate_base58_id(),
420
+ layout_label=prev_composition.layout_label,
415
421
  )
416
422
  # 更新原段落
417
423
  paragraph.pdf_paragraph_composition = (
@@ -440,6 +446,7 @@ class ParagraphFinder:
440
446
  ),
441
447
  unicode="",
442
448
  debug_id=generate_base58_id(),
449
+ layout_label=prev_composition.layout_label,
443
450
  )
444
451
  # 更新原段落
445
452
  paragraph.pdf_paragraph_composition = (