BabelDOC 0.1.6__py3-none-any.whl

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 (42) hide show
  1. babeldoc/__init__.py +1 -0
  2. babeldoc/asynchronize/__init__.py +51 -0
  3. babeldoc/const.py +7 -0
  4. babeldoc/converter.py +510 -0
  5. babeldoc/document_il/__init__.py +43 -0
  6. babeldoc/document_il/backend/__init__.py +0 -0
  7. babeldoc/document_il/backend/pdf_creater.py +402 -0
  8. babeldoc/document_il/frontend/__init__.py +0 -0
  9. babeldoc/document_il/frontend/il_creater.py +348 -0
  10. babeldoc/document_il/il_version_1.py +799 -0
  11. babeldoc/document_il/il_version_1.rnc +141 -0
  12. babeldoc/document_il/il_version_1.rng +390 -0
  13. babeldoc/document_il/il_version_1.xsd +235 -0
  14. babeldoc/document_il/midend/__init__.py +0 -0
  15. babeldoc/document_il/midend/add_debug_information.py +138 -0
  16. babeldoc/document_il/midend/il_translator.py +553 -0
  17. babeldoc/document_il/midend/layout_parser.py +180 -0
  18. babeldoc/document_il/midend/paragraph_finder.py +452 -0
  19. babeldoc/document_il/midend/remove_descent.py +168 -0
  20. babeldoc/document_il/midend/styles_and_formulas.py +701 -0
  21. babeldoc/document_il/midend/typesetting.py +761 -0
  22. babeldoc/document_il/translator/__init__.py +0 -0
  23. babeldoc/document_il/translator/cache.py +145 -0
  24. babeldoc/document_il/translator/translator.py +267 -0
  25. babeldoc/document_il/utils/fontmap.py +227 -0
  26. babeldoc/document_il/utils/layout_helper.py +467 -0
  27. babeldoc/document_il/utils/style_helper.py +86 -0
  28. babeldoc/document_il/xml_converter.py +51 -0
  29. babeldoc/docvision/README.md +0 -0
  30. babeldoc/docvision/__init__.py +0 -0
  31. babeldoc/docvision/doclayout.py +278 -0
  32. babeldoc/docvision/rpc_doclayout.py +162 -0
  33. babeldoc/high_level.py +468 -0
  34. babeldoc/main.py +466 -0
  35. babeldoc/pdfinterp.py +464 -0
  36. babeldoc/progress_monitor.py +146 -0
  37. babeldoc/translation_config.py +156 -0
  38. babeldoc-0.1.6.dist-info/METADATA +283 -0
  39. babeldoc-0.1.6.dist-info/RECORD +42 -0
  40. babeldoc-0.1.6.dist-info/WHEEL +4 -0
  41. babeldoc-0.1.6.dist-info/entry_points.txt +2 -0
  42. babeldoc-0.1.6.dist-info/licenses/LICENSE +661 -0
