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 ADDED
@@ -0,0 +1,3 @@
1
+ from .pdf import *
2
+ from .markdown import *
3
+ from doc_page_extractor import PaddleLang
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
+ ]
@@ -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