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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
import numpy as np
|
|
6
|
+
from pymupdf import Document
|
|
7
|
+
|
|
8
|
+
from babeldoc.document_il import il_version_1
|
|
9
|
+
from babeldoc.document_il.utils.style_helper import GREEN
|
|
10
|
+
from babeldoc.translation_config import TranslationConfig
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LayoutParser:
|
|
16
|
+
stage_name = "解析页面布局"
|
|
17
|
+
|
|
18
|
+
def __init__(self, translation_config: TranslationConfig):
|
|
19
|
+
self.translation_config = translation_config
|
|
20
|
+
self.model = translation_config.doc_layout_model
|
|
21
|
+
|
|
22
|
+
def _save_debug_image(self, image: np.ndarray, layout, page_number: int):
|
|
23
|
+
"""Save debug image with drawn boxes if debug mode is enabled."""
|
|
24
|
+
if not self.translation_config.debug:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
debug_dir = Path(self.translation_config.get_working_file_path("ocr-box-image"))
|
|
28
|
+
debug_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
# Draw boxes on the image
|
|
31
|
+
debug_image = image.copy()
|
|
32
|
+
for box in layout.boxes:
|
|
33
|
+
x0, y0, x1, y1 = box.xyxy
|
|
34
|
+
cv2.rectangle(
|
|
35
|
+
debug_image,
|
|
36
|
+
(int(x0), int(y0)),
|
|
37
|
+
(int(x1), int(y1)),
|
|
38
|
+
(0, 255, 0),
|
|
39
|
+
2,
|
|
40
|
+
)
|
|
41
|
+
# Add text label
|
|
42
|
+
cv2.putText(
|
|
43
|
+
debug_image,
|
|
44
|
+
layout.names[box.cls],
|
|
45
|
+
(int(x0), int(y0) - 5),
|
|
46
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
47
|
+
0.5,
|
|
48
|
+
(0, 255, 0),
|
|
49
|
+
1,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Save the image
|
|
53
|
+
output_path = debug_dir / f"{page_number}.jpg"
|
|
54
|
+
cv2.imwrite(str(output_path), debug_image)
|
|
55
|
+
|
|
56
|
+
def _save_debug_box_to_page(self, page: il_version_1.Page):
|
|
57
|
+
"""Save debug boxes and text labels to the PDF page."""
|
|
58
|
+
if not self.translation_config.debug:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
color = GREEN
|
|
62
|
+
|
|
63
|
+
for layout in page.page_layout:
|
|
64
|
+
# Create a rectangle box
|
|
65
|
+
rect = il_version_1.PdfRectangle(
|
|
66
|
+
box=il_version_1.Box(
|
|
67
|
+
x=layout.box.x,
|
|
68
|
+
y=layout.box.y,
|
|
69
|
+
x2=layout.box.x2,
|
|
70
|
+
y2=layout.box.y2,
|
|
71
|
+
),
|
|
72
|
+
graphic_state=color,
|
|
73
|
+
debug_info=True,
|
|
74
|
+
)
|
|
75
|
+
page.pdf_rectangle.append(rect)
|
|
76
|
+
|
|
77
|
+
# Create text label at top-left corner
|
|
78
|
+
# Note: PDF coordinates are from bottom-left,
|
|
79
|
+
# so we use y2 for top position
|
|
80
|
+
style = il_version_1.PdfStyle(
|
|
81
|
+
font_id="china-ss",
|
|
82
|
+
font_size=4,
|
|
83
|
+
graphic_state=color,
|
|
84
|
+
)
|
|
85
|
+
page.pdf_paragraph.append(
|
|
86
|
+
il_version_1.PdfParagraph(
|
|
87
|
+
first_line_indent=False,
|
|
88
|
+
box=il_version_1.Box(
|
|
89
|
+
x=layout.box.x,
|
|
90
|
+
y=layout.box.y2,
|
|
91
|
+
x2=layout.box.x2,
|
|
92
|
+
y2=layout.box.y2 + 5,
|
|
93
|
+
),
|
|
94
|
+
vertical=False,
|
|
95
|
+
pdf_style=style,
|
|
96
|
+
unicode=layout.class_name,
|
|
97
|
+
pdf_paragraph_composition=[
|
|
98
|
+
il_version_1.PdfParagraphComposition(
|
|
99
|
+
pdf_same_style_unicode_characters=il_version_1.PdfSameStyleUnicodeCharacters(
|
|
100
|
+
unicode=layout.class_name,
|
|
101
|
+
pdf_style=style,
|
|
102
|
+
debug_info=True,
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
],
|
|
106
|
+
xobj_id=-1,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def process(self, docs: il_version_1.Document, mupdf_doc: Document):
|
|
111
|
+
"""Generate layouts for all pages that need to be translated."""
|
|
112
|
+
# Get pages that need to be translated
|
|
113
|
+
pages_to_translate = [
|
|
114
|
+
page
|
|
115
|
+
for page in docs.page
|
|
116
|
+
if self.translation_config.should_translate_page(page.page_number + 1)
|
|
117
|
+
]
|
|
118
|
+
total = len(pages_to_translate)
|
|
119
|
+
with self.translation_config.progress_monitor.stage_start(
|
|
120
|
+
self.stage_name,
|
|
121
|
+
total,
|
|
122
|
+
) as progress:
|
|
123
|
+
# Process pages in batches
|
|
124
|
+
batch_size = 16
|
|
125
|
+
for i in range(0, total, batch_size):
|
|
126
|
+
self.translation_config.raise_if_cancelled()
|
|
127
|
+
batch_pages = pages_to_translate[i : i + batch_size]
|
|
128
|
+
|
|
129
|
+
# Prepare batch images
|
|
130
|
+
batch_images = []
|
|
131
|
+
for page in batch_pages:
|
|
132
|
+
pix = mupdf_doc[page.page_number].get_pixmap(dpi=72)
|
|
133
|
+
image = np.fromstring(pix.samples, np.uint8).reshape(
|
|
134
|
+
pix.height,
|
|
135
|
+
pix.width,
|
|
136
|
+
3,
|
|
137
|
+
)[:, :, ::-1]
|
|
138
|
+
batch_images.append(image)
|
|
139
|
+
|
|
140
|
+
# Get predictions for the batch
|
|
141
|
+
layouts_batch = self.model.predict(batch_images, batch_size=batch_size)
|
|
142
|
+
|
|
143
|
+
# Process predictions for each page
|
|
144
|
+
for page, layouts in zip(batch_pages, layouts_batch, strict=False):
|
|
145
|
+
page_layouts = []
|
|
146
|
+
self._save_debug_image(
|
|
147
|
+
batch_images[batch_pages.index(page)],
|
|
148
|
+
layouts,
|
|
149
|
+
page.page_number + 1,
|
|
150
|
+
)
|
|
151
|
+
for layout in layouts.boxes:
|
|
152
|
+
# Convert coordinate system from picture to il
|
|
153
|
+
# system to the il coordinate system
|
|
154
|
+
x0, y0, x1, y1 = layout.xyxy
|
|
155
|
+
pix = mupdf_doc[page.page_number].get_pixmap()
|
|
156
|
+
h, w = pix.height, pix.width
|
|
157
|
+
x0, y0, x1, y1 = (
|
|
158
|
+
np.clip(int(x0 - 1), 0, w - 1),
|
|
159
|
+
np.clip(int(h - y1 - 1), 0, h - 1),
|
|
160
|
+
np.clip(int(x1 + 1), 0, w - 1),
|
|
161
|
+
np.clip(int(h - y0 + 1), 0, h - 1),
|
|
162
|
+
)
|
|
163
|
+
page_layout = il_version_1.PageLayout(
|
|
164
|
+
id=len(page_layouts) + 1,
|
|
165
|
+
box=il_version_1.Box(
|
|
166
|
+
x0.item(),
|
|
167
|
+
y0.item(),
|
|
168
|
+
x1.item(),
|
|
169
|
+
y1.item(),
|
|
170
|
+
),
|
|
171
|
+
conf=layout.conf.item(),
|
|
172
|
+
class_name=layouts.names[layout.cls],
|
|
173
|
+
)
|
|
174
|
+
page_layouts.append(page_layout)
|
|
175
|
+
|
|
176
|
+
page.page_layout = page_layouts
|
|
177
|
+
self._save_debug_box_to_page(page)
|
|
178
|
+
progress.advance(1)
|
|
179
|
+
|
|
180
|
+
return docs
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import random
|
|
3
|
+
import re
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from babeldoc.document_il import Box
|
|
7
|
+
from babeldoc.document_il import Page
|
|
8
|
+
from babeldoc.document_il import PdfCharacter
|
|
9
|
+
from babeldoc.document_il import PdfLine
|
|
10
|
+
from babeldoc.document_il import PdfParagraph
|
|
11
|
+
from babeldoc.document_il import PdfParagraphComposition
|
|
12
|
+
from babeldoc.document_il.utils.layout_helper import Layout
|
|
13
|
+
from babeldoc.document_il.utils.layout_helper import add_space_dummy_chars
|
|
14
|
+
from babeldoc.document_il.utils.layout_helper import get_char_unicode_string
|
|
15
|
+
from babeldoc.translation_config import TranslationConfig
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Base58 alphabet (Bitcoin style, without numbers 0, O, I, l)
|
|
20
|
+
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_base58_id(length: int = 5) -> str:
|
|
24
|
+
"""Generate a random base58 ID of specified length."""
|
|
25
|
+
return "".join(random.choice(BASE58_ALPHABET) for _ in range(length))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ParagraphFinder:
|
|
29
|
+
stage_name = "解析段落"
|
|
30
|
+
|
|
31
|
+
def __init__(self, translation_config: TranslationConfig):
|
|
32
|
+
self.translation_config = translation_config
|
|
33
|
+
|
|
34
|
+
def update_paragraph_data(self, paragraph: PdfParagraph, update_unicode=False):
|
|
35
|
+
if not paragraph.pdf_paragraph_composition:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
chars = []
|
|
39
|
+
for composition in paragraph.pdf_paragraph_composition:
|
|
40
|
+
if composition.pdf_line:
|
|
41
|
+
chars.extend(composition.pdf_line.pdf_character)
|
|
42
|
+
elif composition.pdf_formula:
|
|
43
|
+
chars.extend(composition.pdf_formula.pdf_character)
|
|
44
|
+
elif composition.pdf_same_style_unicode_characters:
|
|
45
|
+
continue
|
|
46
|
+
else:
|
|
47
|
+
logger.error(
|
|
48
|
+
"Unexpected composition type"
|
|
49
|
+
" in PdfParagraphComposition. "
|
|
50
|
+
"This type only appears in the IL "
|
|
51
|
+
"after the translation is completed.",
|
|
52
|
+
)
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
if update_unicode and chars:
|
|
56
|
+
paragraph.unicode = get_char_unicode_string(chars)
|
|
57
|
+
if not chars:
|
|
58
|
+
return
|
|
59
|
+
# 更新边界框
|
|
60
|
+
min_x = min(char.box.x for char in chars)
|
|
61
|
+
min_y = min(char.box.y for char in chars)
|
|
62
|
+
max_x = max(char.box.x2 for char in chars)
|
|
63
|
+
max_y = max(char.box.y2 for char in chars)
|
|
64
|
+
paragraph.box = Box(min_x, min_y, max_x, max_y)
|
|
65
|
+
paragraph.vertical = chars[0].vertical
|
|
66
|
+
paragraph.xobj_id = chars[0].xobj_id
|
|
67
|
+
|
|
68
|
+
paragraph.first_line_indent = False
|
|
69
|
+
if (
|
|
70
|
+
paragraph.pdf_paragraph_composition[0].pdf_line
|
|
71
|
+
and paragraph.pdf_paragraph_composition[0].pdf_line.pdf_character[0].box.x
|
|
72
|
+
- paragraph.box.x
|
|
73
|
+
> 1
|
|
74
|
+
):
|
|
75
|
+
paragraph.first_line_indent = True
|
|
76
|
+
|
|
77
|
+
def update_line_data(self, line: PdfLine):
|
|
78
|
+
min_x = min(char.box.x for char in line.pdf_character)
|
|
79
|
+
min_y = min(char.box.y for char in line.pdf_character)
|
|
80
|
+
max_x = max(char.box.x2 for char in line.pdf_character)
|
|
81
|
+
max_y = max(char.box.y2 for char in line.pdf_character)
|
|
82
|
+
line.box = Box(min_x, min_y, max_x, max_y)
|
|
83
|
+
|
|
84
|
+
def process(self, document):
|
|
85
|
+
with self.translation_config.progress_monitor.stage_start(
|
|
86
|
+
self.stage_name,
|
|
87
|
+
len(document.page),
|
|
88
|
+
) as pbar:
|
|
89
|
+
for page in document.page:
|
|
90
|
+
self.translation_config.raise_if_cancelled()
|
|
91
|
+
self.process_page(page)
|
|
92
|
+
pbar.advance()
|
|
93
|
+
|
|
94
|
+
def bbox_overlap(self, bbox1: Box, bbox2: Box) -> bool:
|
|
95
|
+
return (
|
|
96
|
+
bbox1.x < bbox2.x2
|
|
97
|
+
and bbox1.x2 > bbox2.x
|
|
98
|
+
and bbox1.y < bbox2.y2
|
|
99
|
+
and bbox1.y2 > bbox2.y
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def process_page(self, page: Page):
|
|
103
|
+
# 第一步:根据 layout 创建 paragraphs
|
|
104
|
+
# 在这一步中,page.pdf_character 中的字符会被移除
|
|
105
|
+
paragraphs = self.create_paragraphs(page)
|
|
106
|
+
page.pdf_paragraph = paragraphs
|
|
107
|
+
|
|
108
|
+
# 第二步:处理段落中的空格和换行符
|
|
109
|
+
for paragraph in paragraphs:
|
|
110
|
+
add_space_dummy_chars(paragraph)
|
|
111
|
+
self.process_paragraph_spacing(paragraph)
|
|
112
|
+
self.update_paragraph_data(paragraph)
|
|
113
|
+
|
|
114
|
+
# 第三步:计算所有行宽度的中位数
|
|
115
|
+
median_width = self.calculate_median_line_width(paragraphs)
|
|
116
|
+
|
|
117
|
+
# 第四步:处理独立段落
|
|
118
|
+
self.process_independent_paragraphs(paragraphs, median_width)
|
|
119
|
+
|
|
120
|
+
for paragraph in paragraphs:
|
|
121
|
+
self.update_paragraph_data(paragraph, update_unicode=True)
|
|
122
|
+
|
|
123
|
+
def is_isolated_formula(self, char: PdfCharacter):
|
|
124
|
+
return char.char_unicode in (
|
|
125
|
+
"(cid:122)",
|
|
126
|
+
"(cid:123)",
|
|
127
|
+
"(cid:124)",
|
|
128
|
+
"(cid:125)",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def create_paragraphs(self, page: Page) -> list[PdfParagraph]:
|
|
132
|
+
paragraphs: list[PdfParagraph] = []
|
|
133
|
+
if page.pdf_paragraph:
|
|
134
|
+
paragraphs.extend(page.pdf_paragraph)
|
|
135
|
+
page.pdf_paragraph = []
|
|
136
|
+
|
|
137
|
+
current_paragraph: PdfParagraph | None = None
|
|
138
|
+
current_layout: Layout | None = None
|
|
139
|
+
current_line_chars: list[PdfCharacter] = []
|
|
140
|
+
skip_chars = []
|
|
141
|
+
|
|
142
|
+
for char in page.pdf_character:
|
|
143
|
+
char_layout = self.get_layout(char, page)
|
|
144
|
+
if not self.is_text_layout(char_layout) or self.is_isolated_formula(char):
|
|
145
|
+
skip_chars.append(char)
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# 检查是否需要开始新行
|
|
149
|
+
if current_line_chars and Layout.is_newline(current_line_chars[-1], char):
|
|
150
|
+
# 创建新行
|
|
151
|
+
if current_line_chars:
|
|
152
|
+
line = self.create_line(current_line_chars)
|
|
153
|
+
if current_paragraph is None:
|
|
154
|
+
current_paragraph = PdfParagraph(
|
|
155
|
+
pdf_paragraph_composition=[line],
|
|
156
|
+
debug_id=generate_base58_id(),
|
|
157
|
+
)
|
|
158
|
+
paragraphs.append(current_paragraph)
|
|
159
|
+
else:
|
|
160
|
+
current_paragraph.pdf_paragraph_composition.append(line)
|
|
161
|
+
self.update_paragraph_data(current_paragraph)
|
|
162
|
+
current_line_chars = []
|
|
163
|
+
|
|
164
|
+
# 检查是否需要开始新段落
|
|
165
|
+
if (
|
|
166
|
+
current_layout is None
|
|
167
|
+
or char_layout.id != current_layout.id
|
|
168
|
+
or ( # 不是同一个 xobject
|
|
169
|
+
current_line_chars
|
|
170
|
+
and current_line_chars[-1].xobj_id != char.xobj_id
|
|
171
|
+
)
|
|
172
|
+
):
|
|
173
|
+
if current_line_chars:
|
|
174
|
+
line = self.create_line(current_line_chars)
|
|
175
|
+
if current_paragraph is not None:
|
|
176
|
+
current_paragraph.pdf_paragraph_composition.append(line)
|
|
177
|
+
self.update_paragraph_data(current_paragraph)
|
|
178
|
+
else:
|
|
179
|
+
current_paragraph = PdfParagraph(
|
|
180
|
+
pdf_paragraph_composition=[line],
|
|
181
|
+
debug_id=generate_base58_id(),
|
|
182
|
+
)
|
|
183
|
+
self.update_paragraph_data(current_paragraph)
|
|
184
|
+
paragraphs.append(current_paragraph)
|
|
185
|
+
current_line_chars = []
|
|
186
|
+
current_paragraph = None
|
|
187
|
+
current_layout = char_layout
|
|
188
|
+
|
|
189
|
+
current_line_chars.append(char)
|
|
190
|
+
|
|
191
|
+
# 处理最后一行的字符
|
|
192
|
+
if current_line_chars:
|
|
193
|
+
line = self.create_line(current_line_chars)
|
|
194
|
+
if current_paragraph is None:
|
|
195
|
+
current_paragraph = PdfParagraph(
|
|
196
|
+
pdf_paragraph_composition=[line],
|
|
197
|
+
debug_id=generate_base58_id(),
|
|
198
|
+
)
|
|
199
|
+
paragraphs.append(current_paragraph)
|
|
200
|
+
else:
|
|
201
|
+
current_paragraph.pdf_paragraph_composition.append(line)
|
|
202
|
+
self.update_paragraph_data(current_paragraph)
|
|
203
|
+
|
|
204
|
+
page.pdf_character = skip_chars
|
|
205
|
+
|
|
206
|
+
return paragraphs
|
|
207
|
+
|
|
208
|
+
def process_paragraph_spacing(self, paragraph: PdfParagraph):
|
|
209
|
+
if not paragraph.pdf_paragraph_composition:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
# 处理行级别的空格
|
|
213
|
+
processed_lines = []
|
|
214
|
+
for composition in paragraph.pdf_paragraph_composition:
|
|
215
|
+
if not composition.pdf_line:
|
|
216
|
+
processed_lines.append(composition)
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
line = composition.pdf_line
|
|
220
|
+
if not "".join(
|
|
221
|
+
x.char_unicode for x in line.pdf_character
|
|
222
|
+
).strip(): # 跳过完全空白的行
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
# 处理行内字符的尾随空格
|
|
226
|
+
processed_chars = []
|
|
227
|
+
for char in line.pdf_character:
|
|
228
|
+
if not char.char_unicode.isspace():
|
|
229
|
+
processed_chars = processed_chars + [char]
|
|
230
|
+
elif processed_chars: # 只有在有非空格字符后才考虑保留空格
|
|
231
|
+
processed_chars.append(char)
|
|
232
|
+
|
|
233
|
+
# 移除尾随空格
|
|
234
|
+
while processed_chars and processed_chars[-1].char_unicode.isspace():
|
|
235
|
+
processed_chars.pop()
|
|
236
|
+
|
|
237
|
+
if processed_chars: # 如果行内还有字符
|
|
238
|
+
line = self.create_line(processed_chars)
|
|
239
|
+
processed_lines.append(line)
|
|
240
|
+
|
|
241
|
+
paragraph.pdf_paragraph_composition = processed_lines
|
|
242
|
+
self.update_paragraph_data(paragraph)
|
|
243
|
+
|
|
244
|
+
def is_text_layout(self, layout: Layout):
|
|
245
|
+
return layout is not None and layout.name in [
|
|
246
|
+
"plain text",
|
|
247
|
+
"title",
|
|
248
|
+
"abandon",
|
|
249
|
+
"figure_caption",
|
|
250
|
+
"table_caption",
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
def get_layout(
|
|
254
|
+
self,
|
|
255
|
+
char: PdfCharacter,
|
|
256
|
+
page: Page,
|
|
257
|
+
xy_mode: Literal["topleft"]
|
|
258
|
+
| Literal["bottomright"]
|
|
259
|
+
| Literal["middle"] = "middle",
|
|
260
|
+
):
|
|
261
|
+
tl, br, md = [
|
|
262
|
+
self._get_layout(char, page, mode)
|
|
263
|
+
for mode in ["topleft", "bottomright", "middle"]
|
|
264
|
+
]
|
|
265
|
+
if tl is not None and tl.name == "isolate_formula":
|
|
266
|
+
return tl
|
|
267
|
+
if br is not None and br.name == "isolate_formula":
|
|
268
|
+
return br
|
|
269
|
+
if md is not None and md.name == "isolate_formula":
|
|
270
|
+
return md
|
|
271
|
+
|
|
272
|
+
if md is not None:
|
|
273
|
+
return md
|
|
274
|
+
if tl is not None:
|
|
275
|
+
return tl
|
|
276
|
+
return br
|
|
277
|
+
|
|
278
|
+
def _get_layout(
|
|
279
|
+
self,
|
|
280
|
+
char: PdfCharacter,
|
|
281
|
+
page: Page,
|
|
282
|
+
xy_mode: Literal["topleft"]
|
|
283
|
+
| Literal["bottomright"]
|
|
284
|
+
| Literal["middle"] = "middle",
|
|
285
|
+
):
|
|
286
|
+
# 这几个符号,解析出来的大小经常只有实际大小的一点点。
|
|
287
|
+
# if (
|
|
288
|
+
# xy_mode != "bottomright"
|
|
289
|
+
# and char.char_unicode in HEIGHT_NOT_USFUL_CHAR_IN_CHAR
|
|
290
|
+
# ):
|
|
291
|
+
# return self.get_layout(char, page, "bottomright")
|
|
292
|
+
# current layouts
|
|
293
|
+
# {
|
|
294
|
+
# "title",
|
|
295
|
+
# "plain text",
|
|
296
|
+
# "abandon",
|
|
297
|
+
# "figure",
|
|
298
|
+
# "figure_caption",
|
|
299
|
+
# "table",
|
|
300
|
+
# "table_caption",
|
|
301
|
+
# "table_footnote",
|
|
302
|
+
# "isolate_formula",
|
|
303
|
+
# "formula_caption",
|
|
304
|
+
# }
|
|
305
|
+
layout_priority = [
|
|
306
|
+
"formula_caption",
|
|
307
|
+
"isolate_formula",
|
|
308
|
+
"table_footnote",
|
|
309
|
+
"table",
|
|
310
|
+
"figure",
|
|
311
|
+
"table_caption",
|
|
312
|
+
"figure_caption",
|
|
313
|
+
"abandon",
|
|
314
|
+
"plain text",
|
|
315
|
+
"title",
|
|
316
|
+
]
|
|
317
|
+
char_box = char.box
|
|
318
|
+
if xy_mode == "topleft":
|
|
319
|
+
char_x = char_box.x
|
|
320
|
+
char_y = char_box.y2
|
|
321
|
+
elif xy_mode == "bottomright":
|
|
322
|
+
char_x = char_box.x2
|
|
323
|
+
char_y = char_box.y
|
|
324
|
+
elif xy_mode == "middle":
|
|
325
|
+
char_x = (char_box.x + char_box.x2) / 2
|
|
326
|
+
char_y = (char_box.y + char_box.y2) / 2
|
|
327
|
+
else:
|
|
328
|
+
logger.error(f"Invalid xy_mode: {xy_mode}")
|
|
329
|
+
return self.get_layout(char, page, "middle")
|
|
330
|
+
# 按照优先级顺序检查每种布局
|
|
331
|
+
matching_layouts = {}
|
|
332
|
+
for layout in page.page_layout:
|
|
333
|
+
layout_box = layout.box
|
|
334
|
+
if (
|
|
335
|
+
layout_box.x <= char_x <= layout_box.x2
|
|
336
|
+
and layout_box.y <= char_y <= layout_box.y2
|
|
337
|
+
):
|
|
338
|
+
matching_layouts[layout.class_name] = Layout(
|
|
339
|
+
layout.id,
|
|
340
|
+
layout.class_name,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# 按照优先级返回最高优先级的布局
|
|
344
|
+
for layout_name in layout_priority:
|
|
345
|
+
if layout_name in matching_layouts:
|
|
346
|
+
return matching_layouts[layout_name]
|
|
347
|
+
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
def create_line(self, chars: list[PdfCharacter]) -> PdfParagraphComposition:
|
|
351
|
+
assert chars
|
|
352
|
+
|
|
353
|
+
line = PdfLine(pdf_character=chars)
|
|
354
|
+
self.update_line_data(line)
|
|
355
|
+
return PdfParagraphComposition(pdf_line=line)
|
|
356
|
+
|
|
357
|
+
def calculate_median_line_width(self, paragraphs: list[PdfParagraph]) -> float:
|
|
358
|
+
# 收集所有行的宽度
|
|
359
|
+
line_widths = []
|
|
360
|
+
for paragraph in paragraphs:
|
|
361
|
+
for composition in paragraph.pdf_paragraph_composition:
|
|
362
|
+
if composition.pdf_line:
|
|
363
|
+
line = composition.pdf_line
|
|
364
|
+
line_widths.append(line.box.x2 - line.box.x)
|
|
365
|
+
|
|
366
|
+
if not line_widths:
|
|
367
|
+
return 0.0
|
|
368
|
+
|
|
369
|
+
# 计算中位数
|
|
370
|
+
line_widths.sort()
|
|
371
|
+
mid = len(line_widths) // 2
|
|
372
|
+
if len(line_widths) % 2 == 0:
|
|
373
|
+
return (line_widths[mid - 1] + line_widths[mid]) / 2
|
|
374
|
+
return line_widths[mid]
|
|
375
|
+
|
|
376
|
+
def process_independent_paragraphs(
|
|
377
|
+
self,
|
|
378
|
+
paragraphs: list[PdfParagraph],
|
|
379
|
+
median_width: float,
|
|
380
|
+
):
|
|
381
|
+
i = 0
|
|
382
|
+
while i < len(paragraphs):
|
|
383
|
+
paragraph = paragraphs[i]
|
|
384
|
+
if len(paragraph.pdf_paragraph_composition) <= 1: # 跳过只有一行的段落
|
|
385
|
+
i += 1
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
j = 1
|
|
389
|
+
while j < len(paragraph.pdf_paragraph_composition):
|
|
390
|
+
prev_composition = paragraph.pdf_paragraph_composition[j - 1]
|
|
391
|
+
if not prev_composition.pdf_line:
|
|
392
|
+
j += 1
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
prev_line = prev_composition.pdf_line
|
|
396
|
+
prev_width = prev_line.box.x2 - prev_line.box.x
|
|
397
|
+
prev_text = "".join([c.char_unicode for c in prev_line.pdf_character])
|
|
398
|
+
|
|
399
|
+
# 检查是否包含连续的点(至少 20 个)
|
|
400
|
+
# 如果有至少连续 20 个点,则代表这是目录条目
|
|
401
|
+
if re.search(r"\.{20,}", prev_text):
|
|
402
|
+
# 创建新的段落
|
|
403
|
+
new_paragraph = PdfParagraph(
|
|
404
|
+
box=Box(0, 0, 0, 0), # 临时边界框
|
|
405
|
+
pdf_paragraph_composition=(
|
|
406
|
+
paragraph.pdf_paragraph_composition[j:]
|
|
407
|
+
),
|
|
408
|
+
unicode="",
|
|
409
|
+
debug_id=generate_base58_id(),
|
|
410
|
+
)
|
|
411
|
+
# 更新原段落
|
|
412
|
+
paragraph.pdf_paragraph_composition = (
|
|
413
|
+
paragraph.pdf_paragraph_composition[:j]
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# 更新两个段落的数据
|
|
417
|
+
self.update_paragraph_data(paragraph)
|
|
418
|
+
self.update_paragraph_data(new_paragraph)
|
|
419
|
+
|
|
420
|
+
# 在原段落后插入新段落
|
|
421
|
+
paragraphs.insert(i + 1, new_paragraph)
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
# 如果前一行宽度小于中位数的一半,将当前行及后续行分割成新段落
|
|
425
|
+
if (
|
|
426
|
+
self.translation_config.split_short_lines
|
|
427
|
+
and prev_width
|
|
428
|
+
< median_width * self.translation_config.short_line_split_factor
|
|
429
|
+
):
|
|
430
|
+
# 创建新的段落
|
|
431
|
+
new_paragraph = PdfParagraph(
|
|
432
|
+
box=Box(0, 0, 0, 0), # 临时边界框
|
|
433
|
+
pdf_paragraph_composition=(
|
|
434
|
+
paragraph.pdf_paragraph_composition[j:]
|
|
435
|
+
),
|
|
436
|
+
unicode="",
|
|
437
|
+
debug_id=generate_base58_id(),
|
|
438
|
+
)
|
|
439
|
+
# 更新原段落
|
|
440
|
+
paragraph.pdf_paragraph_composition = (
|
|
441
|
+
paragraph.pdf_paragraph_composition[:j]
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# 更新两个段落的数据
|
|
445
|
+
self.update_paragraph_data(paragraph)
|
|
446
|
+
self.update_paragraph_data(new_paragraph)
|
|
447
|
+
|
|
448
|
+
# 在原段落后插入新段落
|
|
449
|
+
paragraphs.insert(i + 1, new_paragraph)
|
|
450
|
+
break
|
|
451
|
+
j += 1
|
|
452
|
+
i += 1
|