babeldoc/high_level.py ADDED
@@ -0,0 +1,468 @@
1
+ import asyncio
2
+ import hashlib
3
+ import logging
4
+ import threading
5
+ import time
6
+ from asyncio import CancelledError
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from typing import BinaryIO
10
+
11
+ import httpx
12
+ from pdfminer.pdfdocument import PDFDocument
13
+ from pdfminer.pdfinterp import PDFResourceManager
14
+ from pdfminer.pdfpage import PDFPage
15
+ from pdfminer.pdfparser import PDFParser
16
+ from pymupdf import Document
17
+ from pymupdf import Font
18
+
19
+ from babeldoc import asynchronize
20
+ from babeldoc.const import CACHE_FOLDER
21
+ from babeldoc.const import get_cache_file_path
22
+ from babeldoc.converter import TranslateConverter
23
+ from babeldoc.document_il.backend.pdf_creater import PDFCreater
24
+ from babeldoc.document_il.frontend.il_creater import ILCreater
25
+ from babeldoc.document_il.midend.add_debug_information import AddDebugInformation
26
+ from babeldoc.document_il.midend.il_translator import ILTranslator
27
+ from babeldoc.document_il.midend.layout_parser import LayoutParser
28
+ from babeldoc.document_il.midend.paragraph_finder import ParagraphFinder
29
+ from babeldoc.document_il.midend.remove_descent import RemoveDescent
30
+ from babeldoc.document_il.midend.styles_and_formulas import StylesAndFormulas
31
+ from babeldoc.document_il.midend.typesetting import Typesetting
32
+ from babeldoc.document_il.utils.fontmap import FontMapper
33
+ from babeldoc.document_il.xml_converter import XMLConverter
34
+ from babeldoc.pdfinterp import PDFPageInterpreterEx
35
+ from babeldoc.progress_monitor import ProgressMonitor
36
+ from babeldoc.translation_config import TranslateResult
37
+ from babeldoc.translation_config import TranslationConfig
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ TRANSLATE_STAGES = [
42
+ ILCreater.stage_name,
43
+ LayoutParser.stage_name,
44
+ ParagraphFinder.stage_name,
45
+ StylesAndFormulas.stage_name,
46
+ RemoveDescent.stage_name,
47
+ ILTranslator.stage_name,
48
+ Typesetting.stage_name,
49
+ FontMapper.stage_name,
50
+ PDFCreater.stage_name,
51
+ ]
52
+
53
+ resfont_map = {
54
+ "zh-cn": "china-ss",
55
+ "zh-tw": "china-ts",
56
+ "zh-hans": "china-ss",
57
+ "zh-hant": "china-ts",
58
+ "zh": "china-ss",
59
+ "ja": "japan-s",
60
+ "ko": "korea-s",
61
+ }
62
+
63
+ FONT_ASSETS = [
64
+ (
65
+ "noto.ttf",
66
+ "https://github.com/satbyy/go-noto-universal"
67
+ "/releases/download/v7.0/GoNotoKurrent-Regular.ttf",
68
+ "2f2cee5fbb2403df352ca2005247f6c4faa70f3086ebd31b6c62308b5f2f9865",
69
+ ),
70
+ (
71
+ "source-han-serif-cn.ttf",
72
+ "https://github.com/junmer/source-han-serif-ttf/"
73
+ "raw/refs/heads/master/SubsetTTF/CN/SourceHanSerifCN-Regular.ttf",
74
+ "1e60cc2eedfa25bf5e4ecaa794402f581ad770d4c8be46d338bf52064b307ec7",
75
+ ),
76
+ (
77
+ "source-han-serif-cn-bold.ttf",
78
+ "https://github.com/junmer/source-han-serif-ttf/"
79
+ "raw/refs/heads/master/SubsetTTF/CN/SourceHanSerifCN-Bold.ttf",
80
+ "84c24723a47537fcf5057b788a51c41978ee6173931f19b8a9f5a4595b677dc9",
81
+ ),
82
+ (
83
+ "SourceHanSansSC-Regular.ttf",
84
+ "https://github.com/iizyd/SourceHanSansCN-TTF-Min/"
85
+ "raw/refs/heads/main/source-file/ttf/SourceHanSansSC-Regular.ttf",
86
+ "a878f16eed162dc5b211d888a4a29b1730b73f4cf632e720abca4eab7bd8a152",
87
+ ),
88
+ (
89
+ "SourceHanSansSC-Bold.ttf",
90
+ "https://github.com/iizyd/SourceHanSansCN-TTF-Min/"
91
+ "raw/refs/heads/main/source-file/ttf/SourceHanSansSC-Bold.ttf",
92
+ "485b27eb4f3603223e9c3c5ebfa317aee77772ea8f642f9330df7f030c8b7b43",
93
+ ),
94
+ (
95
+ "LXGWWenKai-Regular.ttf",
96
+ "https://github.com/lxgw/LxgwWenKai/"
97
+ "raw/refs/heads/main/fonts/TTF/LXGWWenKai-Regular.ttf",
98
+ "ea47ec17d0f3d0ed1e6d9c51d6146402d4d1e2f0ff397a90765aaaa0ddd382fb",
99
+ ),
100
+ ]
101
+
102
+
103
+ def verify_file_hash(file_path: str, expected_hash: str) -> bool:
104
+ """Verify the SHA256 hash of a file."""
105
+ sha256_hash = hashlib.sha256()
106
+ with Path(file_path).open("rb") as f:
107
+ # Read the file in chunks to handle large files efficiently
108
+ for byte_block in iter(lambda: f.read(4096), b""):
109
+ sha256_hash.update(byte_block)
110
+ return sha256_hash.hexdigest() == expected_hash
111
+
112
+
113
+ def start_parse_il(
114
+ inf: BinaryIO,
115
+ pages: list[int] | None = None,
116
+ vfont: str = "",
117
+ vchar: str = "",
118
+ thread: int = 0,
119
+ doc_zh: Document = None,
120
+ lang_in: str = "",
121
+ lang_out: str = "",
122
+ service: str = "",
123
+ resfont: str = "",
124
+ noto: Font = None,
125
+ cancellation_event: asyncio.Event = None,
126
+ il_creater: ILCreater = None,
127
+ translation_config: TranslationConfig = None,
128
+ **kwarg: Any,
129
+ ) -> None:
130
+ rsrcmgr = PDFResourceManager()
131
+ layout = {}
132
+ device = TranslateConverter(
133
+ rsrcmgr,
134
+ vfont,
135
+ vchar,
136
+ thread,
137
+ layout,
138
+ lang_in,
139
+ lang_out,
140
+ service,
141
+ resfont,
142
+ noto,
143
+ kwarg.get("envs", {}),
144
+ kwarg.get("prompt", []),
145
+ il_creater=il_creater,
146
+ )
147
+ # model = DocLayoutModel.load_available()
148
+
149
+ assert device is not None
150
+ assert il_creater is not None
151
+ assert translation_config is not None
152
+ obj_patch = {}
153
+ interpreter = PDFPageInterpreterEx(rsrcmgr, device, obj_patch, il_creater)
154
+ if pages:
155
+ total_pages = len(pages)
156
+ else:
157
+ total_pages = doc_zh.page_count
158
+
159
+ il_creater.on_total_pages(total_pages)
160
+
161
+ parser = PDFParser(inf)
162
+ doc = PDFDocument(parser)
163
+
164
+ for pageno, page in enumerate(PDFPage.create_pages(doc)):
165
+ if cancellation_event and cancellation_event.is_set():
166
+ raise CancelledError("task cancelled")
167
+ if pages and (pageno not in pages):
168
+ continue
169
+ page.pageno = pageno
170
+ if not translation_config.should_translate_page(pageno + 1):
171
+ continue
172
+ translation_config.raise_if_cancelled()
173
+ # The current program no longer relies on
174
+ # the following layout recognition results,
175
+ # but in order to facilitate the migration of pdf2zh,
176
+ # the relevant code is temporarily retained.
177
+ # pix = doc_zh[page.pageno].get_pixmap()
178
+ # image = np.fromstring(pix.samples, np.uint8).reshape(
179
+ # pix.height, pix.width, 3
180
+ # )[:, :, ::-1]
181
+ # page_layout = model.predict(
182
+ # image, imgsz=int(pix.height / 32) * 32)[0]
183
+ # # kdtree 是不可能 kdtree 的,不如直接渲染成图片,用空间换时间
184
+ # box = np.ones((pix.height, pix.width))
185
+ # h, w = box.shape
186
+ # vcls = ["abandon", "figure", "table",
187
+ # "isolate_formula", "formula_caption"]
188
+ # for i, d in enumerate(page_layout.boxes):
189
+ # if page_layout.names[int(d.cls)] not in vcls:
190
+ # x0, y0, x1, y1 = d.xyxy.squeeze()
191
+ # x0, y0, x1, y1 = (
192
+ # np.clip(int(x0 - 1), 0, w - 1),
193
+ # np.clip(int(h - y1 - 1), 0, h - 1),
194
+ # np.clip(int(x1 + 1), 0, w - 1),
195
+ # np.clip(int(h - y0 + 1), 0, h - 1),
196
+ # )
197
+ # box[y0:y1, x0:x1] = i + 2
198
+ # for i, d in enumerate(page_layout.boxes):
199
+ # if page_layout.names[int(d.cls)] in vcls:
200
+ # x0, y0, x1, y1 = d.xyxy.squeeze()
201
+ # x0, y0, x1, y1 = (
202
+ # np.clip(int(x0 - 1), 0, w - 1),
203
+ # np.clip(int(h - y1 - 1), 0, h - 1),
204
+ # np.clip(int(x1 + 1), 0, w - 1),
205
+ # np.clip(int(h - y0 + 1), 0, h - 1),
206
+ # )
207
+ # box[y0:y1, x0:x1] = 0
208
+ # layout[page.pageno] = box
209
+ # 新建一个 xref 存放新指令流
210
+ page.page_xref = doc_zh.get_new_xref() # hack 插入页面的新 xref
211
+ doc_zh.update_object(page.page_xref, "<<>>")
212
+ doc_zh.update_stream(page.page_xref, b"")
213
+ doc_zh[page.pageno].set_contents(page.page_xref)
214
+ ops_base = interpreter.process_page(page)
215
+ il_creater.on_page_base_operation(ops_base)
216
+ il_creater.on_page_end()
217
+ il_creater.on_finish()
218
+ device.close()
219
+
220
+
221
+ def translate(translation_config: TranslationConfig) -> TranslateResult:
222
+ with ProgressMonitor(
223
+ translation_config,
224
+ TRANSLATE_STAGES,
225
+ ) as pm:
226
+ return do_translate(pm, translation_config)
227
+
228
+
229
+ async def async_translate(translation_config: TranslationConfig):
230
+ """Asynchronously translate a PDF file with real-time progress reporting.
231
+
232
+ This function yields progress events that can be used to update progress bars
233
+ or other UI elements. The events are dictionaries with the following structure:
234
+
235
+ - progress_start: {
236
+ "type": "progress_start",
237
+ "stage": str, # Stage name
238
+ "stage_progress": float, # Always 0.0
239
+ "stage_current": int, # Current count (0)
240
+ "stage_total": int # Total items in stage
241
+ }
242
+ - progress_update: {
243
+ "type": "progress_update",
244
+ "stage": str, # Stage name
245
+ "stage_progress": float, # Stage progress (0-100)
246
+ "stage_current": int, # Current items processed
247
+ "stage_total": int, # Total items in stage
248
+ "overall_progress": float # Overall progress (0-100)
249
+ }
250
+ - progress_end: {
251
+ "type": "progress_end",
252
+ "stage": str, # Stage name
253
+ "stage_progress": float, # Always 100.0
254
+ "stage_current": int, # Equal to stage_total
255
+ "stage_total": int, # Total items processed
256
+ "overall_progress": float # Overall progress (0-100)
257
+ }
258
+ - finish: {
259
+ "type": "finish",
260
+ "translate_result": TranslateResult
261
+ }
262
+ - error: {
263
+ "type": "error",
264
+ "error": str
265
+ }
266
+
267
+ Args:
268
+ translation_config: Configuration for the translation process
269
+
270
+ Yields:
271
+ dict: Progress events during translation
272
+
273
+ Raises:
274
+ CancelledError: If the translation is cancelled
275
+ Exception: Any other errors during translation
276
+ """
277
+ loop = asyncio.get_running_loop()
278
+ callback = asynchronize.AsyncCallback()
279
+
280
+ finish_event = asyncio.Event()
281
+ cancel_event = threading.Event()
282
+ with ProgressMonitor(
283
+ translation_config,
284
+ TRANSLATE_STAGES,
285
+ progress_change_callback=callback.step_callback,
286
+ finish_callback=callback.finished_callback,
287
+ finish_event=finish_event,
288
+ cancel_event=cancel_event,
289
+ loop=loop,
290
+ ) as pm:
291
+ future = loop.run_in_executor(None, do_translate, pm, translation_config)
292
+ try:
293
+ async for event in callback:
294
+ event = event.kwargs
295
+ yield event
296
+ if event["type"] == "error":
297
+ break
298
+ except CancelledError:
299
+ cancel_event.set()
300
+ except KeyboardInterrupt:
301
+ logger.info("Translation cancelled by user through keyboard interrupt")
302
+ cancel_event.set()
303
+ if cancel_event.is_set():
304
+ future.cancel()
305
+ logger.info("Waiting for translation to finish...")
306
+ await finish_event.wait()
307
+
308
+
309
+ def do_translate(pm, translation_config):
310
+ try:
311
+ translation_config.progress_monitor = pm
312
+ original_pdf_path = translation_config.input_file
313
+ logger.info(f"start to translate: {original_pdf_path}")
314
+ start_time = time.time()
315
+ doc_input = Document(original_pdf_path)
316
+ if translation_config.debug:
317
+ logger.debug("debug mode, save decompressed input pdf")
318
+ output_path = translation_config.get_working_file_path(
319
+ "input.decompressed.pdf",
320
+ )
321
+ doc_input.save(output_path, expand=True, pretty=True)
322
+ # Continue with original processing
323
+ temp_pdf_path = translation_config.get_working_file_path("input.pdf")
324
+ doc_pdf2zh = Document(original_pdf_path)
325
+ resfont = "china-ss"
326
+ for page in doc_pdf2zh:
327
+ page.insert_font(resfont, None)
328
+ doc_pdf2zh.save(temp_pdf_path)
329
+ il_creater = ILCreater(translation_config)
330
+ il_creater.mupdf = doc_input
331
+ xml_converter = XMLConverter()
332
+ logger.debug(f"start parse il from {temp_pdf_path}")
333
+ with Path(temp_pdf_path).open("rb") as f:
334
+ start_parse_il(
335
+ f,
336
+ doc_zh=doc_pdf2zh,
337
+ resfont=resfont,
338
+ il_creater=il_creater,
339
+ translation_config=translation_config,
340
+ )
341
+ logger.debug(f"finish parse il from {temp_pdf_path}")
342
+ docs = il_creater.create_il()
343
+ logger.debug(f"finish create il from {temp_pdf_path}")
344
+ if translation_config.debug:
345
+ xml_converter.write_json(
346
+ docs,
347
+ translation_config.get_working_file_path("create_il.debug.json"),
348
+ )
349
+ # Generate layouts for all pages
350
+ logger.debug("start generating layouts")
351
+ docs = LayoutParser(translation_config).process(docs, doc_input)
352
+ logger.debug("finish generating layouts")
353
+ if translation_config.debug:
354
+ xml_converter.write_json(
355
+ docs,
356
+ translation_config.get_working_file_path("layout_generator.json"),
357
+ )
358
+ ParagraphFinder(translation_config).process(docs)
359
+ logger.debug(f"finish paragraph finder from {temp_pdf_path}")
360
+ if translation_config.debug:
361
+ xml_converter.write_json(
362
+ docs,
363
+ translation_config.get_working_file_path("paragraph_finder.json"),
364
+ )
365
+ StylesAndFormulas(translation_config).process(docs)
366
+ logger.debug(f"finish styles and formulas from {temp_pdf_path}")
367
+ if translation_config.debug:
368
+ xml_converter.write_json(
369
+ docs,
370
+ translation_config.get_working_file_path("styles_and_formulas.json"),
371
+ )
372
+ RemoveDescent(translation_config).process(docs)
373
+ logger.debug(f"finish remove descent from {temp_pdf_path}")
374
+ if translation_config.debug:
375
+ xml_converter.write_json(
376
+ docs,
377
+ translation_config.get_working_file_path("remove_descent.json"),
378
+ )
379
+ translate_engine = translation_config.translator
380
+ ILTranslator(translate_engine, translation_config).translate(docs)
381
+ logger.debug(f"finish ILTranslator from {temp_pdf_path}")
382
+ if translation_config.debug:
383
+ xml_converter.write_json(
384
+ docs,
385
+ translation_config.get_working_file_path("il_translated.json"),
386
+ )
387
+
388
+ if translation_config.debug:
389
+ AddDebugInformation(translation_config).process(docs)
390
+ xml_converter.write_json(
391
+ docs,
392
+ translation_config.get_working_file_path("add_debug_information.json"),
393
+ )
394
+
395
+ Typesetting(translation_config).typsetting_document(docs)
396
+ logger.debug(f"finish typsetting from {temp_pdf_path}")
397
+ if translation_config.debug:
398
+ xml_converter.write_json(
399
+ docs,
400
+ translation_config.get_working_file_path("typsetting.json"),
401
+ )
402
+ # deepcopy
403
+ # docs2 = xml_converter.deepcopy(docs)
404
+ pdf_creater = PDFCreater(temp_pdf_path, docs, translation_config)
405
+ result = pdf_creater.write(translation_config)
406
+ finish_time = time.time()
407
+ result.original_pdf_path = original_pdf_path
408
+ result.total_seconds = finish_time - start_time
409
+ logger.info(
410
+ f"finish translate: {original_pdf_path}, cost: {finish_time - start_time} s",
411
+ )
412
+ pm.translate_done(result)
413
+ return result
414
+ except Exception as e:
415
+ logger.exception(f"translate error: {e}")
416
+ pm.translate_error(e)
417
+ raise
418
+ finally:
419
+ logger.debug("do_translate finally")
420
+ pm.on_finish()
421
+
422
+
423
+ def download_font_assets():
424
+ """Download and verify font assets."""
425
+ for name, url, expected_hash in FONT_ASSETS:
426
+ save_path = get_cache_file_path(name)
427
+
428
+ # Check if file exists and has correct hash
429
+ if Path(save_path).exists():
430
+ if verify_file_hash(save_path, expected_hash):
431
+ continue
432
+ else:
433
+ logger.warning(f"Hash mismatch for {name}, re-downloading...")
434
+ Path(save_path).unlink()
435
+
436
+ # Download file
437
+ r = httpx.get(url, follow_redirects=True)
438
+ if not r.is_success:
439
+ logger.critical("cannot download %s font", name, exc_info=True)
440
+ exit(1)
441
+
442
+ # Save and verify
443
+ with Path(save_path).open("wb") as f:
444
+ f.write(r.content)
445
+
446
+ if not verify_file_hash(save_path, expected_hash):
447
+ logger.critical(f"Downloaded file {name} has incorrect hash!")
448
+ Path(save_path).unlink()
449
+ exit(1)
450
+
451
+ logger.info(f"Successfully downloaded and verified {name}")
452
+
453
+
454
+ def create_cache_folder():
455
+ try:
456
+ logger.debug(f"create cache folder at {CACHE_FOLDER}")
457
+ Path(CACHE_FOLDER).mkdir(parents=True, exist_ok=True)
458
+ except OSError:
459
+ logger.critical(
460
+ f"Failed to create cache folder at {CACHE_FOLDER}",
461
+ exc_info=True,
462
+ )
463
+ exit(1)
464
+
465
+
466
+ def init():
467
+ create_cache_folder()
468
+ download_font_assets()