BabelDOC 0.1.19__tar.gz → 0.1.20__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 (80) hide show
  1. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/workflows/test.yml +19 -5
  2. {babeldoc-0.1.19 → babeldoc-0.1.20}/PKG-INFO +82 -1
  3. {babeldoc-0.1.19 → babeldoc-0.1.20}/README.md +81 -0
  4. babeldoc-0.1.20/babeldoc/__init__.py +1 -0
  5. babeldoc-0.1.20/babeldoc/assets/assets.py +439 -0
  6. babeldoc-0.1.20/babeldoc/assets/embedding_assets_metadata.py +443 -0
  7. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/const.py +7 -2
  8. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/translator/translator.py +8 -4
  9. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/utils/fontmap.py +110 -104
  10. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/docvision/doclayout.py +8 -32
  11. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/high_level.py +2 -70
  12. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/main.py +28 -14
  13. babeldoc-0.1.20/babeldoc/tools/generate_font_metadata.py +101 -0
  14. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/translation_config.py +3 -1
  15. {babeldoc-0.1.19 → babeldoc-0.1.20}/pyproject.toml +2 -2
  16. {babeldoc-0.1.19 → babeldoc-0.1.20}/uv.lock +87 -125
  17. babeldoc-0.1.19/babeldoc/__init__.py +0 -1
  18. {babeldoc-0.1.19 → babeldoc-0.1.20}/.cursorignore +0 -0
  19. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/ISSUE_TEMPLATE/bug_report.yaml +0 -0
  20. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/ISSUE_TEMPLATE/feature_request.yaml +0 -0
  21. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/dependabot.yml +0 -0
  22. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/labels.yml +0 -0
  23. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/release-drafter.yml +0 -0
  24. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/workflows/codeql.yml +0 -0
  25. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/workflows/docs.yml +0 -0
  26. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/workflows/labeler.yml +0 -0
  27. {babeldoc-0.1.19 → babeldoc-0.1.20}/.github/workflows/publish-to-pypi.yml +0 -0
  28. {babeldoc-0.1.19 → babeldoc-0.1.20}/.gitignore +0 -0
  29. {babeldoc-0.1.19 → babeldoc-0.1.20}/.pre-commit-config.yaml +0 -0
  30. {babeldoc-0.1.19 → babeldoc-0.1.20}/LICENSE +0 -0
  31. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/asynchronize/__init__.py +0 -0
  32. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/converter.py +0 -0
  33. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/__init__.py +0 -0
  34. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/backend/__init__.py +0 -0
  35. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/backend/pdf_creater.py +0 -0
  36. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/frontend/__init__.py +0 -0
  37. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/frontend/il_creater.py +0 -0
  38. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/il_version_1.py +0 -0
  39. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/il_version_1.rnc +0 -0
  40. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/il_version_1.rng +0 -0
  41. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/il_version_1.xsd +0 -0
  42. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/midend/__init__.py +0 -0
  43. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/midend/add_debug_information.py +0 -0
  44. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/midend/il_translator.py +0 -0
  45. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/midend/layout_parser.py +0 -0
  46. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/midend/paragraph_finder.py +0 -0
  47. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/midend/remove_descent.py +0 -0
  48. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/midend/styles_and_formulas.py +0 -0
  49. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/midend/typesetting.py +0 -0
  50. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/translator/__init__.py +0 -0
  51. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/translator/cache.py +0 -0
  52. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/utils/layout_helper.py +0 -0
  53. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/utils/style_helper.py +0 -0
  54. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/document_il/xml_converter.py +0 -0
  55. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/docvision/README.md +0 -0
  56. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/docvision/__init__.py +0 -0
  57. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/docvision/rpc_doclayout.py +0 -0
  58. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/pdfinterp.py +0 -0
  59. {babeldoc-0.1.19 → babeldoc-0.1.20}/babeldoc/progress_monitor.py +0 -0
  60. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/CODE_OF_CONDUCT.md +0 -0
  61. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/CONTRIBUTING.md +0 -0
  62. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/CONTRIBUTOR_REWARD.md +0 -0
  63. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/ImplementationDetails/AsyncTranslate/AsyncTranslate.md +0 -0
  64. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/ImplementationDetails/ILTranslator/ILTranslator.md +0 -0
  65. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/ImplementationDetails/PDFCreation/PDFCreation.md +0 -0
  66. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/ImplementationDetails/PDFParsing/PDFParsing.md +0 -0
  67. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/ImplementationDetails/ParagraphFinding/ParagraphFinding.md +0 -0
  68. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/ImplementationDetails/README.md +0 -0
  69. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/ImplementationDetails/StylesAndFormulas/StylesAndFormulas.md +0 -0
  70. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/ImplementationDetails/Typesetting/Typesetting.md +0 -0
  71. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/README.md +0 -0
  72. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/deploy.sh +0 -0
  73. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/images/babeldoc-banner.png +0 -0
  74. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/images/babeldoc-contributor_reward_example.png +0 -0
  75. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/images/babeldoc-preview.gif +0 -0
  76. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/index.md +0 -0
  77. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/intro-to-pdf-object.md +0 -0
  78. {babeldoc-0.1.19 → babeldoc-0.1.20}/docs/requirements.txt +0 -0
  79. {babeldoc-0.1.19 → babeldoc-0.1.20}/mkdocs.yml +0 -0
  80. {babeldoc-0.1.19 → babeldoc-0.1.20}/tests/test_translation_config.py +0 -0
