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.
- babeldoc/__init__.py +1 -0
- babeldoc/asynchronize/__init__.py +51 -0
- babeldoc/const.py +7 -0
- babeldoc/converter.py +510 -0
- babeldoc/document_il/__init__.py +43 -0
- babeldoc/document_il/backend/__init__.py +0 -0
- babeldoc/document_il/backend/pdf_creater.py +402 -0
- babeldoc/document_il/frontend/__init__.py +0 -0
- babeldoc/document_il/frontend/il_creater.py +348 -0
- babeldoc/document_il/il_version_1.py +799 -0
- babeldoc/document_il/il_version_1.rnc +141 -0
- babeldoc/document_il/il_version_1.rng +390 -0
- babeldoc/document_il/il_version_1.xsd +235 -0
- babeldoc/document_il/midend/__init__.py +0 -0
- babeldoc/document_il/midend/add_debug_information.py +138 -0
- babeldoc/document_il/midend/il_translator.py +553 -0
- babeldoc/document_il/midend/layout_parser.py +180 -0
- babeldoc/document_il/midend/paragraph_finder.py +452 -0
- babeldoc/document_il/midend/remove_descent.py +168 -0
- babeldoc/document_il/midend/styles_and_formulas.py +701 -0
- babeldoc/document_il/midend/typesetting.py +761 -0
- babeldoc/document_il/translator/__init__.py +0 -0
- babeldoc/document_il/translator/cache.py +145 -0
- babeldoc/document_il/translator/translator.py +267 -0
- babeldoc/document_il/utils/fontmap.py +227 -0
- babeldoc/document_il/utils/layout_helper.py +467 -0
- babeldoc/document_il/utils/style_helper.py +86 -0
- babeldoc/document_il/xml_converter.py +51 -0
- babeldoc/docvision/README.md +0 -0
- babeldoc/docvision/__init__.py +0 -0
- babeldoc/docvision/doclayout.py +278 -0
- babeldoc/docvision/rpc_doclayout.py +162 -0
- babeldoc/high_level.py +468 -0
- babeldoc/main.py +466 -0
- babeldoc/pdfinterp.py +464 -0
- babeldoc/progress_monitor.py +146 -0
- babeldoc/translation_config.py +156 -0
- babeldoc-0.1.6.dist-info/METADATA +283 -0
- babeldoc-0.1.6.dist-info/RECORD +42 -0
- babeldoc-0.1.6.dist-info/WHEEL +4 -0
- babeldoc-0.1.6.dist-info/entry_points.txt +2 -0
- 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)
|