pdf-craft 0.0.1__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.
- pdf_craft/__init__.py +3 -0
- pdf_craft/markdown.py +76 -0
- pdf_craft/pdf/__init__.py +1 -0
- pdf_craft/pdf/document.py +93 -0
- pdf_craft/pdf/extractor.py +163 -0
- pdf_craft/pdf/section.py +175 -0
- pdf_craft/pdf/text_matcher.py +93 -0
- pdf_craft/pdf/utils.py +29 -0
- pdf_craft-0.0.1.dist-info/LICENSE +661 -0
- pdf_craft-0.0.1.dist-info/METADATA +68 -0
- pdf_craft-0.0.1.dist-info/RECORD +15 -0
- pdf_craft-0.0.1.dist-info/WHEEL +5 -0
- pdf_craft-0.0.1.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_framework.py +151 -0
pdf_craft/__init__.py
ADDED
pdf_craft/markdown.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
from .pdf import Text, TextKind, TextBlock, AssetBlock, Block
|
|
7
|
+
|
|
8
|
+
class MarkDownWriter:
|
|
9
|
+
def __init__(self, md_path: str, assets_path: str, encoding: str | None):
|
|
10
|
+
self._assets_path: str = assets_path
|
|
11
|
+
self._abs_assets_path: str = os.path.abspath(os.path.join(md_path, "..", assets_path))
|
|
12
|
+
self._file: io.TextIOWrapper = open(md_path, "w", encoding=encoding)
|
|
13
|
+
self._texts_buffer: list[Text] = []
|
|
14
|
+
|
|
15
|
+
def __enter__(self) -> "MarkDownWriter":
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
19
|
+
self.close()
|
|
20
|
+
|
|
21
|
+
def flush(self) -> None:
|
|
22
|
+
self._file.flush()
|
|
23
|
+
|
|
24
|
+
def close(self) -> None:
|
|
25
|
+
self._close_texts_buffer()
|
|
26
|
+
self._file.close()
|
|
27
|
+
|
|
28
|
+
def write(self, block: Block) -> None:
|
|
29
|
+
if isinstance(block, TextBlock):
|
|
30
|
+
if block.kind == TextKind.TITLE:
|
|
31
|
+
self._close_texts_buffer()
|
|
32
|
+
self._file.write("# ")
|
|
33
|
+
self._write_text_contents(block.texts)
|
|
34
|
+
self._file.write("\n\n")
|
|
35
|
+
elif block.kind == TextKind.PLAIN_TEXT:
|
|
36
|
+
self._write_plain_text(block)
|
|
37
|
+
elif isinstance(block, AssetBlock):
|
|
38
|
+
self._close_texts_buffer()
|
|
39
|
+
self._write_image(block)
|
|
40
|
+
|
|
41
|
+
def _close_texts_buffer(self):
|
|
42
|
+
if len(self._texts_buffer) > 0:
|
|
43
|
+
self._write_text_contents(self._texts_buffer)
|
|
44
|
+
self._file.write("\n\n")
|
|
45
|
+
self._texts_buffer.clear()
|
|
46
|
+
|
|
47
|
+
def _write_plain_text(self, block: TextBlock) -> None:
|
|
48
|
+
if block.has_paragraph_indentation:
|
|
49
|
+
self._close_texts_buffer()
|
|
50
|
+
self._texts_buffer.extend(block.texts)
|
|
51
|
+
if not block.last_line_touch_end:
|
|
52
|
+
self._close_texts_buffer()
|
|
53
|
+
|
|
54
|
+
def _write_image(self, block: AssetBlock) -> None:
|
|
55
|
+
os.makedirs(self._abs_assets_path, exist_ok=True)
|
|
56
|
+
hash = sha256()
|
|
57
|
+
hash.update(block.image.tobytes())
|
|
58
|
+
file_name = f"{hash.hexdigest()}.png"
|
|
59
|
+
file_path = os.path.join(self._abs_assets_path, file_name)
|
|
60
|
+
relative_path = os.path.join(self._assets_path, file_name)
|
|
61
|
+
|
|
62
|
+
if not os.path.exists(file_path):
|
|
63
|
+
block.image.save(file_path, "PNG")
|
|
64
|
+
|
|
65
|
+
self._file.write("![")
|
|
66
|
+
self._write_text_contents(block.texts, "]")
|
|
67
|
+
self._file.write(f"]({relative_path})")
|
|
68
|
+
self._file.write("\n\n")
|
|
69
|
+
|
|
70
|
+
def _write_text_contents(self, texts: Iterable[Text], ban_symbol: str | None = None) -> None:
|
|
71
|
+
for text in texts:
|
|
72
|
+
content = text.content.strip()
|
|
73
|
+
content = content.replace("\n", " ")
|
|
74
|
+
if ban_symbol is not None:
|
|
75
|
+
content.replace(ban_symbol, "")
|
|
76
|
+
self._file.write(content)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .extractor import Block, Text, TextKind, TextBlock, AssetBlock, PDFPageExtractor
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import fitz
|
|
3
|
+
|
|
4
|
+
from typing import Generator, Literal
|
|
5
|
+
from PIL.Image import frombytes, Image
|
|
6
|
+
from doc_page_extractor import plot, Layout, PaddleLang, DocExtractor, ExtractedResult
|
|
7
|
+
from .section import Section
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DocumentExtractor:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
device: Literal["cpu", "cuda"],
|
|
14
|
+
model_dir_path: str,
|
|
15
|
+
debug_dir_path: str | None = None,
|
|
16
|
+
):
|
|
17
|
+
self._debug_dir_path: str | None = debug_dir_path
|
|
18
|
+
self._doc_extractor = DocExtractor(
|
|
19
|
+
device=device,
|
|
20
|
+
model_dir_path=model_dir_path,
|
|
21
|
+
order_by_layoutreader=False,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def extract(self, pdf: str | fitz.Document, lang: PaddleLang) -> Generator[tuple[ExtractedResult, list[Layout]], None, None]:
|
|
25
|
+
for result, section in self._extract_results_and_sections(pdf, lang):
|
|
26
|
+
framework_layouts = section.framework()
|
|
27
|
+
yield result, [
|
|
28
|
+
layout for layout in result.layouts
|
|
29
|
+
if layout not in framework_layouts
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def _extract_results_and_sections(self, pdf: str | fitz.Document, lang: PaddleLang):
|
|
33
|
+
max_len = 2 # section can be viewed up to 2 pages back
|
|
34
|
+
queue: list[tuple[ExtractedResult, Section]] = []
|
|
35
|
+
|
|
36
|
+
for result in self._extract_page_result(pdf, lang):
|
|
37
|
+
section = Section(result.layouts)
|
|
38
|
+
for i, (_, pre_section) in enumerate(queue):
|
|
39
|
+
offset = len(queue) - i
|
|
40
|
+
pre_section.link_next(section, offset)
|
|
41
|
+
|
|
42
|
+
queue.append((result, section))
|
|
43
|
+
if len(queue) > max_len:
|
|
44
|
+
yield queue.pop(0)
|
|
45
|
+
|
|
46
|
+
for result, section in queue:
|
|
47
|
+
yield result, section
|
|
48
|
+
|
|
49
|
+
def _extract_page_result(self, pdf: str | fitz.Document, lang: PaddleLang):
|
|
50
|
+
if self._debug_dir_path is not None:
|
|
51
|
+
os.makedirs(self._debug_dir_path, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
document: fitz.Document
|
|
54
|
+
should_close = False
|
|
55
|
+
if isinstance(pdf, str):
|
|
56
|
+
document = fitz.open(pdf)
|
|
57
|
+
should_close = True
|
|
58
|
+
else:
|
|
59
|
+
document = pdf
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
for i, page in enumerate(document.pages()):
|
|
63
|
+
dpi = 300 # for scanned book pages
|
|
64
|
+
image = self._page_screenshot_image(page, dpi)
|
|
65
|
+
result = self._doc_extractor.extract(
|
|
66
|
+
image=image,
|
|
67
|
+
lang=lang,
|
|
68
|
+
adjust_points=False,
|
|
69
|
+
)
|
|
70
|
+
if self._debug_dir_path is not None:
|
|
71
|
+
self._generate_plot(image, i, result, self._debug_dir_path)
|
|
72
|
+
yield result
|
|
73
|
+
finally:
|
|
74
|
+
if should_close:
|
|
75
|
+
document.close()
|
|
76
|
+
|
|
77
|
+
def _page_screenshot_image(self, page: fitz.Page, dpi: int):
|
|
78
|
+
default_dpi = 72
|
|
79
|
+
matrix = fitz.Matrix(dpi / default_dpi, dpi / default_dpi)
|
|
80
|
+
pixmap = page.get_pixmap(matrix=matrix)
|
|
81
|
+
return frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
|
|
82
|
+
|
|
83
|
+
def _generate_plot(self, image: Image, index: int, result: ExtractedResult, plot_path: str):
|
|
84
|
+
plot_image: Image
|
|
85
|
+
if result.adjusted_image is None:
|
|
86
|
+
plot_image = image.copy()
|
|
87
|
+
else:
|
|
88
|
+
plot_image = result.adjusted_image
|
|
89
|
+
|
|
90
|
+
plot(plot_image, result.layouts)
|
|
91
|
+
os.makedirs(plot_path, exist_ok=True)
|
|
92
|
+
image_path = os.path.join(plot_path, f"plot_{index + 1}.png")
|
|
93
|
+
plot_image.save(image_path)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Iterable, Generator
|
|
5
|
+
from PIL.Image import Image
|
|
6
|
+
from fitz import Document
|
|
7
|
+
from doc_page_extractor import clip, PaddleLang, Rectangle, Layout, LayoutClass, OCRFragment, ExtractedResult
|
|
8
|
+
from .document import DocumentExtractor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TextKind(Enum):
|
|
12
|
+
TITLE = 0
|
|
13
|
+
PLAIN_TEXT = 1
|
|
14
|
+
ABANDON = 2
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Text:
|
|
18
|
+
content: str
|
|
19
|
+
rank: float
|
|
20
|
+
rect: Rectangle
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TextBlock:
|
|
24
|
+
rect: Rectangle
|
|
25
|
+
kind: TextKind
|
|
26
|
+
texts: list[Text]
|
|
27
|
+
has_paragraph_indentation: bool = False
|
|
28
|
+
last_line_touch_end: bool = False
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AssetBlock:
|
|
32
|
+
rect: Rectangle
|
|
33
|
+
image: Image
|
|
34
|
+
texts: list[Text]
|
|
35
|
+
|
|
36
|
+
Block = TextBlock | AssetBlock
|
|
37
|
+
|
|
38
|
+
class PDFPageExtractor:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
device: Literal["cpu", "cuda"],
|
|
42
|
+
model_dir_path: str,
|
|
43
|
+
debug_dir_path: str | None = None,
|
|
44
|
+
):
|
|
45
|
+
self._doc_extractor: DocumentExtractor = DocumentExtractor(
|
|
46
|
+
device=device,
|
|
47
|
+
model_dir_path=model_dir_path,
|
|
48
|
+
debug_dir_path=debug_dir_path,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def extract(self, pdf: str | Document, lang: PaddleLang) -> Generator[list[Block], None, None]:
|
|
52
|
+
for result, layouts in self._doc_extractor.extract(pdf, lang):
|
|
53
|
+
blocks = self._convert_to_blocks(result, layouts)
|
|
54
|
+
page_range = self._texts_range(blocks)
|
|
55
|
+
|
|
56
|
+
for block in blocks:
|
|
57
|
+
if not isinstance(block, TextBlock) or \
|
|
58
|
+
block.kind == TextKind.ABANDON:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if len(block.texts) == 1:
|
|
62
|
+
mean_line_height, x1, x2 = page_range
|
|
63
|
+
else:
|
|
64
|
+
mean_line_height, x1, x2 = self._texts_range((block,))
|
|
65
|
+
|
|
66
|
+
first_text = block.texts[0]
|
|
67
|
+
last_text = block.texts[-1]
|
|
68
|
+
first_delta_x = (first_text.rect.lt[0] + first_text.rect.lb[0]) / 2 - x1
|
|
69
|
+
last_delta_x = x2 - (last_text.rect.rt[0] + last_text.rect.rb[0]) / 2
|
|
70
|
+
block.has_paragraph_indentation = first_delta_x > mean_line_height
|
|
71
|
+
block.last_line_touch_end = last_delta_x < mean_line_height
|
|
72
|
+
|
|
73
|
+
yield blocks
|
|
74
|
+
|
|
75
|
+
def _convert_to_blocks(self, result: ExtractedResult, layouts: list[Layout]) -> list[Block]:
|
|
76
|
+
store: list[tuple[LayoutClass, Block]] = []
|
|
77
|
+
def previous_block(cls: LayoutClass) -> Block | None:
|
|
78
|
+
for i in range(len(store) - 1, -1, -1):
|
|
79
|
+
block_cls, block = store[i]
|
|
80
|
+
if cls == block_cls:
|
|
81
|
+
return block
|
|
82
|
+
if cls != LayoutClass.ABANDON:
|
|
83
|
+
return None
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
for layout in layouts:
|
|
87
|
+
cls = layout.cls
|
|
88
|
+
if cls == LayoutClass.TITLE:
|
|
89
|
+
store.append((cls, TextBlock(
|
|
90
|
+
rect=layout.rect,
|
|
91
|
+
kind=TextKind.TITLE,
|
|
92
|
+
texts=self._convert_to_text(layout.fragments),
|
|
93
|
+
)))
|
|
94
|
+
elif cls == LayoutClass.PLAIN_TEXT:
|
|
95
|
+
store.append((cls, TextBlock(
|
|
96
|
+
rect=layout.rect,
|
|
97
|
+
kind=TextKind.PLAIN_TEXT,
|
|
98
|
+
texts=self._convert_to_text(layout.fragments),
|
|
99
|
+
)))
|
|
100
|
+
elif cls == LayoutClass.ABANDON:
|
|
101
|
+
store.append((cls, TextBlock(
|
|
102
|
+
rect=layout.rect,
|
|
103
|
+
kind=TextKind.ABANDON,
|
|
104
|
+
texts=self._convert_to_text(layout.fragments),
|
|
105
|
+
)))
|
|
106
|
+
elif cls == LayoutClass.FIGURE or \
|
|
107
|
+
cls == LayoutClass.TABLE or \
|
|
108
|
+
cls == LayoutClass.ISOLATE_FORMULA:
|
|
109
|
+
store.append((cls, AssetBlock(
|
|
110
|
+
rect=layout.rect,
|
|
111
|
+
texts=[],
|
|
112
|
+
image=clip(result, layout),
|
|
113
|
+
)))
|
|
114
|
+
elif cls == LayoutClass.FIGURE_CAPTION:
|
|
115
|
+
block = previous_block(LayoutClass.FIGURE)
|
|
116
|
+
if block is not None:
|
|
117
|
+
assert isinstance(block, AssetBlock)
|
|
118
|
+
block.texts.extend(self._convert_to_text(layout.fragments))
|
|
119
|
+
elif cls == LayoutClass.TABLE_CAPTION or \
|
|
120
|
+
cls == LayoutClass.TABLE_FOOTNOTE:
|
|
121
|
+
block = previous_block(LayoutClass.TABLE)
|
|
122
|
+
if block is not None:
|
|
123
|
+
assert isinstance(block, AssetBlock)
|
|
124
|
+
block.texts.extend(self._convert_to_text(layout.fragments))
|
|
125
|
+
elif cls == LayoutClass.FORMULA_CAPTION:
|
|
126
|
+
block = previous_block(LayoutClass.ISOLATE_FORMULA)
|
|
127
|
+
if block is not None:
|
|
128
|
+
assert isinstance(block, AssetBlock)
|
|
129
|
+
block.texts.extend(self._convert_to_text(layout.fragments))
|
|
130
|
+
|
|
131
|
+
return [block for _, block in store]
|
|
132
|
+
|
|
133
|
+
def _texts_range(self, blocks: Iterable[Block]) -> tuple[float, float, float]:
|
|
134
|
+
sum_lines_height: float = 0.0
|
|
135
|
+
texts_count: int = 0
|
|
136
|
+
x1: float = float("inf")
|
|
137
|
+
x2: float = float("-inf")
|
|
138
|
+
|
|
139
|
+
for block in blocks:
|
|
140
|
+
if not isinstance(block, TextBlock):
|
|
141
|
+
continue
|
|
142
|
+
if block.kind == TextKind.ABANDON:
|
|
143
|
+
continue
|
|
144
|
+
for text in block.texts:
|
|
145
|
+
sum_lines_height += text.rect.size[1]
|
|
146
|
+
texts_count += 1
|
|
147
|
+
for x, _ in text.rect:
|
|
148
|
+
x1 = min(x1, x)
|
|
149
|
+
x2 = max(x2, x)
|
|
150
|
+
|
|
151
|
+
if texts_count == 0:
|
|
152
|
+
return 0.0, 0.0, 0.0
|
|
153
|
+
return sum_lines_height / texts_count, x1, x2
|
|
154
|
+
|
|
155
|
+
def _convert_to_text(self, fragments: list[OCRFragment]) -> list[Text]:
|
|
156
|
+
return [
|
|
157
|
+
Text(
|
|
158
|
+
content=f.text,
|
|
159
|
+
rank=f.rank,
|
|
160
|
+
rect=f.rect,
|
|
161
|
+
)
|
|
162
|
+
for f in fragments
|
|
163
|
+
]
|
pdf_craft/pdf/section.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Iterable, Sequence, Callable
|
|
4
|
+
from doc_page_extractor import Point, Rectangle, Layout, OCRFragment
|
|
5
|
+
from .text_matcher import check_texts_matching_rate
|
|
6
|
+
from .utils import rate, intersection_area_size
|
|
7
|
+
|
|
8
|
+
class _Shape:
|
|
9
|
+
def __init__(self, layout: Layout):
|
|
10
|
+
self.layout: Layout = layout
|
|
11
|
+
self.pre: list[Layout | None] = [None, None]
|
|
12
|
+
self.nex: list[Layout | None] = [None, None]
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def distance2(self) -> float:
|
|
16
|
+
x, y = self.layout.rect.lt
|
|
17
|
+
return x*x + y*y
|
|
18
|
+
|
|
19
|
+
class Section:
|
|
20
|
+
def __init__(self, layouts: Iterable[Layout]) -> list[Layout]:
|
|
21
|
+
self._shapes: list[_Shape] = [_Shape(layout) for layout in layouts]
|
|
22
|
+
|
|
23
|
+
def framework(self) -> list[Layout]:
|
|
24
|
+
pre_shapes = list(self._side_framework(lambda shape: shape.pre))
|
|
25
|
+
nex_shapes = list(self._side_framework(lambda shape: shape.nex))
|
|
26
|
+
framework_shapes: list[_Shape]
|
|
27
|
+
|
|
28
|
+
if len(pre_shapes) < len(nex_shapes):
|
|
29
|
+
framework_shapes = nex_shapes
|
|
30
|
+
else:
|
|
31
|
+
framework_shapes = pre_shapes
|
|
32
|
+
|
|
33
|
+
return [shape.layout for shape in framework_shapes]
|
|
34
|
+
|
|
35
|
+
def _side_framework(self, get_side: Callable[[_Shape], list[Layout | None]]):
|
|
36
|
+
for shape in self._shapes:
|
|
37
|
+
matched_shapes = get_side(shape)
|
|
38
|
+
# The logic here should be understood as the processing of book page scans.
|
|
39
|
+
# When the book is opened, the even-numbered pages will be on the left and the odd-numbered pages will be on the right.
|
|
40
|
+
# Usually when editing and typesetting, even-numbered pages will be matched with one typesetting format,
|
|
41
|
+
# and odd-numbered pages will be matched with another typesetting format, in order to achieve aesthetics.
|
|
42
|
+
# Therefore, typesetting format (framework) matching usually takes into account the situation of matching every other page.
|
|
43
|
+
if matched_shapes[0] is not None or matched_shapes[1] is not None:
|
|
44
|
+
yield shape
|
|
45
|
+
|
|
46
|
+
def link_next(self, next: Section, offset: int) -> None:
|
|
47
|
+
assert offset in (1, 2), f"invalid offset {offset}"
|
|
48
|
+
matched_shapes_matrix: list[list[_Shape]] = []
|
|
49
|
+
for shape in self._shapes:
|
|
50
|
+
matched_shapes_matrix.append([
|
|
51
|
+
# pylint: disable=W0212
|
|
52
|
+
next_shape for next_shape in next._shapes
|
|
53
|
+
if self._is_shape_contents_matches(shape, next_shape)
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
origin_shapes = self._find_origin_shapes(matched_shapes_matrix)
|
|
57
|
+
if origin_shapes is not None:
|
|
58
|
+
origins = (origin_shapes[0].layout.rect.lt, origin_shapes[1].layout.rect.lt)
|
|
59
|
+
for shape1, shape2 in self._iter_matched_shapes(origins, matched_shapes_matrix):
|
|
60
|
+
shape1.nex[offset - 1] = shape2
|
|
61
|
+
shape2.pre[offset - 1] = shape1
|
|
62
|
+
|
|
63
|
+
def _is_shape_contents_matches(self, shape1: _Shape, shape2: _Shape) -> bool:
|
|
64
|
+
size_match_rate = 0.95
|
|
65
|
+
layout1 = shape1.layout
|
|
66
|
+
layout2 = shape2.layout
|
|
67
|
+
size1 = layout1.rect.size
|
|
68
|
+
size2 = layout2.rect.size
|
|
69
|
+
if rate(size1[0], size2[0]) < size_match_rate or \
|
|
70
|
+
rate(size1[1], size2[1]) < size_match_rate:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
matched_count: int = 0
|
|
74
|
+
|
|
75
|
+
for fragment1 in layout1.fragments:
|
|
76
|
+
for fragment2 in layout2.fragments:
|
|
77
|
+
if self._is_fragments_matches(layout1, layout2, fragment1, fragment2):
|
|
78
|
+
matched_count += 1
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
fragments_count = max(len(layout1.fragments), len(layout2.fragments))
|
|
82
|
+
if fragments_count == 0:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
return self._check_group_matches(
|
|
86
|
+
matched_count / fragments_count,
|
|
87
|
+
fragments_count,
|
|
88
|
+
(0.0, 0.45, 0.45, 0.6, 0.8, 0.95),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _is_fragments_matches(self, layout1: Layout, layout2: Layout, fragment1: OCRFragment, fragment2: OCRFragment) -> bool:
|
|
92
|
+
size_match_rate = 0.85
|
|
93
|
+
rect1 = self._relative_rect(layout1.rect.lt, fragment1.rect)
|
|
94
|
+
rect2 = self._relative_rect(layout2.rect.lt, fragment2.rect)
|
|
95
|
+
if self._intersection_rate(rect1, rect2) < size_match_rate:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
text_rate, text_length = check_texts_matching_rate(fragment1.text, fragment2.text)
|
|
99
|
+
return self._check_group_matches(
|
|
100
|
+
text_rate,
|
|
101
|
+
text_length,
|
|
102
|
+
(0.0, 0.0, 0.5, 0.55, 0.6, 0.75, 0.8, 0.95),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _find_origin_shapes(self, matched_shapes: list[list[_Shape]]):
|
|
106
|
+
origin_shape1: _Shape | None = None
|
|
107
|
+
origin_shape2: _Shape | None = None
|
|
108
|
+
origin_matched_shape: list[_Shape] | None = None
|
|
109
|
+
min_distance2: float = float("inf")
|
|
110
|
+
|
|
111
|
+
for shape, matched_shapes in zip(self._shapes, matched_shapes):
|
|
112
|
+
if len(matched_shapes) == 0:
|
|
113
|
+
continue
|
|
114
|
+
distance2 = shape.distance2
|
|
115
|
+
if distance2 < min_distance2:
|
|
116
|
+
origin_shape1 = shape
|
|
117
|
+
origin_matched_shape = matched_shapes
|
|
118
|
+
min_distance2 = distance2
|
|
119
|
+
|
|
120
|
+
if origin_shape1 is None:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
assert origin_matched_shape is not None
|
|
124
|
+
min_distance2 = float("inf")
|
|
125
|
+
|
|
126
|
+
for shape in origin_matched_shape:
|
|
127
|
+
distance2 = shape.distance2
|
|
128
|
+
if distance2 < min_distance2:
|
|
129
|
+
origin_shape2 = shape
|
|
130
|
+
min_distance2 = distance2
|
|
131
|
+
|
|
132
|
+
assert origin_shape2 is not None
|
|
133
|
+
return origin_shape1, origin_shape2
|
|
134
|
+
|
|
135
|
+
def _iter_matched_shapes(self, origins: tuple[Point, Point], matched_shapes_matrix: list[list[_Shape]]):
|
|
136
|
+
for shape1, matched_shapes in zip(self._shapes, matched_shapes_matrix):
|
|
137
|
+
if len(matched_shapes) == 0:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
rect1 = self._relative_rect(origins[0], shape1.layout.rect)
|
|
141
|
+
max_area_rate: float = float("-inf")
|
|
142
|
+
matched_shape2: _Shape | None = None
|
|
143
|
+
|
|
144
|
+
for shape2 in matched_shapes:
|
|
145
|
+
rect2 = self._relative_rect(origins[1], shape2.layout.rect)
|
|
146
|
+
size_rate = self._intersection_rate(rect1, rect2)
|
|
147
|
+
if size_rate > max_area_rate:
|
|
148
|
+
max_area_rate = size_rate
|
|
149
|
+
matched_shape2 = shape2
|
|
150
|
+
|
|
151
|
+
if max_area_rate >= 0.85:
|
|
152
|
+
assert matched_shape2 is not None
|
|
153
|
+
yield shape1, matched_shape2
|
|
154
|
+
|
|
155
|
+
def _check_group_matches(self, calculated_rate: float, group_count: int, rates_list: Sequence[float]) -> bool:
|
|
156
|
+
if group_count < len(rates_list):
|
|
157
|
+
return calculated_rate >= rates_list[group_count]
|
|
158
|
+
else:
|
|
159
|
+
return calculated_rate >= rates_list[-1]
|
|
160
|
+
|
|
161
|
+
def _relative_rect(self, origin: Point, rect: Rectangle) -> Rectangle:
|
|
162
|
+
return Rectangle(
|
|
163
|
+
lt=(rect.lt[0] - origin[0], rect.lt[1] - origin[1]),
|
|
164
|
+
rt=(rect.rt[0] - origin[0], rect.rt[1] - origin[1]),
|
|
165
|
+
lb=(rect.lb[0] - origin[0], rect.lb[1] - origin[1]),
|
|
166
|
+
rb=(rect.rb[0] - origin[0], rect.rb[1] - origin[1])
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _intersection_rate(self, rect1: Rectangle, rect2: Rectangle) -> float:
|
|
170
|
+
width1, height1 = rect1.size
|
|
171
|
+
width2, height2 = rect2.size
|
|
172
|
+
width, height = intersection_area_size(rect1, rect2)
|
|
173
|
+
width_rate = width / max(width1, width2)
|
|
174
|
+
height_rate = height / max(height1, height2)
|
|
175
|
+
return 0.5 * (width_rate + height_rate)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import io
|
|
3
|
+
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from unicodedata import category
|
|
6
|
+
from alphabet_detector import AlphabetDetector
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_texts_matching_rate(text1: str, text2: str) -> tuple[float, int]:
|
|
10
|
+
words1: list[str] = list(split_into_words(text1))
|
|
11
|
+
words2: list[str] = list(split_into_words(text2))
|
|
12
|
+
|
|
13
|
+
if len(words1) > len(words2):
|
|
14
|
+
words1, words2 = words2, words1
|
|
15
|
+
|
|
16
|
+
marked_taken_list: list[bool] = [False] * len(words2)
|
|
17
|
+
matched_indexes: list[int] = []
|
|
18
|
+
for word1 in words1:
|
|
19
|
+
matched_index: int = -1
|
|
20
|
+
for i, word2 in enumerate(words2):
|
|
21
|
+
if not marked_taken_list[i] and word1 == word2:
|
|
22
|
+
marked_taken_list[i] = True
|
|
23
|
+
matched_index = i
|
|
24
|
+
break
|
|
25
|
+
matched_indexes.append(matched_index)
|
|
26
|
+
|
|
27
|
+
not_matched_count: int = len(words2) - len(words1)
|
|
28
|
+
for i, matched_index in enumerate(matched_indexes):
|
|
29
|
+
if matched_index == -1 or i > matched_index:
|
|
30
|
+
not_matched_count += 1
|
|
31
|
+
|
|
32
|
+
return 1.0 - not_matched_count / len(words2), len(words2)
|
|
33
|
+
|
|
34
|
+
class _Phase(Enum):
|
|
35
|
+
Init = 0,
|
|
36
|
+
Letter = 1,
|
|
37
|
+
Character = 2,
|
|
38
|
+
Number = 3,
|
|
39
|
+
Space = 4,
|
|
40
|
+
|
|
41
|
+
def split_into_words(text: str):
|
|
42
|
+
space_pattern = re.compile(r"\s")
|
|
43
|
+
number_pattern = re.compile(r"\d")
|
|
44
|
+
number_signs_pattern = re.compile(r"[\.,']")
|
|
45
|
+
word_buffer = io.StringIO()
|
|
46
|
+
phase: _Phase = _Phase.Init
|
|
47
|
+
|
|
48
|
+
for char in text:
|
|
49
|
+
if _is_letter(char):
|
|
50
|
+
if phase == _Phase.Number:
|
|
51
|
+
yield word_buffer.getvalue()
|
|
52
|
+
word_buffer = io.StringIO()
|
|
53
|
+
word_buffer.write(char)
|
|
54
|
+
phase = _Phase.Letter
|
|
55
|
+
|
|
56
|
+
elif number_pattern.match(char):
|
|
57
|
+
if phase == _Phase.Letter:
|
|
58
|
+
yield word_buffer.getvalue()
|
|
59
|
+
word_buffer = io.StringIO()
|
|
60
|
+
word_buffer.write(char)
|
|
61
|
+
phase = _Phase.Letter
|
|
62
|
+
|
|
63
|
+
elif phase == _Phase.Number and \
|
|
64
|
+
number_signs_pattern.match(char):
|
|
65
|
+
word_buffer.write(char)
|
|
66
|
+
|
|
67
|
+
else:
|
|
68
|
+
if phase == _Phase.Letter or \
|
|
69
|
+
phase == _Phase.Number:
|
|
70
|
+
yield word_buffer.getvalue()
|
|
71
|
+
word_buffer = io.StringIO()
|
|
72
|
+
|
|
73
|
+
if space_pattern.match(char):
|
|
74
|
+
phase = _Phase.Space
|
|
75
|
+
else: # others (like Chinese character)
|
|
76
|
+
yield char
|
|
77
|
+
phase = _Phase.Character
|
|
78
|
+
|
|
79
|
+
if phase == _Phase.Letter:
|
|
80
|
+
yield word_buffer.getvalue()
|
|
81
|
+
|
|
82
|
+
ad = AlphabetDetector()
|
|
83
|
+
|
|
84
|
+
# check is Latin, Cyrillic, Greek, or Hebrew letter
|
|
85
|
+
def _is_letter(char: str):
|
|
86
|
+
if not category(char).startswith("L"):
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
# AlphabetDetector unable to process punctuation
|
|
90
|
+
return ad.is_latin(char) or \
|
|
91
|
+
ad.is_cyrillic(char) or \
|
|
92
|
+
ad.is_greek(char) or \
|
|
93
|
+
ad.is_hebrew(char)
|
pdf_craft/pdf/utils.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from doc_page_extractor import Rectangle
|
|
2
|
+
from shapely.geometry import Polygon
|
|
3
|
+
|
|
4
|
+
def rate(value1: float, value2: float) -> float:
|
|
5
|
+
if value1 > value2:
|
|
6
|
+
value1, value2 = value2, value1
|
|
7
|
+
if value2 == 0.0:
|
|
8
|
+
return 1.0
|
|
9
|
+
else:
|
|
10
|
+
return value1 / value2
|
|
11
|
+
|
|
12
|
+
def intersection_area_size(rect1: Rectangle, rect2: Rectangle) -> tuple[float, float]:
|
|
13
|
+
poly1 = Polygon(rect1)
|
|
14
|
+
poly2 = Polygon(rect2)
|
|
15
|
+
intersection: Polygon = poly1.intersection(poly2)
|
|
16
|
+
if intersection.is_empty:
|
|
17
|
+
return 0.0, 0.0
|
|
18
|
+
|
|
19
|
+
x1: float = float("inf")
|
|
20
|
+
y1: float = float("inf")
|
|
21
|
+
x2: float = float("-inf")
|
|
22
|
+
y2: float = float("-inf")
|
|
23
|
+
|
|
24
|
+
for x, y in intersection.exterior.coords:
|
|
25
|
+
x1 = min(x1, x)
|
|
26
|
+
y1 = min(y1, y)
|
|
27
|
+
x2 = max(x2, x)
|
|
28
|
+
y2 = max(y2, y)
|
|
29
|
+
return x2 - x1, y2 - y1
|