@@ -21,20 +21,34 @@ jobs:
21
21
  - uses: actions/checkout@v4
22
22
  with:
23
23
  persist-credentials: false
24
- - name: Cached models
25
- id: cache-mods
24
+ - name: Cached Assets
25
+ id: cache-assets
26
26
  uses: actions/cache@v4.2.0
27
27
  with:
28
- path: ~/.cache/huggingface/hub/models--wybxc--DocLayout-YOLO-DocStructBench-onnx
29
- key: ${{runner.os}}-huggingmodel-wybxc--DocLayout-YOLO-DocStructBench-onnx
28
+ path: ~/.cache/babeldoc
29
+ key: babeldoc-assets-${{ hashFiles('babeldoc/assets/embedding_assets_metadata.py') }}
30
30
  - name: Setup uv with Python ${{ matrix.python-version }}
31
31
  uses: astral-sh/setup-uv@1edb52594c857e2b5b13128931090f0640537287 # v5.3.0
32
32
  with:
33
33
  python-version: ${{ matrix.python-version }}
34
34
  enable-cache: true
35
35
  cache-dependency-glob: "uv.lock"
36
-
36
+ - name: Warm up cache
37
+ run: |
38
+ uv run babeldoc --warmup
37
39
  - name: Run tests
38
40
  run: |
39
41
  uv run babeldoc --help
40
42
  uv run babeldoc --bing --files examples/ci/test.pdf
43
+ - name: Generate offline assets package
44
+ run: |
45
+ uv run babeldoc --generate-offline-assets /tmp/offline_assets
46
+ - name: Restore offline assets package
47
+ run: |
48
+ rm -rf ~/.cache/babeldoc
49
+ uv run babeldoc --restore-offline-assets /tmp/offline_assets
50
+ - name: Clean up
51
+ run: |
52
+ rm -rf /tmp/offline_assets
53
+ rm -rf ~/.cache/babeldoc/cache.v1.db
54
+ rm -rf ~/.cache/babeldoc/working
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: BabelDOC
3
- Version: 0.1.19
3
+ Version: 0.1.20
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
@@ -191,6 +191,22 @@ uv run babeldoc --bing --files example.pdf --files example2.pdf
191
191
  - `--debug`, `-d`: Enable debug logging level and export detailed intermediate results in `~/.cache/yadt/working`.
192
192
  - `--report-interval`: Progress report interval in seconds (default: 0.1).
193
193
 
194
+ ### Offline Assets Management
195
+
196
+ - `--generate-offline-assets`: Generate an offline assets package in the specified directory. This creates a zip file containing all required models and fonts.
197
+ - `--restore-offline-assets`: Restore an offline assets package from the specified file. This extracts models and fonts from a previously generated package.
198
+
199
+ > [!TIP]
200
+ >
201
+ > 1. Offline assets packages are useful for environments without internet access or to speed up installation on multiple machines.
202
+ > 2. Generate a package once with `babeldoc --generate-offline-assets /path/to/output/dir` and then distribute it.
203
+ > 3. Restore the package on target machines with `babeldoc --restore-offline-assets /path/to/offline_assets_*.zip`.
204
+ > 4. The offline assets package name cannot be modified because the file list hash is encoded in the name.
205
+ > 5. If you provide a directory path to `--restore-offline-assets`, the tool will automatically look for the correct offline assets package file in that directory.
206
+ > 6. The package contains all necessary fonts and models required for document processing, ensuring consistent results across different environments.
207
+ > 7. The integrity of all assets is verified using SHA3-256 hashes during both packaging and restoration.
208
+ > 8. If you're deploying in an air-gapped environment, make sure to generate the package on a machine with internet access first.
209
+
194
210
  ### Configuration File
195
211
 
