docx-from-pdf 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. docx_from_pdf-0.1/LICENSE +7 -0
  2. docx_from_pdf-0.1/MANIFEST.in +7 -0
  3. docx_from_pdf-0.1/PKG-INFO +103 -0
  4. docx_from_pdf-0.1/README.md +74 -0
  5. docx_from_pdf-0.1/docx_from_pdf/__init__.py +3 -0
  6. docx_from_pdf-0.1/docx_from_pdf/backend/__init__.py +1 -0
  7. docx_from_pdf-0.1/docx_from_pdf/backend/pdfium.py +440 -0
  8. docx_from_pdf-0.1/docx_from_pdf/common/Block.py +144 -0
  9. docx_from_pdf-0.1/docx_from_pdf/common/Collection.py +359 -0
  10. docx_from_pdf-0.1/docx_from_pdf/common/Element.py +307 -0
  11. docx_from_pdf-0.1/docx_from_pdf/common/__init__.py +0 -0
  12. docx_from_pdf-0.1/docx_from_pdf/common/algorithm.py +403 -0
  13. docx_from_pdf-0.1/docx_from_pdf/common/constants.py +90 -0
  14. docx_from_pdf-0.1/docx_from_pdf/common/docx.py +593 -0
  15. docx_from_pdf-0.1/docx_from_pdf/common/geometry.py +206 -0
  16. docx_from_pdf-0.1/docx_from_pdf/common/share.py +319 -0
  17. docx_from_pdf-0.1/docx_from_pdf/converter.py +473 -0
  18. docx_from_pdf-0.1/docx_from_pdf/font/Fonts.py +243 -0
  19. docx_from_pdf-0.1/docx_from_pdf/font/__init__.py +0 -0
  20. docx_from_pdf-0.1/docx_from_pdf/gui/App.py +37 -0
  21. docx_from_pdf-0.1/docx_from_pdf/gui/MainFrame.py +147 -0
  22. docx_from_pdf-0.1/docx_from_pdf/gui/__init__.py +0 -0
  23. docx_from_pdf-0.1/docx_from_pdf/image/Image.py +112 -0
  24. docx_from_pdf-0.1/docx_from_pdf/image/ImageBlock.py +81 -0
  25. docx_from_pdf-0.1/docx_from_pdf/image/ImageSpan.py +27 -0
  26. docx_from_pdf-0.1/docx_from_pdf/image/ImagesExtractor.py +117 -0
  27. docx_from_pdf-0.1/docx_from_pdf/image/__init__.py +0 -0
  28. docx_from_pdf-0.1/docx_from_pdf/layout/Blocks.py +650 -0
  29. docx_from_pdf-0.1/docx_from_pdf/layout/Column.py +49 -0
  30. docx_from_pdf-0.1/docx_from_pdf/layout/Layout.py +177 -0
  31. docx_from_pdf-0.1/docx_from_pdf/layout/Section.py +97 -0
  32. docx_from_pdf-0.1/docx_from_pdf/layout/Sections.py +91 -0
  33. docx_from_pdf-0.1/docx_from_pdf/layout/__init__.py +0 -0
  34. docx_from_pdf-0.1/docx_from_pdf/main.py +135 -0
  35. docx_from_pdf-0.1/docx_from_pdf/page/BasePage.py +27 -0
  36. docx_from_pdf-0.1/docx_from_pdf/page/Page.py +211 -0
  37. docx_from_pdf-0.1/docx_from_pdf/page/Pages.py +90 -0
  38. docx_from_pdf-0.1/docx_from_pdf/page/RawPage.py +279 -0
  39. docx_from_pdf-0.1/docx_from_pdf/page/RawPageFactory.py +21 -0
  40. docx_from_pdf-0.1/docx_from_pdf/page/RawPagePdfium.py +66 -0
  41. docx_from_pdf-0.1/docx_from_pdf/page/__init__.py +0 -0
  42. docx_from_pdf-0.1/docx_from_pdf/shape/Path.py +405 -0
  43. docx_from_pdf-0.1/docx_from_pdf/shape/Paths.py +142 -0
  44. docx_from_pdf-0.1/docx_from_pdf/shape/Shape.py +365 -0
  45. docx_from_pdf-0.1/docx_from_pdf/shape/Shapes.py +241 -0
  46. docx_from_pdf-0.1/docx_from_pdf/shape/__init__.py +0 -0
  47. docx_from_pdf-0.1/docx_from_pdf/table/Border.py +419 -0
  48. docx_from_pdf-0.1/docx_from_pdf/table/Cell.py +165 -0
  49. docx_from_pdf-0.1/docx_from_pdf/table/Cells.py +27 -0
  50. docx_from_pdf-0.1/docx_from_pdf/table/Row.py +78 -0
  51. docx_from_pdf-0.1/docx_from_pdf/table/Rows.py +25 -0
  52. docx_from_pdf-0.1/docx_from_pdf/table/TableBlock.py +174 -0
  53. docx_from_pdf-0.1/docx_from_pdf/table/TableStructure.py +633 -0
  54. docx_from_pdf-0.1/docx_from_pdf/table/TablesConstructor.py +382 -0
  55. docx_from_pdf-0.1/docx_from_pdf/table/__init__.py +0 -0
  56. docx_from_pdf-0.1/docx_from_pdf/text/Char.py +65 -0
  57. docx_from_pdf-0.1/docx_from_pdf/text/Line.py +183 -0
  58. docx_from_pdf-0.1/docx_from_pdf/text/Lines.py +281 -0
  59. docx_from_pdf-0.1/docx_from_pdf/text/Spans.py +59 -0
  60. docx_from_pdf-0.1/docx_from_pdf/text/TextBlock.py +502 -0
  61. docx_from_pdf-0.1/docx_from_pdf/text/TextSpan.py +426 -0
  62. docx_from_pdf-0.1/docx_from_pdf/text/__init__.py +0 -0
  63. docx_from_pdf-0.1/docx_from_pdf.egg-info/PKG-INFO +103 -0
  64. docx_from_pdf-0.1/docx_from_pdf.egg-info/SOURCES.txt +106 -0
  65. docx_from_pdf-0.1/docx_from_pdf.egg-info/dependency_links.txt +1 -0
  66. docx_from_pdf-0.1/docx_from_pdf.egg-info/entry_points.txt +2 -0
  67. docx_from_pdf-0.1/docx_from_pdf.egg-info/not-zip-safe +1 -0
  68. docx_from_pdf-0.1/docx_from_pdf.egg-info/requires.txt +7 -0
  69. docx_from_pdf-0.1/docx_from_pdf.egg-info/top_level.txt +1 -0
  70. docx_from_pdf-0.1/pyproject.toml +3 -0
  71. docx_from_pdf-0.1/requirements.txt +7 -0
  72. docx_from_pdf-0.1/setup.cfg +4 -0
  73. docx_from_pdf-0.1/setup.py +57 -0
  74. docx_from_pdf-0.1/test/samples/demo-blank.pdf +0 -0
  75. docx_from_pdf-0.1/test/samples/demo-image-colorspace.pdf +0 -0
  76. docx_from_pdf-0.1/test/samples/demo-image-floating.pdf +0 -0
  77. docx_from_pdf-0.1/test/samples/demo-image-overlap.pdf +0 -0
  78. docx_from_pdf-0.1/test/samples/demo-image-rotation.pdf +0 -0
  79. docx_from_pdf-0.1/test/samples/demo-image-vector-graphic.pdf +0 -0
  80. docx_from_pdf-0.1/test/samples/demo-image.pdf +0 -0
  81. docx_from_pdf-0.1/test/samples/demo-issue-340.pdf +0 -0
  82. docx_from_pdf-0.1/test/samples/demo-issue-346.pdf +0 -0
  83. docx_from_pdf-0.1/test/samples/demo-path-transformation.pdf +0 -0
  84. docx_from_pdf-0.1/test/samples/demo-section-spacing.pdf +0 -0
  85. docx_from_pdf-0.1/test/samples/demo-section.pdf +0 -0
  86. docx_from_pdf-0.1/test/samples/demo-table-align-borders.pdf +1671 -2
  87. docx_from_pdf-0.1/test/samples/demo-table-border-style.pdf +0 -0
  88. docx_from_pdf-0.1/test/samples/demo-table-bottom.pdf +0 -0
  89. docx_from_pdf-0.1/test/samples/demo-table-close-underline.pdf +0 -0
  90. docx_from_pdf-0.1/test/samples/demo-table-empty-cell.pdf +0 -0
  91. docx_from_pdf-0.1/test/samples/demo-table-lattice-one-cell.pdf +0 -0
  92. docx_from_pdf-0.1/test/samples/demo-table-lattice.pdf +0 -0
  93. docx_from_pdf-0.1/test/samples/demo-table-nested.pdf +0 -0
  94. docx_from_pdf-0.1/test/samples/demo-table-shading-highlight.pdf +0 -0
  95. docx_from_pdf-0.1/test/samples/demo-table-shading.pdf +0 -0
  96. docx_from_pdf-0.1/test/samples/demo-table-stream.pdf +197 -0
  97. docx_from_pdf-0.1/test/samples/demo-table-weird.pdf +0 -0
  98. docx_from_pdf-0.1/test/samples/demo-table.pdf +0 -0
  99. docx_from_pdf-0.1/test/samples/demo-text-alignment.pdf +0 -0
  100. docx_from_pdf-0.1/test/samples/demo-text-hidden.pdf +0 -0
  101. docx_from_pdf-0.1/test/samples/demo-text-scaling.pdf +0 -0
  102. docx_from_pdf-0.1/test/samples/demo-text-unnamed-fonts.pdf +0 -0
  103. docx_from_pdf-0.1/test/samples/demo-text-vertical.pdf +0 -0
  104. docx_from_pdf-0.1/test/samples/demo-text.pdf +0 -0
  105. docx_from_pdf-0.1/test/samples/demo-whisper_2_3.pdf +0 -0
  106. docx_from_pdf-0.1/test/samples/demo.pdf +0 -0
  107. docx_from_pdf-0.1/test/test.py +513 -0
  108. docx_from_pdf-0.1/version.txt +1 -0
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2026 Artifex Software, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,7 @@
1
+ include *.md
2
+ include LICENSE*
3
+ include requirements.txt
4
+ include version.txt
5
+ prune test
6
+ include test/*.py
7
+ include test/samples/*.pdf
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: docx-from-pdf
3
+ Version: 0.1
4
+ Summary: Python library and CLI for converting PDF files to DOCX.
5
+ Home-page: https://github.com/michael-provision/docx-from-pdf/
6
+ Author: Michael Ellis
7
+ License: MIT
8
+ Keywords: pdf-to-word,pdf-to-docx,docx-from-pdf
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: pypdfium2>=5.11.0
13
+ Requires-Dist: Pillow>=10.0.0
14
+ Requires-Dist: python-docx>=0.8.10
15
+ Requires-Dist: fonttools>=4.24.0
16
+ Requires-Dist: numpy>=1.17.2
17
+ Requires-Dist: opencv-python-headless>=4.5
18
+ Requires-Dist: fire>=0.3.0
19
+ Dynamic: author
20
+ Dynamic: description
21
+ Dynamic: description-content-type
22
+ Dynamic: home-page
23
+ Dynamic: keywords
24
+ Dynamic: license
25
+ Dynamic: license-file
26
+ Dynamic: requires-dist
27
+ Dynamic: requires-python
28
+ Dynamic: summary
29
+
30
+ # docx-from-pdf
31
+
32
+ A Python library and CLI for converting PDF files into editable DOCX documents.
33
+
34
+ `docx-from-pdf` uses PDFium for PDF parsing and rendering. It converts text,
35
+ tables, images, vector paths, and section layout into DOCX output with
36
+ permissive runtime dependencies.
37
+
38
+ This project uses PDFium through `pypdfium2`; it is not affiliated with PDFium,
39
+ Chromium, or Google.
40
+
41
+ ## Install
42
+
43
+ Install from PyPI:
44
+
45
+ ```bash
46
+ pip install docx-from-pdf
47
+ ```
48
+
49
+ Install from source:
50
+
51
+ ```bash
52
+ pip install git+https://github.com/michael-provision/docx-from-pdf.git
53
+ ```
54
+
55
+ Install local development dependencies:
56
+
57
+ ```bash
58
+ uv venv
59
+ uv pip install -r requirements.txt
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ Convert a PDF from Python:
65
+
66
+ ```python
67
+ from docx_from_pdf import Converter
68
+
69
+ converter = Converter("input.pdf")
70
+ converter.convert("output.docx")
71
+ converter.close()
72
+ ```
73
+
74
+ Convert a PDF from the CLI:
75
+
76
+ ```bash
77
+ docx-from-pdf convert input.pdf output.docx
78
+ ```
79
+
80
+ Extract tables:
81
+
82
+ ```python
83
+ from docx_from_pdf import Converter
84
+
85
+ converter = Converter("input.pdf")
86
+ tables = converter.extract_tables()
87
+ converter.close()
88
+ ```
89
+
90
+ ## Development
91
+
92
+ Run the test suite:
93
+
94
+ ```bash
95
+ PYTHONPATH=. uv run --no-project --with-requirements requirements.txt --with pytest pytest -q test/test.py
96
+ ```
97
+
98
+ Some visual-comparison tests render DOCX files through LibreOffice on non-Windows
99
+ systems, so LibreOffice must be installed for the full suite.
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,74 @@
1
+ # docx-from-pdf
2
+
3
+ A Python library and CLI for converting PDF files into editable DOCX documents.
4
+
5
+ `docx-from-pdf` uses PDFium for PDF parsing and rendering. It converts text,
6
+ tables, images, vector paths, and section layout into DOCX output with
7
+ permissive runtime dependencies.
8
+
9
+ This project uses PDFium through `pypdfium2`; it is not affiliated with PDFium,
10
+ Chromium, or Google.
11
+
12
+ ## Install
13
+
14
+ Install from PyPI:
15
+
16
+ ```bash
17
+ pip install docx-from-pdf
18
+ ```
19
+
20
+ Install from source:
21
+
22
+ ```bash
23
+ pip install git+https://github.com/michael-provision/docx-from-pdf.git
24
+ ```
25
+
26
+ Install local development dependencies:
27
+
28
+ ```bash
29
+ uv venv
30
+ uv pip install -r requirements.txt
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ Convert a PDF from Python:
36
+
37
+ ```python
38
+ from docx_from_pdf import Converter
39
+
40
+ converter = Converter("input.pdf")
41
+ converter.convert("output.docx")
42
+ converter.close()
43
+ ```
44
+
45
+ Convert a PDF from the CLI:
46
+
47
+ ```bash
48
+ docx-from-pdf convert input.pdf output.docx
49
+ ```
50
+
51
+ Extract tables:
52
+
53
+ ```python
54
+ from docx_from_pdf import Converter
55
+
56
+ converter = Converter("input.pdf")
57
+ tables = converter.extract_tables()
58
+ converter.close()
59
+ ```
60
+
61
+ ## Development
62
+
63
+ Run the test suite:
64
+
65
+ ```bash
66
+ PYTHONPATH=. uv run --no-project --with-requirements requirements.txt --with pytest pytest -q test/test.py
67
+ ```
68
+
69
+ Some visual-comparison tests render DOCX files through LibreOffice on non-Windows
70
+ systems, so LibreOffice must be installed for the full suite.
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,3 @@
1
+ from .converter import Converter
2
+ from .page.Page import Page
3
+ from .main import parse
@@ -0,0 +1 @@
1
+ """PDF backend implementations."""
@@ -0,0 +1,440 @@
1
+ """PDFium-backed document and page primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ctypes import byref, c_double, c_float, c_int, c_uint, create_string_buffer
6
+ from io import BytesIO
7
+ from math import cos, pi, sin, sqrt
8
+ from pathlib import Path
9
+
10
+ import pypdfium2 as pdfium
11
+ import pypdfium2.raw as pdfium_c
12
+
13
+ from ..common.geometry import Matrix, Rect
14
+
15
+
16
+ class PdfiumDocument:
17
+ """Document wrapper exposing the subset of behavior used by the converter."""
18
+
19
+ def __init__(self, pdf_file: str | None = None, password: str | None = None, stream: bytes | None = None):
20
+ self._document = pdfium.PdfDocument(stream if stream is not None else Path(pdf_file), password=password or None)
21
+ self.needs_pass = False
22
+
23
+ def __len__(self):
24
+ return len(self._document)
25
+
26
+ def __iter__(self):
27
+ for page_index in range(len(self)):
28
+ yield self[page_index]
29
+
30
+ def __getitem__(self, page_index: int):
31
+ return PdfiumPage(self, page_index)
32
+
33
+ def authenticate(self, password: str):
34
+ return True
35
+
36
+ def close(self):
37
+ self._document.close()
38
+
39
+
40
+ class PdfiumPage:
41
+ """Page wrapper with extraction helpers."""
42
+
43
+ def __init__(self, document: PdfiumDocument, page_index: int):
44
+ self.parent = document
45
+ self.page_index = page_index
46
+ self._page = document._document[page_index]
47
+ self._pdf_cropbox = self._page.get_cropbox()
48
+ crop_left, crop_bottom, crop_right, crop_top = self._pdf_cropbox
49
+ self.width = crop_right - crop_left
50
+ self.height = crop_top - crop_bottom
51
+ self.rect = Rect(0.0, 0.0, self.width, self.height)
52
+ self.cropbox = self.rect
53
+ self.rotation = self._page.get_rotation()
54
+ self.rotation_matrix = Matrix()
55
+
56
+ def close(self):
57
+ self._page.close()
58
+
59
+ def extract_text_blocks(self, sort=None):
60
+ text_page = self._page.get_textpage()
61
+ chars = self._extract_chars(text_page)
62
+ if sort:
63
+ chars.sort(key=lambda char: (char["bbox"][1], char["bbox"][0]))
64
+ lines = self._group_chars_into_lines(chars)
65
+ return [{"type": 0, "bbox": self._bbox_for_lines(lines), "lines": lines}] if lines else []
66
+
67
+ def extract_images(self, clip_image_res_ratio: float = 3.0):
68
+ images = []
69
+ for image_object in self._page.get_objects(filter=[pdfium_c.FPDF_PAGEOBJ_IMAGE], max_depth=3):
70
+ bbox = self._pdf_bounds_to_page_rect(image_object.get_bounds())
71
+ if bbox.get_area() <= 4:
72
+ continue
73
+ image_bytes, width, height = self.render_clip_to_png(
74
+ bbox,
75
+ zoom=clip_image_res_ratio,
76
+ rm_text=True,
77
+ rm_image=False,
78
+ )
79
+ images.append(
80
+ {
81
+ "type": 1,
82
+ "bbox": tuple(bbox),
83
+ "width": width,
84
+ "height": height,
85
+ "image": image_bytes,
86
+ }
87
+ )
88
+ return images
89
+
90
+ def extract_paths(self):
91
+ paths = []
92
+ for path_object in self._page.get_objects(filter=[pdfium_c.FPDF_PAGEOBJ_PATH], max_depth=3):
93
+ items = self._extract_path_items(path_object)
94
+ if not items:
95
+ continue
96
+ fill_mode = c_int()
97
+ stroke = c_int()
98
+ pdfium_c.FPDFPath_GetDrawMode(path_object, byref(fill_mode), byref(stroke))
99
+ path_type = ""
100
+ if fill_mode.value:
101
+ path_type += "f"
102
+ if stroke.value:
103
+ path_type += "s"
104
+ if not path_type:
105
+ continue
106
+ stroke_width = c_float()
107
+ pdfium_c.FPDFPageObj_GetStrokeWidth(path_object, byref(stroke_width))
108
+ paths.append(
109
+ {
110
+ "type": path_type,
111
+ "items": items,
112
+ "width": max(0.1, float(stroke_width.value)),
113
+ "color": self._get_object_color(path_object, stroke=True),
114
+ "fill": self._get_object_color(path_object, stroke=False),
115
+ "closePath": any(item[0] == "close" for item in items),
116
+ }
117
+ )
118
+ return paths
119
+
120
+ def render_clip_to_png(self, bbox=None, zoom: float = 3.0, rm_text: bool = False, rm_image: bool = False):
121
+ clip = Rect(bbox) if bbox is not None else self.rect
122
+ crop = (
123
+ max(0.0, clip.x0),
124
+ max(0.0, self.height - clip.y1),
125
+ max(0.0, self.width - clip.x1),
126
+ max(0.0, clip.y0),
127
+ )
128
+ deactivated_objects = self._deactivate_page_objects(rm_text=rm_text, rm_image=rm_image)
129
+ try:
130
+ bitmap = self._page.render(scale=zoom, crop=crop, rev_byteorder=True)
131
+ pil_image = bitmap.to_pil()
132
+ output = BytesIO()
133
+ pil_image.save(output, format="PNG")
134
+ return output.getvalue(), pil_image.width, pil_image.height
135
+ finally:
136
+ for page_object in deactivated_objects:
137
+ pdfium_c.FPDFPageObj_SetIsActive(page_object, 1)
138
+
139
+ def _extract_chars(self, text_page):
140
+ chars = []
141
+ for char_index in range(text_page.count_chars()):
142
+ character = self._xml_compatible_text(text_page.get_text_range(char_index, 1))
143
+ if not character:
144
+ continue
145
+ bbox = self._pdf_bounds_to_page_rect(text_page.get_charbox(char_index, loose=True))
146
+ if bbox.is_empty:
147
+ continue
148
+ origin_x = c_double()
149
+ origin_y = c_double()
150
+ pdfium_c.FPDFText_GetCharOrigin(text_page, char_index, byref(origin_x), byref(origin_y))
151
+ font_name = self._font_name(text_page, char_index)
152
+ font_size = self._effective_font_size(text_page, char_index)
153
+ chars.append(
154
+ {
155
+ "c": character,
156
+ "bbox": tuple(bbox),
157
+ "origin": (float(origin_x.value), self.height - float(origin_y.value)),
158
+ "dir": self._text_direction(text_page, char_index),
159
+ "font": font_name,
160
+ "size": font_size,
161
+ "color": self._text_color(text_page, char_index),
162
+ "flags": self._font_flags(text_page, char_index, font_name),
163
+ }
164
+ )
165
+ return chars
166
+
167
+ def _group_chars_into_lines(self, chars):
168
+ lines = []
169
+ for char in chars:
170
+ for line in reversed(lines[-8:]):
171
+ if line["_dir"] != char["dir"]:
172
+ continue
173
+ if abs(line["_center"] - self._line_center(char)) <= self._line_grouping_threshold(char):
174
+ line["_chars"].append(char)
175
+ line["_center"] = self._weighted_line_center(line["_chars"])
176
+ break
177
+ else:
178
+ lines.append({"_center": self._line_center(char), "_dir": char["dir"], "_chars": [char]})
179
+
180
+ raw_lines = []
181
+ for line in sorted(lines, key=self._line_sort_key):
182
+ line_chars = self._sort_line_chars(line["_chars"], line["_dir"])
183
+ for segment_chars in self._split_line_chars_by_gap(line_chars, line["_dir"]):
184
+ self._add_missing_spaces(segment_chars, line["_dir"])
185
+ spans = self._group_chars_into_spans(segment_chars)
186
+ raw_lines.append({"wmode": 0, "dir": line["_dir"], "bbox": self._bbox_for_spans(spans), "spans": spans})
187
+ return raw_lines
188
+
189
+ @staticmethod
190
+ def _split_line_chars_by_gap(chars, direction):
191
+ if not chars:
192
+ return []
193
+ groups = [[chars[0]]]
194
+ for char in chars[1:]:
195
+ previous_char = groups[-1][-1]
196
+ if direction == (0.0, -1.0):
197
+ gap = previous_char["bbox"][1] - char["bbox"][3]
198
+ else:
199
+ gap = char["bbox"][0] - previous_char["bbox"][2]
200
+ threshold = max(8.0, min(PdfiumPage._char_extent(previous_char), PdfiumPage._char_extent(char)) * 1.2)
201
+ if gap > threshold:
202
+ groups.append([char])
203
+ else:
204
+ groups[-1].append(char)
205
+ return groups
206
+
207
+ @staticmethod
208
+ def _add_missing_spaces(chars, direction):
209
+ for previous_char, char in zip(chars, chars[1:]):
210
+ if previous_char["c"].isspace() or char["c"].isspace():
211
+ continue
212
+ if direction == (0.0, -1.0):
213
+ gap = previous_char["bbox"][1] - char["bbox"][3]
214
+ else:
215
+ gap = char["bbox"][0] - previous_char["bbox"][2]
216
+ threshold = max(1.5, min(PdfiumPage._char_advance_extent(previous_char), PdfiumPage._char_advance_extent(char)) * 0.35)
217
+ if gap > threshold:
218
+ previous_char["c"] += " "
219
+
220
+ @staticmethod
221
+ def _group_chars_into_spans(chars):
222
+ spans = []
223
+ current = None
224
+ for char in chars:
225
+ key = (char["font"], round(char["size"], 2), char["color"], char["flags"])
226
+ if current is None or current["_key"] != key:
227
+ current = {
228
+ "_key": key,
229
+ "font": char["font"],
230
+ "size": char["size"],
231
+ "color": char["color"],
232
+ "flags": char["flags"],
233
+ "chars": [],
234
+ }
235
+ spans.append(current)
236
+ current["chars"].append({"c": char["c"], "bbox": char["bbox"], "origin": char["origin"]})
237
+ for span in spans:
238
+ span["bbox"] = PdfiumPage._bbox_for_chars(span["chars"])
239
+ del span["_key"]
240
+ return spans
241
+
242
+ def _extract_path_items(self, path_object):
243
+ matrix = path_object.get_matrix()
244
+ items = []
245
+ cursor = None
246
+ bezier_points = []
247
+ segment_count = pdfium_c.FPDFPath_CountSegments(path_object)
248
+ for segment_index in range(segment_count):
249
+ segment = pdfium_c.FPDFPath_GetPathSegment(path_object, segment_index)
250
+ segment_type = pdfium_c.FPDFPathSegment_GetType(segment)
251
+ x = c_float()
252
+ y = c_float()
253
+ pdfium_c.FPDFPathSegment_GetPoint(segment, byref(x), byref(y))
254
+ point = self._matrix_point_to_page_point(matrix, x.value, y.value)
255
+ if segment_type == pdfium_c.FPDF_SEGMENT_MOVETO:
256
+ cursor = point
257
+ bezier_points = []
258
+ elif segment_type == pdfium_c.FPDF_SEGMENT_LINETO and cursor is not None:
259
+ items.append(("l", cursor, point))
260
+ cursor = point
261
+ bezier_points = []
262
+ elif segment_type == pdfium_c.FPDF_SEGMENT_BEZIERTO and cursor is not None:
263
+ bezier_points.append(point)
264
+ if len(bezier_points) == 3:
265
+ items.append(("c", cursor, bezier_points[0], bezier_points[1], bezier_points[2]))
266
+ cursor = bezier_points[2]
267
+ bezier_points = []
268
+ if pdfium_c.FPDFPathSegment_GetClose(segment):
269
+ items.append(("close",))
270
+ return items
271
+
272
+ def _deactivate_page_objects(self, rm_text: bool, rm_image: bool):
273
+ if not rm_text and not rm_image:
274
+ return []
275
+ filters = []
276
+ if rm_text:
277
+ filters.append(pdfium_c.FPDF_PAGEOBJ_TEXT)
278
+ if rm_image:
279
+ filters.append(pdfium_c.FPDF_PAGEOBJ_IMAGE)
280
+ deactivated_objects = []
281
+ for page_object in self._page.get_objects(filter=filters, max_depth=3):
282
+ active = c_int()
283
+ pdfium_c.FPDFPageObj_GetIsActive(page_object, byref(active))
284
+ if active.value:
285
+ pdfium_c.FPDFPageObj_SetIsActive(page_object, 0)
286
+ deactivated_objects.append(page_object)
287
+ return deactivated_objects
288
+
289
+ def _matrix_point_to_page_point(self, matrix, x: float, y: float):
290
+ page_x = x * matrix.a + y * matrix.c + matrix.e
291
+ page_y = x * matrix.b + y * matrix.d + matrix.f
292
+ crop_left, _, _, crop_top = self._pdf_cropbox
293
+ return (float(page_x - crop_left), float(crop_top - page_y))
294
+
295
+ def _pdf_bounds_to_page_rect(self, bounds):
296
+ left, bottom, right, top = bounds
297
+ crop_left, _, _, crop_top = self._pdf_cropbox
298
+ return Rect(left - crop_left, crop_top - top, right - crop_left, crop_top - bottom)
299
+
300
+ @staticmethod
301
+ def _effective_font_size(text_page, char_index: int):
302
+ font_size = float(pdfium_c.FPDFText_GetFontSize(text_page, char_index) or 12.0)
303
+ matrix = pdfium_c.FS_MATRIX()
304
+ if not pdfium_c.FPDFText_GetMatrix(text_page, char_index, byref(matrix)):
305
+ return font_size
306
+ vertical_scale = sqrt(matrix.c * matrix.c + matrix.d * matrix.d)
307
+ return font_size * vertical_scale if vertical_scale else font_size
308
+
309
+ @staticmethod
310
+ def _font_name(text_page, char_index: int):
311
+ buffer = create_string_buffer(512)
312
+ flags = c_int()
313
+ length = pdfium_c.FPDFText_GetFontInfo(text_page, char_index, buffer, len(buffer), byref(flags))
314
+ return buffer.value[:length].decode("utf-8", errors="ignore").split("+")[-1] or "Arial"
315
+
316
+ @staticmethod
317
+ def _font_flags(text_page, char_index: int, font_name: str):
318
+ flags = 0
319
+ if "Italic" in font_name or "Oblique" in font_name:
320
+ flags |= 2**1
321
+ if "Bold" in font_name or pdfium_c.FPDFText_GetFontWeight(text_page, char_index) >= 600:
322
+ flags |= 2**4
323
+ return flags
324
+
325
+ @staticmethod
326
+ def _text_direction(text_page, char_index: int):
327
+ angle = float(pdfium_c.FPDFText_GetCharAngle(text_page, char_index)) % (2 * pi)
328
+ if angle <= 0.1 or abs(angle - 2 * pi) <= 0.1:
329
+ return (1.0, 0.0)
330
+ if abs(angle - 1.5 * pi) <= 0.1:
331
+ return (0.0, -1.0)
332
+ return (round(cos(angle), 3), round(sin(angle), 3))
333
+
334
+ @staticmethod
335
+ def _text_color(text_page, char_index: int):
336
+ red = c_uint()
337
+ green = c_uint()
338
+ blue = c_uint()
339
+ alpha = c_uint()
340
+ if not pdfium_c.FPDFText_GetFillColor(text_page, char_index, byref(red), byref(green), byref(blue), byref(alpha)):
341
+ return 0
342
+ return (red.value << 16) + (green.value << 8) + blue.value
343
+
344
+ @staticmethod
345
+ def _get_object_color(path_object, stroke: bool):
346
+ red = c_uint()
347
+ green = c_uint()
348
+ blue = c_uint()
349
+ alpha = c_uint()
350
+ color_function = pdfium_c.FPDFPageObj_GetStrokeColor if stroke else pdfium_c.FPDFPageObj_GetFillColor
351
+ if not color_function(path_object, byref(red), byref(green), byref(blue), byref(alpha)):
352
+ return None
353
+ return (red.value / 255.0, green.value / 255.0, blue.value / 255.0)
354
+
355
+ @staticmethod
356
+ def _xml_compatible_text(text: str):
357
+ return "".join(char for char in text if PdfiumPage._is_xml_compatible_character(char))
358
+
359
+ @staticmethod
360
+ def _is_xml_compatible_character(char: str):
361
+ codepoint = ord(char)
362
+ return (
363
+ codepoint in (0x9, 0xA, 0xD)
364
+ or 0x20 <= codepoint <= 0xD7FF
365
+ or 0xE000 <= codepoint <= 0xFFFD
366
+ or 0x10000 <= codepoint <= 0x10FFFF
367
+ )
368
+
369
+ @staticmethod
370
+ def _vertical_center(bbox):
371
+ return (bbox[1] + bbox[3]) / 2.0
372
+
373
+ @staticmethod
374
+ def _horizontal_center(bbox):
375
+ return (bbox[0] + bbox[2]) / 2.0
376
+
377
+ @staticmethod
378
+ def _line_center(char):
379
+ if char["dir"] == (0.0, -1.0):
380
+ return PdfiumPage._horizontal_center(char["bbox"])
381
+ return PdfiumPage._vertical_center(char["bbox"])
382
+
383
+ @staticmethod
384
+ def _char_extent(char):
385
+ if char["dir"] == (0.0, -1.0):
386
+ return max(1.0, char["bbox"][2] - char["bbox"][0])
387
+ return max(1.0, char["bbox"][3] - char["bbox"][1])
388
+
389
+ @staticmethod
390
+ def _char_advance_extent(char):
391
+ if char["dir"] == (0.0, -1.0):
392
+ return max(1.0, char["bbox"][3] - char["bbox"][1])
393
+ return max(1.0, char["bbox"][2] - char["bbox"][0])
394
+
395
+ @staticmethod
396
+ def _line_grouping_threshold(char):
397
+ return max(1.5, PdfiumPage._char_extent(char) * 0.35)
398
+
399
+ @staticmethod
400
+ def _weighted_line_center(chars):
401
+ if chars[0]["dir"] == (0.0, -1.0):
402
+ return sum(PdfiumPage._horizontal_center(char["bbox"]) * PdfiumPage._char_extent(char) for char in chars) / sum(
403
+ PdfiumPage._char_extent(char) for char in chars
404
+ )
405
+ return sum(PdfiumPage._vertical_center(char["bbox"]) * PdfiumPage._char_extent(char) for char in chars) / sum(
406
+ PdfiumPage._char_extent(char) for char in chars
407
+ )
408
+
409
+ @staticmethod
410
+ def _line_sort_key(line):
411
+ if line["_dir"] == (0.0, -1.0):
412
+ return (min(char["bbox"][0] for char in line["_chars"]), min(char["bbox"][1] for char in line["_chars"]))
413
+ return (line["_center"], min(char["bbox"][0] for char in line["_chars"]))
414
+
415
+ @staticmethod
416
+ def _sort_line_chars(chars, direction):
417
+ if direction == (0.0, -1.0):
418
+ return sorted(chars, key=lambda item: (-item["bbox"][1], item["bbox"][0]))
419
+ return sorted(chars, key=lambda item: item["bbox"][0])
420
+
421
+ @staticmethod
422
+ def _bbox_for_chars(chars):
423
+ bbox = Rect()
424
+ for char in chars:
425
+ bbox |= Rect(char["bbox"])
426
+ return tuple(bbox)
427
+
428
+ @staticmethod
429
+ def _bbox_for_spans(spans):
430
+ bbox = Rect()
431
+ for span in spans:
432
+ bbox |= Rect(span["bbox"])
433
+ return tuple(bbox)
434
+
435
+ @staticmethod
436
+ def _bbox_for_lines(lines):
437
+ bbox = Rect()
438
+ for line in lines:
439
+ bbox |= Rect(line["bbox"])
440
+ return tuple(bbox)