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
File without changes
@@ -0,0 +1,145 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from peewee import SQL
5
+ from peewee import AutoField
6
+ from peewee import CharField
7
+ from peewee import Model
8
+ from peewee import SqliteDatabase
9
+ from peewee import TextField
10
+
11
+ # we don't init the database here
12
+ db = SqliteDatabase(None)
13
+
14
+
15
+ class _TranslationCache(Model):
16
+ id = AutoField()
17
+ translate_engine = CharField(max_length=20)
18
+ translate_engine_params = TextField()
19
+ original_text = TextField()
20
+ translation = TextField()
21
+
22
+ class Meta:
23
+ database = db
24
+ constraints = [
25
+ SQL(
26
+ """
27
+ UNIQUE (
28
+ translate_engine,
29
+ translate_engine_params,
30
+ original_text
31
+ )
32
+ ON CONFLICT REPLACE
33
+ """,
34
+ ),
35
+ ]
36
+
37
+
38
+ class TranslationCache:
39
+ @staticmethod
40
+ def _sort_dict_recursively(obj):
41
+ if isinstance(obj, dict):
42
+ return {
43
+ k: TranslationCache._sort_dict_recursively(v)
44
+ for k in sorted(obj.keys())
45
+ for v in [obj[k]]
46
+ }
47
+ elif isinstance(obj, list):
48
+ return [TranslationCache._sort_dict_recursively(item) for item in obj]
49
+ return obj
50
+
51
+ def __init__(self, translate_engine: str, translate_engine_params: dict = None):
52
+ self.translate_engine = translate_engine
53
+ self.replace_params(translate_engine_params)
54
+
55
+ # The program typically starts multi-threaded translation
56
+ # only after cache parameters are fully configured,
57
+ # so thread safety doesn't need to be considered here.
58
+ def replace_params(self, params: dict = None):
59
+ if params is None:
60
+ params = {}
61
+ self.params = params
62
+ params = self._sort_dict_recursively(params)
63
+ self.translate_engine_params = json.dumps(params)
64
+
65
+ def update_params(self, params: dict = None):
66
+ if params is None:
67
+ params = {}
68
+ self.params.update(params)
69
+ self.replace_params(self.params)
70
+
71
+ def add_params(self, k: str, v):
72
+ self.params[k] = v
73
+ self.replace_params(self.params)
74
+
75
+ # Since peewee and the underlying sqlite are thread-safe,
76
+ # get and set operations don't need locks.
77
+ def get(self, original_text: str) -> str | None:
78
+ result = _TranslationCache.get_or_none(
79
+ translate_engine=self.translate_engine,
80
+ translate_engine_params=self.translate_engine_params,
81
+ original_text=original_text,
82
+ )
83
+ return result.translation if result else None
84
+
85
+ def set(self, original_text: str, translation: str):
86
+ _TranslationCache.create(
87
+ translate_engine=self.translate_engine,
88
+ translate_engine_params=self.translate_engine_params,
89
+ original_text=original_text,
90
+ translation=translation,
91
+ )
92
+
93
+
94
+ def init_db(remove_exists=False):
95
+ cache_folder = Path.home() / ".cache" / "pdf2zh"
96
+ cache_folder.mkdir(parents=True, exist_ok=True)
97
+ # The current version does not support database migration, so add the version number to the file name.
98
+ cache_db_path = cache_folder / "cache.v1.db"
99
+ if remove_exists and cache_db_path.exists():
100
+ cache_db_path.unlink()
101
+ db.init(
102
+ cache_db_path,
103
+ pragmas={
104
+ "journal_mode": "wal",
105
+ "busy_timeout": 1000,
106
+ },
107
+ )
108
+ db.create_tables([_TranslationCache], safe=True)
109
+
110
+
111
+ def init_test_db():
112
+ import tempfile
113
+
114
+ temp_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
115
+ cache_db_path = temp_file.name
116
+ temp_file.close()
117
+
118
+ test_db = SqliteDatabase(
119
+ cache_db_path,
120
+ pragmas={
121
+ "journal_mode": "wal",
122
+ "busy_timeout": 1000,
123
+ },
124
+ )
125
+ test_db.bind([_TranslationCache], bind_refs=False, bind_backrefs=False)
126
+ test_db.connect()
127
+ test_db.create_tables([_TranslationCache], safe=True)
128
+ return test_db
129
+
130
+
131
+ def clean_test_db(test_db):
132
+ test_db.drop_tables([_TranslationCache])
133
+ test_db.close()
134
+ db_path = Path(test_db.database)
135
+ if db_path.exists():
136
+ db_path.unlink()
137
+ wal_path = Path(str(db_path) + "-wal")
138
+ if wal_path.exists():
139
+ wal_path.unlink()
140
+ shm_path = Path(str(db_path) + "-shm")
141
+ if shm_path.exists():
142
+ shm_path.unlink()
143
+
144
+
145
+ init_db()
@@ -0,0 +1,267 @@
1
+ import html
2
+ import logging
3
+ import re
4
+ import threading
5
+ import time
6
+ import unicodedata
7
+ from abc import ABC
8
+
9
+ import openai
10
+ import requests
11
+
12
+ from babeldoc.document_il.translator.cache import TranslationCache
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def remove_control_characters(s):
18
+ return "".join(ch for ch in s if unicodedata.category(ch)[0] != "C")
19
+
20
+
21
+ class RateLimiter:
22
+ def __init__(self, max_qps: int):
23
+ self.max_qps = max_qps
24
+ self.min_interval = 1.0 / max_qps
25
+ self.last_requests = [] # Track last N requests
26
+ self.window_size = max_qps # Track requests in a sliding window
27
+ self.lock = threading.Lock()
28
+
29
+ def wait(self):
30
+ with self.lock:
31
+ now = time.time()
32
+
33
+ # Clean up old requests outside the 1-second window
34
+ while self.last_requests and now - self.last_requests[0] > 1.0:
35
+ self.last_requests.pop(0)
36
+
37
+ # If we have less than max_qps requests in the last second, allow immediately
38
+ if len(self.last_requests) < self.max_qps:
39
+ self.last_requests.append(now)
40
+ return
41
+
42
+ # Otherwise, wait until we can make the next request
43
+ next_time = self.last_requests[0] + 1.0
44
+ if next_time > now:
45
+ time.sleep(next_time - now)
46
+ self.last_requests.pop(0)
47
+ self.last_requests.append(next_time)
48
+
49
+ def set_max_qps(self, max_qps):
50
+ self.max_qps = max_qps
51
+ self.min_interval = 1.0 / max_qps
52
+ self.window_size = max_qps
53
+
54
+
55
+ _translate_rate_limiter = RateLimiter(5)
56
+
57
+
58
+ def set_translate_rate_limiter(max_qps):
59
+ _translate_rate_limiter.set_max_qps(max_qps)
60
+
61
+
62
+ class BaseTranslator(ABC):
63
+ # Due to cache limitations, name should be within 20 characters.
64
+ # cache.py: translate_engine = CharField(max_length=20)
65
+ name = "base"
66
+ lang_map = {}
67
+
68
+ def __init__(self, lang_in, lang_out, ignore_cache):
69
+ self.ignore_cache = ignore_cache
70
+ lang_in = self.lang_map.get(lang_in.lower(), lang_in)
71
+ lang_out = self.lang_map.get(lang_out.lower(), lang_out)
72
+ self.lang_in = lang_in
73
+ self.lang_out = lang_out
74
+
75
+ self.cache = TranslationCache(
76
+ self.name,
77
+ {
78
+ "lang_in": lang_in,
79
+ "lang_out": lang_out,
80
+ },
81
+ )
82
+
83
+ self.translate_call_count = 0
84
+ self.translate_cache_call_count = 0
85
+
86
+ def __del__(self):
87
+ logger.info(f"{self.name} translate call count: {self.translate_call_count}")
88
+ logger.info(
89
+ f"{self.name} translate cache call count: {self.translate_cache_call_count}",
90
+ )
91
+
92
+ def add_cache_impact_parameters(self, k: str, v):
93
+ """
94
+ Add parameters that affect the translation quality to distinguish the translation effects under different parameters.
95
+ :param k: key
96
+ :param v: value
97
+ """
98
+ self.cache.add_params(k, v)
99
+
100
+ def translate(self, text, ignore_cache=False):
101
+ """
102
+ Translate the text, and the other part should call this method.
103
+ :param text: text to translate
104
+ :return: translated text
105
+ """
106
+ self.translate_call_count += 1
107
+ if not (self.ignore_cache or ignore_cache):
108
+ cache = self.cache.get(text)
109
+ if cache is not None:
110
+ self.translate_cache_call_count += 1
111
+ return cache
112
+ _translate_rate_limiter.wait()
113
+ translation = self.do_translate(text)
114
+ if not (self.ignore_cache or ignore_cache):
115
+ self.cache.set(text, translation)
116
+ return translation
117
+
118
+ def do_translate(self, text):
119
+ """
120
+ Actual translate text, override this method
121
+ :param text: text to translate
122
+ :return: translated text
123
+ """
124
+ logger.critical(
125
+ f"Do not call BaseTranslator.do_translate. "
126
+ f"Translator: {self}. "
127
+ f"Text: {text}. ",
128
+ )
129
+ raise NotImplementedError
130
+
131
+ def __str__(self):
132
+ return f"{self.name} {self.lang_in} {self.lang_out} {self.model}"
133
+
134
+ def get_rich_text_left_placeholder(self, placeholder_id: int):
135
+ return f"<b{placeholder_id}>"
136
+
137
+ def get_rich_text_right_placeholder(self, placeholder_id: int):
138
+ return f"</b{placeholder_id}>"
139
+
140
+ def get_formular_placeholder(self, placeholder_id: int):
141
+ return self.get_rich_text_left_placeholder(placeholder_id)
142
+
143
+
144
+ class GoogleTranslator(BaseTranslator):
145
+ name = "google"
146
+ lang_map = {"zh": "zh-CN"}
147
+
148
+ def __init__(self, lang_in, lang_out, ignore_cache=False):
149
+ super().__init__(lang_in, lang_out, ignore_cache)
150
+ self.session = requests.Session()
151
+ self.endpoint = "http://translate.google.com/m"
152
+ self.headers = {
153
+ "User-Agent": "Mozilla/4.0 (compatible;MSIE 6.0;Windows NT 5.1;SV1;.NET CLR 1.1.4322;.NET CLR 2.0.50727;.NET CLR 3.0.04506.30)",
154
+ }
155
+
156
+ def do_translate(self, text):
157
+ text = text[:5000] # google translate max length
158
+ response = self.session.get(
159
+ self.endpoint,
160
+ params={"tl": self.lang_out, "sl": self.lang_in, "q": text},
161
+ headers=self.headers,
162
+ )
163
+ re_result = re.findall(
164
+ r'(?s)class="(?:t0|result-container)">(.*?)<',
165
+ response.text,
166
+ )
167
+ if response.status_code == 400:
168
+ result = "IRREPARABLE TRANSLATION ERROR"
169
+ else:
170
+ response.raise_for_status()
171
+ result = html.unescape(re_result[0])
172
+ return remove_control_characters(result)
173
+
174
+
175
+ class BingTranslator(BaseTranslator):
176
+ # https://github.com/immersive-translate/old-immersive-translate/blob/6df13da22664bea2f51efe5db64c63aca59c4e79/src/background/translationService.js
177
+ name = "bing"
178
+ lang_map = {"zh": "zh-Hans"}
179
+
180
+ def __init__(self, lang_in, lang_out, ignore_cache=False):
181
+ super().__init__(lang_in, lang_out, ignore_cache)
182
+ self.session = requests.Session()
183
+ self.endpoint = "https://www.bing.com/translator"
184
+ self.headers = {
185
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
186
+ }
187
+
188
+ def find_sid(self):
189
+ response = self.session.get(self.endpoint)
190
+ response.raise_for_status()
191
+ url = response.url[:-10]
192
+ ig = re.findall(r"\"ig\":\"(.*?)\"", response.text)[0]
193
+ iid = re.findall(r"data-iid=\"(.*?)\"", response.text)[-1]
194
+ key, token = re.findall(
195
+ r"params_AbusePreventionHelper\s=\s\[(.*?),\"(.*?)\",",
196
+ response.text,
197
+ )[0]
198
+ return url, ig, iid, key, token
199
+
200
+ def do_translate(self, text):
201
+ text = text[:1000] # bing translate max length
202
+ url, ig, iid, key, token = self.find_sid()
203
+ response = self.session.post(
204
+ f"{url}ttranslatev3?IG={ig}&IID={iid}",
205
+ data={
206
+ "fromLang": self.lang_in,
207
+ "to": self.lang_out,
208
+ "text": text,
209
+ "token": token,
210
+ "key": key,
211
+ },
212
+ headers=self.headers,
213
+ )
214
+ response.raise_for_status()
215
+ return response.json()[0]["translations"][0]["text"]
216
+
217
+
218
+ class OpenAITranslator(BaseTranslator):
219
+ # https://github.com/openai/openai-python
220
+ name = "openai"
221
+
222
+ def __init__(
223
+ self,
224
+ lang_in,
225
+ lang_out,
226
+ model,
227
+ base_url=None,
228
+ api_key=None,
229
+ ignore_cache=False,
230
+ ):
231
+ super().__init__(lang_in, lang_out, ignore_cache)
232
+ self.options = {"temperature": 0} # 随机采样可能会打断公式标记
233
+ self.client = openai.OpenAI(base_url=base_url, api_key=api_key)
234
+ self.add_cache_impact_parameters("temperature", self.options["temperature"])
235
+ self.model = model
236
+ self.add_cache_impact_parameters("model", self.model)
237
+ self.add_cache_impact_parameters("prompt", self.prompt(""))
238
+
239
+ def do_translate(self, text) -> str:
240
+ response = self.client.chat.completions.create(
241
+ model=self.model,
242
+ **self.options,
243
+ messages=self.prompt(text),
244
+ )
245
+ return response.choices[0].message.content.strip()
246
+
247
+ def prompt(self, text):
248
+ return [
249
+ {
250
+ "role": "system",
251
+ "content": "You are a professional,authentic machine translation engine.",
252
+ },
253
+ {
254
+ "role": "user",
255
+ "content": f";; Treat next line as plain text input and translate it into {self.lang_out}, output translation ONLY. If translation is unnecessary (e.g. proper nouns, codes, {'{{1}}, etc. '}), return the original text. NO explanations. NO notes. Input:\n\n{text}",
256
+ },
257
+ ]
258
+
259
+ def get_formular_placeholder(self, placeholder_id: int):
260
+ return "{{v" + str(placeholder_id) + "}}"
261
+ return "{{" + str(placeholder_id) + "}}"
262
+
263
+ def get_rich_text_left_placeholder(self, placeholder_id: int):
264
+ return self.get_formular_placeholder(placeholder_id)
265
+
266
+ def get_rich_text_right_placeholder(self, placeholder_id: int):
267
+ return self.get_formular_placeholder(placeholder_id + 1)
@@ -0,0 +1,227 @@
1
+ import functools
2
+ import logging
3
+ import re
4
+ from pathlib import Path
5
+
6
+ import pymupdf
7
+ from babeldoc.const import get_cache_file_path
8
+ from babeldoc.document_il import PdfFont
9
+ from babeldoc.document_il import il_version_1
10
+ from babeldoc.translation_config import TranslationConfig
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class FontMapper:
16
+ stage_name = "添加字体"
17
+
18
+ def __init__(self, translation_config: TranslationConfig):
19
+ self.font_names = [
20
+ "source-han-serif-cn.ttf",
21
+ "SourceHanSansSC-Regular.ttf",
22
+ "source-han-serif-cn-bold.ttf",
23
+ "SourceHanSansSC-Bold.ttf",
24
+ ]
25
+ self.fonts = {
26
+ Path(file_name).name.split(".")[0].replace("-", "").lower(): pymupdf.Font(
27
+ fontfile=str(get_cache_file_path(file_name)),
28
+ )
29
+ for file_name in self.font_names
30
+ }
31
+ for k, v in self.fonts.items():
32
+ v.font_id = k
33
+ self.translation_config = translation_config
34
+ self.base_font_path = translation_config.font
35
+ self.fallback_font_path = get_cache_file_path("noto.ttf")
36
+ self.base_font = pymupdf.Font(fontfile=str(self.base_font_path))
37
+ self.fallback_font = pymupdf.Font(fontfile=str(self.fallback_font_path))
38
+
39
+ self.kai_font_path = get_cache_file_path("LXGWWenKai-Regular.ttf")
40
+ self.kai_font = pymupdf.Font(fontfile=str(self.kai_font_path))
41
+
42
+ self.base_font.font_id = "base"
43
+ self.fallback_font.font_id = "fallback"
44
+ self.kai_font.font_id = "kai"
45
+
46
+ # Set ascent and descent for base font
47
+ self.base_font.ascent_fontmap = 1151
48
+ self.base_font.descent_fontmap = -286
49
+
50
+ # Set ascent and descent for fallback font
51
+ self.fallback_font.ascent_fontmap = 1069
52
+ self.fallback_font.descent_fontmap = -293
53
+
54
+ # Set ascent and descent for kai font
55
+ self.kai_font.ascent_fontmap = 928
56
+ self.kai_font.descent_fontmap = -256
57
+
58
+ self.fontid2font = {f.font_id: f for f in self.fonts.values()}
59
+ self.fontid2font["base"] = self.base_font
60
+ self.fontid2font["fallback"] = self.fallback_font
61
+ self.fontid2font["kai"] = self.kai_font
62
+
63
+ # Set ascent and descent for other fonts
64
+ font_metrics = {
65
+ "sourcehanserifcn": (1151, 0),
66
+ "sourcehansansscregular": (1160, -288),
67
+ "sourcehanserifcnbold": (1151, -286),
68
+ "sourcehansansscbold": (1160, -288),
69
+ }
70
+
71
+ for font_id, (ascent, descent) in font_metrics.items():
72
+ if font_id in self.fontid2font:
73
+ self.fontid2font[font_id].ascent_fontmap = ascent
74
+ self.fontid2font[font_id].descent_fontmap = descent
75
+
76
+ for font in self.fontid2font.values():
77
+ font.char_lengths = functools.lru_cache(maxsize=10240, typed=True)(
78
+ font.char_lengths,
79
+ )
80
+
81
+ def has_char(self, char_unicode: str):
82
+ if len(char_unicode) != 1:
83
+ return False
84
+ current_char = ord(char_unicode)
85
+ for font in self.fonts.values():
86
+ if font.has_glyph(current_char):
87
+ return True
88
+ if self.base_font.has_glyph(current_char):
89
+ return True
90
+ if self.fallback_font.has_glyph(current_char):
91
+ return True
92
+ return False
93
+
94
+ def map(self, original_font: PdfFont, char_unicode: str):
95
+ current_char = ord(char_unicode)
96
+ if isinstance(original_font, pymupdf.Font):
97
+ bold = original_font.is_bold
98
+ italic = original_font.is_italic
99
+ monospaced = original_font.is_monospaced
100
+ serif = original_font.is_serif
101
+ elif isinstance(original_font, PdfFont):
102
+ bold = original_font.bold
103
+ italic = original_font.italic
104
+ monospaced = original_font.monospace
105
+ serif = original_font.serif
106
+ else:
107
+ logger.error(
108
+ f"Unknown font type: {type(original_font)}. "
109
+ f"Original font: {original_font}. "
110
+ f"Char unicode: {char_unicode}. ",
111
+ )
112
+ return None
113
+ if italic and self.kai_font.has_glyph(current_char):
114
+ return self.kai_font
115
+ for _k, font in self.fonts.items():
116
+ if not font.has_glyph(current_char):
117
+ continue
118
+ if bold != font.is_bold:
119
+ continue
120
+ # 不知道什么原因,思源黑体的 serif 属性为1,先workaround
121
+ if serif == 1 and "serif" not in font.font_id:
122
+ continue
123
+ if serif == 0 and "serif" in font.font_id:
124
+ continue
125
+ return font
126
+ if self.base_font.has_glyph(current_char):
127
+ return self.base_font
128
+
129
+ if self.fallback_font.has_glyph(current_char):
130
+ return self.fallback_font
131
+
132
+ logger.error(
133
+ f"Can't find font for {char_unicode}({current_char}). "
134
+ f"Original font: {original_font}. "
135
+ f"Char unicode: {char_unicode}. ",
136
+ )
137
+ return None
138
+
139
+ def add_font(self, doc_zh: pymupdf.Document, il: il_version_1.Document):
140
+ font_list = [
141
+ ("base", self.base_font_path),
142
+ ("fallback", self.fallback_font_path),
143
+ ("kai", self.kai_font_path),
144
+ ]
145
+ font_list.extend(
146
+ [
147
+ (
148
+ Path(file_name).name.split(".")[0].replace("-", "").lower(),
149
+ get_cache_file_path(file_name),
150
+ )
151
+ for file_name in self.font_names
152
+ ],
153
+ )
154
+ font_id = {}
155
+ xreflen = doc_zh.xref_length()
156
+ with self.translation_config.progress_monitor.stage_start(
157
+ self.stage_name,
158
+ xreflen - 1 + len(font_list) + len(il.page) + len(font_list),
159
+ ) as pbar:
160
+ for font in font_list:
161
+ font_id[font[0]] = doc_zh[0].insert_font(font[0], font[1])
162
+ pbar.advance(1)
163
+ for xref in range(1, xreflen):
164
+ pbar.advance(1)
165
+ for label in ["Resources/", ""]: # 可能是基于 xobj 的 res
166
+ try: # xref 读写可能出错
167
+ font_res = doc_zh.xref_get_key(xref, f"{label}Font")
168
+ if font_res is None:
169
+ continue
170
+ target_key_prefix = f"{label}Font/"
171
+ if font_res[0] == "xref":
172
+ resource_xref_id = re.search(
173
+ "(\\d+) 0 R",
174
+ font_res[1],
175
+ ).group(1)
176
+ xref = int(resource_xref_id)
177
+ font_res = ("dict", doc_zh.xref_object(xref))
178
+ target_key_prefix = ""
179
+ if font_res[0] == "dict":
180
+ for font in font_list:
181
+ target_key = f"{target_key_prefix}{font[0]}"
182
+ font_exist = doc_zh.xref_get_key(xref, target_key)
183
+ if font_exist[0] == "null":
184
+ doc_zh.xref_set_key(
185
+ xref,
186
+ target_key,
187
+ f"{font_id[font[0]]} 0 R",
188
+ )
189
+ except Exception:
190
+ pass
191
+
192
+ # Create PdfFont for each font
193
+ # 预先创建所有字体对象
194
+ pdf_fonts = []
195
+ for font_name, font_path in font_list:
196
+ font = pymupdf.Font(fontfile=str(font_path))
197
+ # Get descent_fontmap from fontid2font
198
+ descent_fontmap = None
199
+ if font_name in self.fontid2font:
200
+ mupdf_font = self.fontid2font[font_name]
201
+ if hasattr(mupdf_font, "descent_fontmap"):
202
+ descent_fontmap = mupdf_font.descent_fontmap
203
+ if hasattr(mupdf_font, "ascent_fontmap"):
204
+ ascent_fontmap = mupdf_font.ascent_fontmap
205
+
206
+ pdf_fonts.append(
207
+ il_version_1.PdfFont(
208
+ name=font_name,
209
+ xref_id=font_id[font_name],
210
+ font_id=font_name,
211
+ encoding_length=2,
212
+ bold=font.is_bold,
213
+ italic=font.is_italic,
214
+ monospace=font.is_monospaced,
215
+ serif=font.is_serif,
216
+ descent=descent_fontmap,
217
+ ascent=ascent_fontmap,
218
+ ),
219
+ )
220
+ pbar.advance(1)
221
+
222
+ # 批量添加字体到页面和XObject
223
+ for page in il.page:
224
+ page.pdf_font.extend(pdf_fonts)
225
+ for xobj in page.pdf_xobject:
226
+ xobj.pdf_font.extend(pdf_fonts)
227
+ pbar.advance(1)