196
212
  - `--config`, `-c`: Configuration file path. Use the TOML format.
@@ -209,9 +225,49 @@ openai = true
209
225
  openai-model = "SOME_ALSOME_MODEL"
210
226
  openai-base-url = "https://example.example/v1"
211
227
  openai-api-key = "[KEY]"
228
+ # Offline assets management
229
+ # generate-offline-assets = "/path/to/output/dir"
230
+ # restore-offline-assets = "/path/to/offline_assets_package.zip"
212
231
  # All other options can also be set in the configuration file.
213
232
  ```
214
233
 
234
+ For a more comprehensive configuration example with offline assets management:
235
+
236
+ ```toml
237
+ [babeldoc]
238
+ # Basic settings
239
+ debug = true
240
+ lang-in = "en-US"
241
+ lang-out = "zh-CN"
242
+ qps = 10
243
+ output = "/path/to/output/dir"
244
+
245
+ # Translation service
246
+ openai = true
247
+ openai-model = "gpt-4o-mini"
248
+ openai-base-url = "https://api.openai.com/v1"
249
+ openai-api-key = "your-api-key-here"
250
+
251
+ # PDF processing options
252
+ split-short-lines = false
253
+ short-line-split-factor = 0.8
254
+ skip-clean = false
255
+ dual-translate-first = false
256
+ disable-rich-text-translate = false
257
+ use-alternating-pages-dual = false
258
+
259
+ # Output control
260
+ no-dual = false
261
+ no-mono = false
262
+ min-text-length = 5
263
+ report-interval = 0.5
264
+
265
+ # Offline assets management
266
+ # Uncomment one of these options as needed:
267
+ # generate-offline-assets = "/path/to/output/dir"
268
+ # restore-offline-assets = "/path/to/offline_assets_package.zip"
269
+ ```
270
+
215
271
  ## Python API
216
272
 
217
273
  You can refer to the example in [main.py](https://github.com/funstory-ai/yadt/blob/main/babeldoc/main.py) to use BabelDOC's Python API.
@@ -222,6 +278,31 @@ Please note:
222
278
 
223
279
  2. The current `TranslationConfig` does not fully validate input parameters, so you need to ensure the validity of input parameters
224
280
 
281
+ 3. For offline assets management, you can use the following functions:
282
+ ```python
283
+ # Generate an offline assets package
284
+ from pathlib import Path
285
+ import babeldoc.assets.assets
286
+
287
+ # Generate package to a specific directory
288
+ # path is optional, default is ~/.cache/babeldoc/assets/offline_assets_{hash}.zip
289
+ babeldoc.assets.assets.generate_offline_assets_package(Path("/path/to/output/dir"))
290
+
291
+ # Restore from a package file
292
+ # path is optional, default is ~/.cache/babeldoc/assets/offline_assets_{hash}.zip
293
+ babeldoc.assets.assets.restore_offline_assets_package(Path("/path/to/offline_assets_package.zip"))
294
+
295
+ # You can also restore from a directory containing the offline assets package
296
+ # The tool will automatically find the correct package file based on the hash
297
+ babeldoc.assets.assets.restore_offline_assets_package(Path("/path/to/directory"))
298
+ ```
299
+
300
+ > [!TIP]
301
+ >
302
+ > 1. The offline assets package name cannot be modified because the file list hash is encoded in the name.
303
+ > 2. When using in production environments, it's recommended to pre-generate the assets package and include it with your application distribution.
304
+ > 3. The package verification ensures that all required assets are intact and match their expected checksums.
305
+
225
306
  ## Background
226
307
 
227
308
  There are a lot projects and teams working on to make document editing and translating easier like:
@@ -157,6 +157,22 @@ uv run babeldoc --bing --files example.pdf --files example2.pdf
157
157
  - `--debug`, `-d`: Enable debug logging level and export detailed intermediate results in `~/.cache/yadt/working`.
158
158
  - `--report-interval`: Progress report interval in seconds (default: 0.1).
159
159
 
160
+ ### Offline Assets Management
161
+
162
+ - `--generate-offline-assets`: Generate an offline assets package in the specified directory. This creates a zip file containing all required models and fonts.
163
+ - `--restore-offline-assets`: Restore an offline assets package from the specified file. This extracts models and fonts from a previously generated package.
164
+
165
+ > [!TIP]
166
+ >
167
+ > 1. Offline assets packages are useful for environments without internet access or to speed up installation on multiple machines.
168
+ > 2. Generate a package once with `babeldoc --generate-offline-assets /path/to/output/dir` and then distribute it.
169
+ > 3. Restore the package on target machines with `babeldoc --restore-offline-assets /path/to/offline_assets_*.zip`.
170
+ > 4. The offline assets package name cannot be modified because the file list hash is encoded in the name.
171
+ > 5. If you provide a directory path to `--restore-offline-assets`, the tool will automatically look for the correct offline assets package file in that directory.
172
+ > 6. The package contains all necessary fonts and models required for document processing, ensuring consistent results across different environments.
173
+ > 7. The integrity of all assets is verified using SHA3-256 hashes during both packaging and restoration.
174
+ > 8. If you're deploying in an air-gapped environment, make sure to generate the package on a machine with internet access first.
175
+
160
176
  ### Configuration File
161
177
 
162
178
  - `--config`, `-c`: Configuration file path. Use the TOML format.
@@ -175,9 +191,49 @@ openai = true
175
191
  openai-model = "SOME_ALSOME_MODEL"
176
192
  openai-base-url = "https://example.example/v1"
177
193
  openai-api-key = "[KEY]"
194
+ # Offline assets management
195
+ # generate-offline-assets = "/path/to/output/dir"
196
+ # restore-offline-assets = "/path/to/offline_assets_package.zip"
178
197
  # All other options can also be set in the configuration file.
179
198
  ```
180
199
 
200
+ For a more comprehensive configuration example with offline assets management:
201
+
202
+ ```toml
203
+ [babeldoc]
204
+ # Basic settings
205
+ debug = true
206
+ lang-in = "en-US"
207
+ lang-out = "zh-CN"
208
+ qps = 10
209
+ output = "/path/to/output/dir"
210
+
211
+ # Translation service
212
+ openai = true
213
+ openai-model = "gpt-4o-mini"
214
+ openai-base-url = "https://api.openai.com/v1"
215
+ openai-api-key = "your-api-key-here"
216
+
217
+ # PDF processing options
218
+ split-short-lines = false
219
+ short-line-split-factor = 0.8
220
+ skip-clean = false
221
+ dual-translate-first = false
222
+ disable-rich-text-translate = false
223
+ use-alternating-pages-dual = false
224
+
225
+ # Output control
226
+ no-dual = false
227
+ no-mono = false
228
+ min-text-length = 5
229
+ report-interval = 0.5
230
+
231
+ # Offline assets management
232
+ # Uncomment one of these options as needed:
233
+ # generate-offline-assets = "/path/to/output/dir"
234
+ # restore-offline-assets = "/path/to/offline_assets_package.zip"
235
+ ```
236
+
181
237
  ## Python API
182
238
 
183
239
  You can refer to the example in [main.py](https://github.com/funstory-ai/yadt/blob/main/babeldoc/main.py) to use BabelDOC's Python API.
@@ -188,6 +244,31 @@ Please note:
188
244
 
189
245
  2. The current `TranslationConfig` does not fully validate input parameters, so you need to ensure the validity of input parameters
190
246
 
247
+ 3. For offline assets management, you can use the following functions:
248
+ ```python
249
+ # Generate an offline assets package
250
+ from pathlib import Path
251
+ import babeldoc.assets.assets
252
+
253
+ # Generate package to a specific directory
254
+ # path is optional, default is ~/.cache/babeldoc/assets/offline_assets_{hash}.zip
255
+ babeldoc.assets.assets.generate_offline_assets_package(Path("/path/to/output/dir"))
256
+
257
+ # Restore from a package file
258
+ # path is optional, default is ~/.cache/babeldoc/assets/offline_assets_{hash}.zip
259
+ babeldoc.assets.assets.restore_offline_assets_package(Path("/path/to/offline_assets_package.zip"))
260
+
261
+ # You can also restore from a directory containing the offline assets package
262
+ # The tool will automatically find the correct package file based on the hash
263
+ babeldoc.assets.assets.restore_offline_assets_package(Path("/path/to/directory"))
264
+ ```
265
+
266
+ > [!TIP]
267
+ >
268
+ > 1. The offline assets package name cannot be modified because the file list hash is encoded in the name.
269
+ > 2. When using in production environments, it's recommended to pre-generate the assets package and include it with your application distribution.
270
+ > 3. The package verification ensures that all required assets are intact and match their expected checksums.
271
+
191
272
  ## Background
192
273
 
193
274
  There are a lot projects and teams working on to make document editing and translating easier like:
@@ -0,0 +1 @@
1
+ __version__ = "0.1.20"
@@ -0,0 +1,439 @@
1
+ import asyncio
2
+ import hashlib
3
+ import logging
4
+ import threading
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ from babeldoc.assets import embedding_assets_metadata
10
+ from babeldoc.assets.embedding_assets_metadata import DOC_LAYOUT_ONNX_MODEL_URL
11
+ from babeldoc.assets.embedding_assets_metadata import (
12
+ DOCLAYOUT_YOLO_DOCSTRUCTBENCH_IMGSZ1024ONNX_SHA3_256,
13
+ )
14
+ from babeldoc.assets.embedding_assets_metadata import EMBEDDING_FONT_METADATA
15
+ from babeldoc.assets.embedding_assets_metadata import FONT_METADATA_URL
16
+ from babeldoc.assets.embedding_assets_metadata import FONT_URL_BY_UPSTREAM
17
+ from babeldoc.const import get_cache_file_path
18
+ from tenacity import retry
19
+ from tenacity import stop_after_attempt
20
+ from tenacity import wait_exponential
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class ResultContainer:
26
+ def __init__(self):
27
+ self.result = None
28
+
29
+ def set_result(self, result):
30
+ self.result = result
31
+
32
+
33
+ def run_in_another_thread(coro):
34
+ result_container = ResultContainer()
35
+
36
+ def _wrapper():
37
+ result_container.set_result(asyncio.run(coro))
38
+
39
+ thread = threading.Thread(target=_wrapper)
40
+ thread.start()
41
+ thread.join()
42
+ return result_container.result
43
+
44
+
45
+ def run_coro(coro):
46
+ try:
47
+ return asyncio.run(coro)
48
+ except RuntimeError:
49
+ return run_in_another_thread(coro)
50
+
51
+
52
+ def _retry_if_not_cancelled_and_failed(retry_state):
53
+ """Only retry if the exception is not CancelledError and the attempt failed."""
54
+ if retry_state.outcome.failed:
55
+ exception = retry_state.outcome.exception()
56
+ # Don't retry on CancelledError
57
+ if isinstance(exception, asyncio.CancelledError):
58
+ logger.debug("Operation was cancelled, not retrying")
59
+ return False
60
+ # Retry on network related errors
61
+ if isinstance(
62
+ exception, httpx.HTTPError | ConnectionError | ValueError | TimeoutError
63
+ ):
64
+ logger.warning(f"Network error occurred: {exception}, will retry")
65
+ return True
66
+ # Don't retry on success
67
+ return False
68
+
69
+
70
+ def verify_file(path: Path, sha3_256: str):
71
+ if not path.exists():
72
+ return False
73
+ hash_ = hashlib.sha3_256()
74
+ with path.open("rb") as f:
75
+ while True:
76
+ chunk = f.read(1024 * 1024)
77
+ if not chunk:
78
+ break
79
+ hash_.update(chunk)
80
+ return hash_.hexdigest() == sha3_256
81
+
82
+
83
+ @retry(
84
+ retry=_retry_if_not_cancelled_and_failed,
85
+ stop=stop_after_attempt(3),
86
+ wait=wait_exponential(multiplier=1, min=1, max=15),
87
+ before_sleep=lambda retry_state: logger.warning(
88
+ f"Download file failed, retrying in {retry_state.next_action.sleep} seconds... "
89
+ f"(Attempt {retry_state.attempt_number}/3)"
90
+ ),
91
+ )
92
+ async def download_file(
93
+ client: httpx.AsyncClient | None = None,
94
+ url: str = None,
95
+ path: Path = None,
96
+ sha3_256: str = None,
97
+ ):
98
+ if client is None:
99
+ async with httpx.AsyncClient() as client:
100
+ response = await client.get(url, follow_redirects=True)
101
+ else:
102
+ response = await client.get(url, follow_redirects=True)
103
+
104
+ response.raise_for_status()
105
+ with path.open("wb") as f:
106
+ f.write(response.content)
107
+ if not verify_file(path, sha3_256):
108
+ path.unlink(missing_ok=True)
109
+ raise ValueError(f"File {path} is corrupted")
110
+
111
+
112
+ @retry(
113
+ retry=_retry_if_not_cancelled_and_failed,
114
+ stop=stop_after_attempt(3),
115
+ wait=wait_exponential(multiplier=1, min=1, max=15),
116
+ before_sleep=lambda retry_state: logger.warning(
117
+ f"Get font metadata failed, retrying in {retry_state.next_action.sleep} seconds... "
118
+ f"(Attempt {retry_state.attempt_number}/3)"
119
+ ),
120
+ )
121
+ async def get_font_metadata(
122
+ client: httpx.AsyncClient | None = None, upstream: str = None
123
+ ):
124
+ if upstream not in FONT_METADATA_URL:
125
+ logger.critical(f"Invalid upstream: {upstream}")
126
+ exit(1)
127
+
128
+ if client is None:
129
+ async with httpx.AsyncClient() as client:
130
+ response = await client.get(
131
+ FONT_METADATA_URL[upstream], follow_redirects=True
132
+ )
133
+ else:
134
+ response = await client.get(FONT_METADATA_URL[upstream], follow_redirects=True)
135
+
136
+ response.raise_for_status()
137
+ logger.debug(f"Get font metadata from {upstream} success")
138
+ return upstream, response.json()
139
+
140
+
141
+ async def get_fastest_upstream_for_font(
142
+ client: httpx.AsyncClient | None = None, exclude_upstream: list[str] = None
143
+ ):
144
+ tasks: list[asyncio.Task[tuple[str, dict]]] = []
145
+ for upstream in FONT_METADATA_URL:
146
+ if exclude_upstream and upstream in exclude_upstream:
147
+ continue
148
+ tasks.append(asyncio.create_task(get_font_metadata(client, upstream)))
149
+ for future in asyncio.as_completed(tasks):
150
+ try:
151
+ result = await future
152
+ for task in tasks:
153
+ if not task.done():
154
+ task.cancel()
155
+ return result
156
+ except Exception as e:
157
+ logger.exception(f"Error getting font metadata: {e}")
158
+ logger.error("All upstreams failed")
159
+ return None, None
160
+
161
+
162
+ async def get_fastest_upstream_for_model(client: httpx.AsyncClient | None = None):
163
+ return await get_fastest_upstream_for_font(client, exclude_upstream=["github"])
164
+
165
+
166
+ async def get_fastest_upstream(client: httpx.AsyncClient | None = None):
167
+ (
168
+ fastest_upstream_for_font,
169
+ online_font_metadata,
170
+ ) = await get_fastest_upstream_for_font(client)
171
+ if fastest_upstream_for_font is None:
172
+ logger.error("Failed to get fastest upstream")
173
+ exit(1)
174
+
175
+ if fastest_upstream_for_font == "github":
176
+ # since github is only store font, we need to get the fastest upstream for model
177
+ fastest_upstream_for_model, _ = await get_fastest_upstream_for_model(client)
178
+ if fastest_upstream_for_model is None:
179
+ logger.error("Failed to get fastest upstream")
180
+ exit(1)
181
+ else:
182
+ fastest_upstream_for_model = fastest_upstream_for_font
183
+
184
+ return online_font_metadata, fastest_upstream_for_font, fastest_upstream_for_model
185
+
186
+
187
+ async def get_doclayout_onnx_model_path_async(client: httpx.AsyncClient | None = None):
188
+ onnx_path = get_cache_file_path(
189
+ "doclayout_yolo_docstructbench_imgsz1024.onnx", "models"
190
+ )
191
+ if verify_file(onnx_path, DOCLAYOUT_YOLO_DOCSTRUCTBENCH_IMGSZ1024ONNX_SHA3_256):
192
+ return onnx_path
193
+
194
+ logger.info("doclayout onnx model not found or corrupted, downloading...")
195
+ fastest_upstream, _ = await get_fastest_upstream_for_model(client)
196
+ if fastest_upstream is None:
197
+ logger.error("Failed to get fastest upstream")
198
+ exit(1)
199
+
200
+ url = DOC_LAYOUT_ONNX_MODEL_URL[fastest_upstream]
201
+
202
+ await download_file(
203
+ client, url, onnx_path, DOCLAYOUT_YOLO_DOCSTRUCTBENCH_IMGSZ1024ONNX_SHA3_256
204
+ )
205
+ logger.info(f"Download doclayout onnx model from {fastest_upstream} success")
206
+ return onnx_path
207
+
208
+
209
+ def get_doclayout_onnx_model_path():
210
+ return run_coro(get_doclayout_onnx_model_path_async())
211
+
212
+
213
+ def get_font_url_by_name_and_upstream(font_file_name: str, upstream: str):
214
+ if upstream not in FONT_URL_BY_UPSTREAM:
215
+ logger.critical(f"Invalid upstream: {upstream}")
216
+ exit(1)
217
+
218
+ return FONT_URL_BY_UPSTREAM[upstream](font_file_name)
219
+
220
+
221
+ async def get_font_and_metadata_async(
222
+ font_file_name: str,
223
+ client: httpx.AsyncClient | None = None,
224
+ fastest_upstream: str | None = None,
225
+ font_metadata: dict | None = None,
226
+ ):
227
+ cache_file_path = get_cache_file_path(font_file_name, "fonts")
228
+ if font_file_name in EMBEDDING_FONT_METADATA and verify_file(
229
+ cache_file_path, EMBEDDING_FONT_METADATA[font_file_name]["sha3_256"]
230
+ ):
231
+ return cache_file_path, EMBEDDING_FONT_METADATA[font_file_name]
232
+
233
+ logger.info(f"Font {cache_file_path} not found or corrupted, downloading...")
234
+ if fastest_upstream is None:
235
+ fastest_upstream, font_metadata = await get_fastest_upstream_for_font(client)
236
+ if fastest_upstream is None:
237
+ logger.critical("Failed to get fastest upstream")
238
+ exit(1)
239
+
240
+ if font_file_name not in font_metadata:
241
+ logger.critical(f"Font {font_file_name} not found in {font_metadata}")
242
+ exit(1)
243
+
244
+ if verify_file(cache_file_path, font_metadata[font_file_name]["sha3_256"]):
245
+ return cache_file_path, font_metadata[font_file_name]
246
+
247
+ assert font_metadata is not None
248
+
249
+ url = get_font_url_by_name_and_upstream(font_file_name, fastest_upstream)
250
+ if "sha3_256" not in font_metadata[font_file_name]:
251
+ logger.critical(f"Font {font_file_name} not found in {font_metadata}")
252
+ exit(1)
253
+ await download_file(
254
+ client, url, cache_file_path, font_metadata[font_file_name]["sha3_256"]
255
+ )
256
+ return cache_file_path, font_metadata[font_file_name]
257
+
258
+
259
+ def get_font_and_metadata(font_file_name: str):
260
+ return run_coro(get_font_and_metadata_async(font_file_name))
261
+
262
+
263
+ def get_font_family(lang_code: str):
264
+ font_family = embedding_assets_metadata.get_font_family(lang_code)
265
+ return font_family
266
+
267
+
268
+ async def download_all_fonts_async(client: httpx.AsyncClient | None = None):
269
+ for font_file_name in EMBEDDING_FONT_METADATA:
270
+ if not verify_file(
271
+ get_cache_file_path(font_file_name, "fonts"),
272
+ EMBEDDING_FONT_METADATA[font_file_name]["sha3_256"],
273
+ ):
274
+ break
275
+ else:
276
+ logger.debug("All fonts are already downloaded")
277
+ return
278
+
279
+ fastest_upstream, font_metadata = await get_fastest_upstream_for_font(client)
280
+ if fastest_upstream is None:
281
+ logger.error("Failed to get fastest upstream")
282
+ exit(1)
283
+ logger.info(f"Downloading fonts from {fastest_upstream}")
284
+
285
+ font_tasks = [
286
+ asyncio.create_task(
287
+ get_font_and_metadata_async(
288
+ font_file_name, client, fastest_upstream, font_metadata
289
+ )
290
+ )
291
+ for font_file_name in EMBEDDING_FONT_METADATA
292
+ ]
293
+ await asyncio.gather(*font_tasks)
294
+
295
+
296
+ async def async_warmup():
297
+ logger.info("Downloading all assets...")
298
+ async with httpx.AsyncClient() as client:
299
+ onnx_task = asyncio.create_task(get_doclayout_onnx_model_path_async(client))
300
+ font_tasks = asyncio.create_task(download_all_fonts_async(client))
301
+ await asyncio.gather(onnx_task, font_tasks)
302
+
303
+
304
+ def warmup():
305
+ run_coro(async_warmup())
306
+
307
+
308
+ def generate_all_assets_file_list():
309
+ result = {}
310
+ result["fonts"] = []
311
+ result["models"] = []
312
+ for font_file_name in EMBEDDING_FONT_METADATA:
313
+ result["fonts"].append(
314
+ {
315
+ "name": font_file_name,
316
+ "sha3_256": EMBEDDING_FONT_METADATA[font_file_name]["sha3_256"],
317
+ }
318
+ )
319
+ result["models"].append(
320
+ {
321
+ "name": "doclayout_yolo_docstructbench_imgsz1024.onnx",
322
+ "sha3_256": DOCLAYOUT_YOLO_DOCSTRUCTBENCH_IMGSZ1024ONNX_SHA3_256,
323
+ }
324
+ )
325
+ return result
326
+
327
+
328
+ async def generate_offline_assets_package_async(output_directory: Path | None = None):
329
+ await async_warmup()
330
+ logger.info("Generating offline assets package...")
331
+ file_list = generate_all_assets_file_list()
332
+ offline_assets_tag = get_offline_assets_tag(file_list)
333
+ if output_directory is None:
334
+ output_path = get_cache_file_path(
335
+ f"offline_assets_{offline_assets_tag}.zip", "assets"
336
+ )
337
+ else:
338
+ output_directory.mkdir(parents=True, exist_ok=True)
339
+ output_path = output_directory / f"offline_assets_{offline_assets_tag}.zip"
340
+ with zipfile.ZipFile(
341
+ output_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
342
+ ) as zipf:
343
+ for file_type, file_descs in file_list.items():
344
+ # zipf.mkdir(file_type)
345
+ for file_desc in file_descs:
346
+ file_name = file_desc["name"]
347
+ sha3_256 = file_desc["sha3_256"]
348
+ file_path = get_cache_file_path(file_name, file_type)
349
+ if not verify_file(file_path, sha3_256):
350
+ logger.error(f"File {file_path} is corrupted")
351
+ exit(1)
352
+
353
+ with file_path.open("rb") as f:
354
+ zipf.writestr(f"{file_type}/{file_name}", f.read())
355
+ logger.info(f"Offline assets package generated at {output_path}")
356
+
357
+
358
+ async def restore_offline_assets_package_async(input_path: Path | None = None):
359
+ file_list = generate_all_assets_file_list()
360
+ offline_assets_tag = get_offline_assets_tag(file_list)
361
+ if input_path is None:
362
+ input_path = get_cache_file_path(
363
+ f"offline_assets_{offline_assets_tag}.zip", "assets"
364
+ )
365
+ else:
366
+ if input_path.exists() and input_path.is_dir():
367
+ input_path = input_path / f"offline_assets_{offline_assets_tag}.zip"
368
+ if not input_path.exists():
369
+ logger.critical(f"Offline assets package not found: {input_path}")
370
+ exit(1)
371
+
372
+ import re
373
+
374
+ offline_assets_tag_from_input_path = re.match(
375
+ r"offline_assets_(.*)\.zip", input_path.name
376
+ ).group(1)
377
+ if offline_assets_tag != offline_assets_tag_from_input_path:
378
+ logger.critical(
379
+ f"Offline assets tag mismatch: {offline_assets_tag} != {offline_assets_tag_from_input_path}"
380
+ )
381
+ exit(1)
382
+ nothing_changed = True
383
+ with zipfile.ZipFile(input_path, "r") as zipf:
384
+ for file_type, file_descs in file_list.items():
385
+ for file_desc in file_descs:
386
+ file_name = file_desc["name"]
387
+ file_path = get_cache_file_path(file_name, file_type)
388
+
389
+ if verify_file(file_path, file_desc["sha3_256"]):
390
+ continue
391
+ nothing_changed = False
392
+ with zipf.open(f"{file_type}/{file_name}", "r") as f:
393
+ with file_path.open("wb") as f2:
394
+ f2.write(f.read())
395
+ if not verify_file(file_path, file_desc["sha3_256"]):
396
+ logger.critical(
397
+ "Offline assets package is corrupted, please delete it and try again"
398
+ )
399
+ exit(1)
400
+ if not nothing_changed:
401
+ logger.info(f"Offline assets package restored from {input_path}")
402
+
403
+
404
+ def get_offline_assets_tag(file_list: dict | None = None):
405
+ if file_list is None:
406
+ file_list = generate_all_assets_file_list()
407
+ import orjson
408
+
409
+ # noinspection PyTypeChecker
410
+ offline_assets_tag = hashlib.sha3_256(
411
+ orjson.dumps(
412
+ file_list,
413
+ option=orjson.OPT_APPEND_NEWLINE
414
+ | orjson.OPT_INDENT_2
415
+ | orjson.OPT_SORT_KEYS,
416
+ )
417
+ ).hexdigest()
418
+ return offline_assets_tag
419
+
420
+
421
+ def generate_offline_assets_package(output_directory: Path | None = None):
422
+ return run_coro(generate_offline_assets_package_async(output_directory))
423
+
424
+
425
+ def restore_offline_assets_package(input_path: Path | None = None):
426
+ return run_coro(restore_offline_assets_package_async(input_path))
427
+
428
+
429
+ if __name__ == "__main__":
430
+ from rich.logging import RichHandler
431
+
432
+ logging.basicConfig(level=logging.DEBUG, handlers=[RichHandler()])
433
+ logging.getLogger("httpx").setLevel(logging.WARNING)
434
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
435
+ # warmup()
436
+ # generate_offline_assets_package()
437
+ # restore_offline_assets_package(Path(
438
+ # '/Users/aw/.cache/babeldoc/assets/offline_assets_33971e4940e90ba0c35baacda44bbe83b214f4703a7bdb8b837de97d0383508c.zip'))
439
+ # warmup()