xml2table 0.1.0__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.
- xml2table/__init__.py +71 -0
- xml2table/__main__.py +4 -0
- xml2table/cli.py +186 -0
- xml2table/converter.py +168 -0
- xml2table/exceptions.py +28 -0
- xml2table/options.py +66 -0
- xml2table/parser.py +228 -0
- xml2table/pdf_converter.py +97 -0
- xml2table/pdf_extract.py +213 -0
- xml2table/pdf_options.py +54 -0
- xml2table/pdf_text_extract.py +70 -0
- xml2table/pdf_xml_writer.py +71 -0
- xml2table/py.typed +0 -0
- xml2table/writers/__init__.py +4 -0
- xml2table/writers/csv_writer.py +50 -0
- xml2table/writers/excel_writer.py +114 -0
- xml2table-0.1.0.dist-info/METADATA +353 -0
- xml2table-0.1.0.dist-info/RECORD +22 -0
- xml2table-0.1.0.dist-info/WHEEL +5 -0
- xml2table-0.1.0.dist-info/entry_points.txt +2 -0
- xml2table-0.1.0.dist-info/licenses/LICENSE +21 -0
- xml2table-0.1.0.dist-info/top_level.txt +1 -0
xml2table/parser.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Core XML -> rows flattening engine.
|
|
2
|
+
|
|
3
|
+
This module has no knowledge of CSV or Excel; it only turns an XML document
|
|
4
|
+
into a list of flat ``dict`` rows according to :class:`~xml2table.options.FlattenOptions`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import IO, Any, Dict, List, Optional, Union
|
|
13
|
+
from xml.etree import ElementTree as ET
|
|
14
|
+
|
|
15
|
+
from .exceptions import RecordPathNotFoundError, XMLParseError
|
|
16
|
+
from .options import FlattenOptions
|
|
17
|
+
|
|
18
|
+
XmlSource = Union[str, bytes, Path, IO[bytes], IO[str]]
|
|
19
|
+
|
|
20
|
+
_NAMESPACE_RE = re.compile(r"\{[^}]*\}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _strip_ns(tag: str) -> str:
|
|
24
|
+
return _NAMESPACE_RE.sub("", tag)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_root(source: XmlSource) -> ET.Element:
|
|
28
|
+
"""Parse ``source`` into a root :class:`~xml.etree.ElementTree.Element`.
|
|
29
|
+
|
|
30
|
+
``source`` may be a path (``str``/``Path``), a raw XML string/bytes, or
|
|
31
|
+
a file-like object.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
if isinstance(source, ET.Element): # already parsed
|
|
35
|
+
return source
|
|
36
|
+
if isinstance(source, Path):
|
|
37
|
+
return ET.parse(str(source)).getroot()
|
|
38
|
+
if hasattr(source, "read"):
|
|
39
|
+
return ET.parse(source).getroot()
|
|
40
|
+
if isinstance(source, (bytes, bytearray)):
|
|
41
|
+
return ET.fromstring(source)
|
|
42
|
+
if isinstance(source, str):
|
|
43
|
+
stripped = source.lstrip()
|
|
44
|
+
looks_like_markup = stripped.startswith("<")
|
|
45
|
+
if not looks_like_markup and (Path(source).exists() or len(source) < 4096):
|
|
46
|
+
# Treat as a filesystem path.
|
|
47
|
+
return ET.parse(source).getroot()
|
|
48
|
+
return ET.fromstring(source)
|
|
49
|
+
raise TypeError(f"Unsupported XML source type: {type(source)!r}")
|
|
50
|
+
except ET.ParseError as exc:
|
|
51
|
+
raise XMLParseError(f"Failed to parse XML: {exc}") from exc
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
raise XMLParseError(f"Failed to read XML source: {exc}") from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _group_children(elem: ET.Element, strip_namespaces: bool) -> "OrderedDict[str, List[ET.Element]]":
|
|
57
|
+
groups: "OrderedDict[str, List[ET.Element]]" = OrderedDict()
|
|
58
|
+
for child in list(elem):
|
|
59
|
+
tag = _strip_ns(child.tag) if strip_namespaces else child.tag
|
|
60
|
+
groups.setdefault(tag, []).append(child)
|
|
61
|
+
return groups
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cross_merge(base_rows: List[Dict[str, Any]], extra_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
65
|
+
"""Cartesian-merge two lists of partial rows into one."""
|
|
66
|
+
if not extra_rows:
|
|
67
|
+
return base_rows
|
|
68
|
+
if not base_rows:
|
|
69
|
+
return [dict(r) for r in extra_rows]
|
|
70
|
+
merged: List[Dict[str, Any]] = []
|
|
71
|
+
for base in base_rows:
|
|
72
|
+
for extra in extra_rows:
|
|
73
|
+
row = dict(base)
|
|
74
|
+
row.update(extra)
|
|
75
|
+
merged.append(row)
|
|
76
|
+
return merged
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _stringify(value: Any) -> str:
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
return value
|
|
82
|
+
if isinstance(value, dict):
|
|
83
|
+
return ", ".join(f"{k}={v}" for k, v in value.items())
|
|
84
|
+
if isinstance(value, list):
|
|
85
|
+
return " | ".join(_stringify(v) for v in value)
|
|
86
|
+
return str(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def flatten_element(elem: ET.Element, options: FlattenOptions) -> List[Dict[str, Any]]:
|
|
90
|
+
"""Flatten a single element into one or more row dicts.
|
|
91
|
+
|
|
92
|
+
A leaf element with no attributes collapses to a plain scalar string so
|
|
93
|
+
that its parent can assign it directly to a column; anything else
|
|
94
|
+
(an element with children and/or attributes) becomes a list of one or
|
|
95
|
+
more row dicts, ready to be merged into its parent's rows.
|
|
96
|
+
"""
|
|
97
|
+
attrs: Dict[str, Any] = {}
|
|
98
|
+
if options.include_attributes:
|
|
99
|
+
for name, value in elem.attrib.items():
|
|
100
|
+
key = _strip_ns(name) if options.strip_namespaces else name
|
|
101
|
+
attrs[f"{options.attribute_prefix}{key}"] = value
|
|
102
|
+
|
|
103
|
+
children = list(elem)
|
|
104
|
+
text = (elem.text or "").strip()
|
|
105
|
+
|
|
106
|
+
if not children and not attrs:
|
|
107
|
+
return text # type: ignore[return-value] # scalar leaf, merged by caller
|
|
108
|
+
|
|
109
|
+
rows: List[Dict[str, Any]] = [dict(attrs)]
|
|
110
|
+
if not children and text:
|
|
111
|
+
for row in rows:
|
|
112
|
+
row[options.text_key] = text
|
|
113
|
+
|
|
114
|
+
for tag, group in _group_children(elem, options.strip_namespaces).items():
|
|
115
|
+
if len(group) == 1:
|
|
116
|
+
result = flatten_element(group[0], options)
|
|
117
|
+
if isinstance(result, str):
|
|
118
|
+
for row in rows:
|
|
119
|
+
row[tag] = result
|
|
120
|
+
else:
|
|
121
|
+
nested = [
|
|
122
|
+
{f"{tag}{options.separator}{k}": v for k, v in nr.items()}
|
|
123
|
+
for nr in result
|
|
124
|
+
]
|
|
125
|
+
rows = _cross_merge(rows, nested)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# Repeated sibling elements: handle per array_mode.
|
|
129
|
+
child_results = [flatten_element(child, options) for child in group]
|
|
130
|
+
|
|
131
|
+
if options.array_mode == "join":
|
|
132
|
+
joined = options.join_separator.join(_stringify(r) for r in child_results)
|
|
133
|
+
for row in rows:
|
|
134
|
+
row[tag] = joined
|
|
135
|
+
|
|
136
|
+
elif options.array_mode == "index":
|
|
137
|
+
for idx, result in enumerate(child_results):
|
|
138
|
+
if isinstance(result, str):
|
|
139
|
+
for row in rows:
|
|
140
|
+
row[f"{tag}{options.separator}{idx}"] = result
|
|
141
|
+
else:
|
|
142
|
+
for nr in result:
|
|
143
|
+
for k, v in nr.items():
|
|
144
|
+
key = f"{tag}{options.separator}{idx}{options.separator}{k}"
|
|
145
|
+
for row in rows:
|
|
146
|
+
row[key] = v
|
|
147
|
+
|
|
148
|
+
elif options.array_mode == "explode":
|
|
149
|
+
exploded: List[Dict[str, Any]] = []
|
|
150
|
+
for result in child_results:
|
|
151
|
+
if isinstance(result, str):
|
|
152
|
+
exploded.append({tag: result})
|
|
153
|
+
else:
|
|
154
|
+
exploded.extend(
|
|
155
|
+
{f"{tag}{options.separator}{k}": v for k, v in nr.items()}
|
|
156
|
+
for nr in result
|
|
157
|
+
)
|
|
158
|
+
rows = _cross_merge(rows, exploded)
|
|
159
|
+
|
|
160
|
+
return rows
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve_records(root: ET.Element, options: FlattenOptions) -> List[ET.Element]:
|
|
164
|
+
if options.record_path:
|
|
165
|
+
matches = root.findall(options.record_path)
|
|
166
|
+
if not matches:
|
|
167
|
+
raise RecordPathNotFoundError(
|
|
168
|
+
f"record_path {options.record_path!r} matched no elements"
|
|
169
|
+
)
|
|
170
|
+
return matches
|
|
171
|
+
|
|
172
|
+
children = list(root)
|
|
173
|
+
if not children:
|
|
174
|
+
return [root]
|
|
175
|
+
|
|
176
|
+
groups = _group_children(root, options.strip_namespaces)
|
|
177
|
+
repeated = [(tag, elems) for tag, elems in groups.items() if len(elems) > 1]
|
|
178
|
+
|
|
179
|
+
if len(groups) == 1:
|
|
180
|
+
(_, only_group) = next(iter(groups.items()))
|
|
181
|
+
if len(only_group) > 1:
|
|
182
|
+
return only_group
|
|
183
|
+
return [root]
|
|
184
|
+
|
|
185
|
+
if len(repeated) == 1:
|
|
186
|
+
return repeated[0][1]
|
|
187
|
+
|
|
188
|
+
if len(repeated) > 1:
|
|
189
|
+
candidates = ", ".join(f"{tag!r} ({len(elems)})" for tag, elems in repeated)
|
|
190
|
+
raise RecordPathNotFoundError(
|
|
191
|
+
"Could not infer which repeated element represents a record: "
|
|
192
|
+
f"found multiple candidates under the root ({candidates}). "
|
|
193
|
+
"Pass record_path explicitly, e.g. FlattenOptions(record_path='Order')."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return [root]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def parse_to_records(source: XmlSource, options: Optional[FlattenOptions] = None) -> List[Dict[str, Any]]:
|
|
200
|
+
"""Parse an XML source into a list of flat row dicts.
|
|
201
|
+
|
|
202
|
+
This is the main entry point of the parsing layer; :class:`~xml2table.converter.XMLConverter`
|
|
203
|
+
and the module-level convenience functions build on top of it.
|
|
204
|
+
"""
|
|
205
|
+
options = options or FlattenOptions()
|
|
206
|
+
root = _load_root(source)
|
|
207
|
+
records = _resolve_records(root, options)
|
|
208
|
+
|
|
209
|
+
rows: List[Dict[str, Any]] = []
|
|
210
|
+
for record in records:
|
|
211
|
+
result = flatten_element(record, options)
|
|
212
|
+
if isinstance(result, str):
|
|
213
|
+
rows.append({options.text_key: result})
|
|
214
|
+
else:
|
|
215
|
+
rows.extend(result)
|
|
216
|
+
return rows
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def union_columns(rows: List[Dict[str, Any]]) -> List[str]:
|
|
220
|
+
"""Return column names in first-seen order across all rows."""
|
|
221
|
+
columns: List[str] = []
|
|
222
|
+
seen = set()
|
|
223
|
+
for row in rows:
|
|
224
|
+
for key in row:
|
|
225
|
+
if key not in seen:
|
|
226
|
+
seen.add(key)
|
|
227
|
+
columns.append(key)
|
|
228
|
+
return columns
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""High-level, user-facing PDF conversion API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .pdf_extract import Document, Page, PdfSource, extract_document
|
|
9
|
+
from .pdf_options import PDFOptions
|
|
10
|
+
from .pdf_text_extract import extract_text, write_text
|
|
11
|
+
from .pdf_xml_writer import document_to_xml, write_document_xml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PDFConverter:
|
|
15
|
+
"""Convert one PDF into structured pages, XML, or plain text.
|
|
16
|
+
|
|
17
|
+
``to_xml()`` / ``pages`` are backed by pdfplumber: it detects paragraphs
|
|
18
|
+
and tables and is parsed once, lazily, then cached. ``to_text()`` is
|
|
19
|
+
backed by pypdf instead, independently of that pdfplumber pass, since
|
|
20
|
+
plain text extraction doesn't need table detection and pypdf is a much
|
|
21
|
+
lighter dependency; each call re-reads the PDF (fast, since pypdf does
|
|
22
|
+
no layout analysis beyond its own text extraction).
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> converter = PDFConverter("invoice.pdf")
|
|
26
|
+
>>> converter.to_xml("invoice.xml") # structure preserved: paragraphs + tables
|
|
27
|
+
>>> converter.to_text("invoice.txt") # fast, reading-order plain text
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, source: PdfSource, options: Optional[PDFOptions] = None) -> None:
|
|
31
|
+
self._source = source
|
|
32
|
+
self.options = options or PDFOptions()
|
|
33
|
+
self._document: Optional[Document] = None
|
|
34
|
+
|
|
35
|
+
def to_document(self) -> Document:
|
|
36
|
+
"""Return the parsed :class:`~xml2table.pdf_extract.Document` (pages of paragraphs/tables)."""
|
|
37
|
+
if self._document is None:
|
|
38
|
+
self._document = extract_document(self._source, self.options)
|
|
39
|
+
return self._document
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def pages(self) -> List[Page]:
|
|
43
|
+
"""The document's pages, each with paragraphs and tables in reading order."""
|
|
44
|
+
return self.to_document().pages
|
|
45
|
+
|
|
46
|
+
def to_xml(self, destination: Optional[Union[str, Path]] = None) -> Union[str, Path]:
|
|
47
|
+
"""Render the PDF as XML, preserving paragraphs and tables.
|
|
48
|
+
|
|
49
|
+
Returns the XML string if ``destination`` is omitted, otherwise
|
|
50
|
+
writes the file and returns its path.
|
|
51
|
+
"""
|
|
52
|
+
if destination is None:
|
|
53
|
+
return document_to_xml(self.to_document(), self.options)
|
|
54
|
+
return write_document_xml(self.to_document(), destination, self.options)
|
|
55
|
+
|
|
56
|
+
def to_text(self, destination: Optional[Union[str, Path]] = None) -> Union[str, Path]:
|
|
57
|
+
"""Extract the PDF's raw text, page by page, in reading order.
|
|
58
|
+
|
|
59
|
+
Returns the text string if ``destination`` is omitted, otherwise
|
|
60
|
+
writes the file and returns its path.
|
|
61
|
+
"""
|
|
62
|
+
if destination is None:
|
|
63
|
+
return extract_text(self._source, self.options)
|
|
64
|
+
return write_text(self._source, destination, self.options)
|
|
65
|
+
|
|
66
|
+
def __len__(self) -> int:
|
|
67
|
+
return len(self.pages)
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
70
|
+
return f"PDFConverter(source={self._source!r})"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pdf_to_xml(
|
|
74
|
+
source: PdfSource,
|
|
75
|
+
destination: Optional[Union[str, Path]] = None,
|
|
76
|
+
*,
|
|
77
|
+
options: Optional[PDFOptions] = None,
|
|
78
|
+
) -> Union[str, Path]:
|
|
79
|
+
"""Convert a PDF straight to XML (paragraphs + tables, via pdfplumber).
|
|
80
|
+
|
|
81
|
+
See :class:`PDFOptions` for tuning. Requires ``xml2table[pdf]``.
|
|
82
|
+
"""
|
|
83
|
+
return PDFConverter(source, options=options).to_xml(destination)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pdf_to_text(
|
|
87
|
+
source: PdfSource,
|
|
88
|
+
destination: Optional[Union[str, Path]] = None,
|
|
89
|
+
*,
|
|
90
|
+
options: Optional[PDFOptions] = None,
|
|
91
|
+
) -> Union[str, Path]:
|
|
92
|
+
"""Convert a PDF straight to plain text (via pypdf).
|
|
93
|
+
|
|
94
|
+
See :class:`PDFOptions` for tuning. Requires ``xml2table[pdf-text]``
|
|
95
|
+
(also included in ``xml2table[pdf]``).
|
|
96
|
+
"""
|
|
97
|
+
return PDFConverter(source, options=options).to_text(destination)
|
xml2table/pdf_extract.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Structure-preserving PDF extraction.
|
|
2
|
+
|
|
3
|
+
Turns a PDF into an ordered, per-page sequence of :class:`Paragraph` and
|
|
4
|
+
:class:`Table` blocks, in the same top-to-bottom order they appear on the
|
|
5
|
+
page. This is the single source of truth that both the XML writer and the
|
|
6
|
+
text writer render from, so ``pdf_to_xml`` and ``pdf_to_text`` always agree
|
|
7
|
+
on structure.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from statistics import median
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
16
|
+
|
|
17
|
+
from .exceptions import MissingOptionalDependencyError, PDFExtractionError
|
|
18
|
+
from .pdf_options import PDFOptions
|
|
19
|
+
|
|
20
|
+
PdfSource = Union[str, Path]
|
|
21
|
+
|
|
22
|
+
Bbox = Tuple[float, float, float, float] # (x0, top, x1, bottom)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Paragraph:
|
|
27
|
+
"""A block of running text, as it appeared outside any table."""
|
|
28
|
+
|
|
29
|
+
text: str
|
|
30
|
+
bbox: Bbox
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def top(self) -> float:
|
|
34
|
+
return self.bbox[1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Table:
|
|
39
|
+
"""A grid of cells, in row-major order. Missing cells are ``None``."""
|
|
40
|
+
|
|
41
|
+
rows: List[List[Optional[str]]]
|
|
42
|
+
bbox: Bbox
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def top(self) -> float:
|
|
46
|
+
return self.bbox[1]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def n_rows(self) -> int:
|
|
50
|
+
return len(self.rows)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def n_cols(self) -> int:
|
|
54
|
+
return max((len(row) for row in self.rows), default=0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
Block = Union[Paragraph, Table]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Page:
|
|
62
|
+
"""One page's blocks, already sorted in reading order."""
|
|
63
|
+
|
|
64
|
+
number: int
|
|
65
|
+
width: float
|
|
66
|
+
height: float
|
|
67
|
+
blocks: List[Block] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def paragraphs(self) -> List[Paragraph]:
|
|
71
|
+
return [b for b in self.blocks if isinstance(b, Paragraph)]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def tables(self) -> List[Table]:
|
|
75
|
+
return [b for b in self.blocks if isinstance(b, Table)]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Document:
|
|
80
|
+
"""The full extracted document: a source name and its pages."""
|
|
81
|
+
|
|
82
|
+
source: str
|
|
83
|
+
pages: List[Page] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _require_pdfplumber():
|
|
87
|
+
try:
|
|
88
|
+
import pdfplumber
|
|
89
|
+
except ImportError as exc: # pragma: no cover - exercised only without pdfplumber
|
|
90
|
+
raise MissingOptionalDependencyError(
|
|
91
|
+
"pdfplumber is required for PDF extraction; install it with "
|
|
92
|
+
"'pip install xml2table[pdf]'"
|
|
93
|
+
) from exc
|
|
94
|
+
return pdfplumber
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _point_in_bbox(x: float, y: float, bbox: Bbox, tolerance: float = 0.5) -> bool:
|
|
98
|
+
x0, top, x1, bottom = bbox
|
|
99
|
+
return (x0 - tolerance) <= x <= (x1 + tolerance) and (top - tolerance) <= y <= (bottom + tolerance)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_tables(page, options: PDFOptions) -> List[Table]:
|
|
103
|
+
found = page.find_tables(table_settings=options.table_settings or {})
|
|
104
|
+
tables: List[Table] = []
|
|
105
|
+
for table in found:
|
|
106
|
+
rows = table.extract()
|
|
107
|
+
if not rows or not any(any(cell for cell in row) for row in rows):
|
|
108
|
+
continue
|
|
109
|
+
tables.append(Table(rows=rows, bbox=tuple(table.bbox)))
|
|
110
|
+
return tables
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _group_words_into_lines(words: List[Dict[str, Any]], line_tolerance: float) -> List[List[Dict[str, Any]]]:
|
|
114
|
+
lines: List[List[Dict[str, Any]]] = []
|
|
115
|
+
for word in sorted(words, key=lambda w: (w["top"], w["x0"])):
|
|
116
|
+
placed = False
|
|
117
|
+
for line in lines:
|
|
118
|
+
if abs(line[0]["top"] - word["top"]) <= line_tolerance:
|
|
119
|
+
line.append(word)
|
|
120
|
+
placed = True
|
|
121
|
+
break
|
|
122
|
+
if not placed:
|
|
123
|
+
lines.append([word])
|
|
124
|
+
lines.sort(key=lambda line: min(w["top"] for w in line))
|
|
125
|
+
for line in lines:
|
|
126
|
+
line.sort(key=lambda w: w["x0"])
|
|
127
|
+
return lines
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _line_bbox(line: List[Dict[str, Any]]) -> Bbox:
|
|
131
|
+
return (
|
|
132
|
+
min(w["x0"] for w in line),
|
|
133
|
+
min(w["top"] for w in line),
|
|
134
|
+
max(w["x1"] for w in line),
|
|
135
|
+
max(w["bottom"] for w in line),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _group_lines_into_paragraphs(
|
|
140
|
+
lines: List[List[Dict[str, Any]]], paragraph_gap: float
|
|
141
|
+
) -> List[Paragraph]:
|
|
142
|
+
if not lines:
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
line_infos = [(line, _line_bbox(line)) for line in lines]
|
|
146
|
+
heights = [bbox[3] - bbox[1] for _, bbox in line_infos]
|
|
147
|
+
typical_height = median(heights) if heights else 10.0
|
|
148
|
+
gap_threshold = max(paragraph_gap, typical_height * 0.5)
|
|
149
|
+
|
|
150
|
+
paragraphs: List[Paragraph] = []
|
|
151
|
+
current_lines: List[List[Dict[str, Any]]] = [line_infos[0][0]]
|
|
152
|
+
current_bbox = line_infos[0][1]
|
|
153
|
+
|
|
154
|
+
for (line, bbox) in line_infos[1:]:
|
|
155
|
+
gap = bbox[1] - current_bbox[3]
|
|
156
|
+
if gap > gap_threshold:
|
|
157
|
+
paragraphs.append(_finalize_paragraph(current_lines, current_bbox))
|
|
158
|
+
current_lines = [line]
|
|
159
|
+
current_bbox = bbox
|
|
160
|
+
else:
|
|
161
|
+
current_lines.append(line)
|
|
162
|
+
current_bbox = (
|
|
163
|
+
min(current_bbox[0], bbox[0]),
|
|
164
|
+
current_bbox[1],
|
|
165
|
+
max(current_bbox[2], bbox[2]),
|
|
166
|
+
bbox[3],
|
|
167
|
+
)
|
|
168
|
+
paragraphs.append(_finalize_paragraph(current_lines, current_bbox))
|
|
169
|
+
return paragraphs
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _finalize_paragraph(lines: List[List[Dict[str, Any]]], bbox: Bbox) -> Paragraph:
|
|
173
|
+
text = "\n".join(" ".join(w["text"] for w in line) for line in lines)
|
|
174
|
+
return Paragraph(text=text, bbox=bbox)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_page(page, options: PDFOptions) -> Page:
|
|
178
|
+
tables = _extract_tables(page, options)
|
|
179
|
+
|
|
180
|
+
words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
|
|
181
|
+
remaining_words = [
|
|
182
|
+
w
|
|
183
|
+
for w in words
|
|
184
|
+
if not any(
|
|
185
|
+
_point_in_bbox((w["x0"] + w["x1"]) / 2, (w["top"] + w["bottom"]) / 2, t.bbox)
|
|
186
|
+
for t in tables
|
|
187
|
+
)
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
lines = _group_words_into_lines(remaining_words, options.line_tolerance)
|
|
191
|
+
paragraphs = _group_lines_into_paragraphs(lines, options.paragraph_gap)
|
|
192
|
+
|
|
193
|
+
blocks: List[Block] = [*paragraphs, *tables]
|
|
194
|
+
blocks.sort(key=lambda b: b.top)
|
|
195
|
+
|
|
196
|
+
return Page(number=page.page_number, width=float(page.width), height=float(page.height), blocks=blocks)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def extract_document(source: PdfSource, options: Optional[PDFOptions] = None) -> Document:
|
|
200
|
+
"""Parse a PDF file into a :class:`Document` of ordered pages and blocks."""
|
|
201
|
+
pdfplumber = _require_pdfplumber()
|
|
202
|
+
options = options or PDFOptions()
|
|
203
|
+
|
|
204
|
+
path = Path(source)
|
|
205
|
+
try:
|
|
206
|
+
with pdfplumber.open(str(path)) as pdf:
|
|
207
|
+
pages = [_extract_page(page, options) for page in pdf.pages]
|
|
208
|
+
except MissingOptionalDependencyError:
|
|
209
|
+
raise
|
|
210
|
+
except Exception as exc: # pragma: no cover - depends on pdfplumber's own error types
|
|
211
|
+
raise PDFExtractionError(f"Failed to read PDF {path}: {exc}") from exc
|
|
212
|
+
|
|
213
|
+
return Document(source=str(path), pages=pages)
|
xml2table/pdf_options.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Configuration for PDF extraction.
|
|
2
|
+
|
|
3
|
+
``pdf_to_xml`` (pdfplumber-backed) and ``pdf_to_text`` (pypdf-backed) share
|
|
4
|
+
one options object, but each only reads the fields relevant to it: the
|
|
5
|
+
``line_tolerance``/``paragraph_gap``/``table_settings``/``cell_na`` group
|
|
6
|
+
tunes XML's paragraph and table detection, while ``page_separator`` and
|
|
7
|
+
``keep_layout`` tune text output.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class PDFOptions:
|
|
18
|
+
"""Controls PDF -> XML structure detection and PDF -> text rendering.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
line_tolerance: XML only. Maximum vertical gap (in PDF points)
|
|
22
|
+
between two words for them to be considered part of the same
|
|
23
|
+
visual line.
|
|
24
|
+
paragraph_gap: XML only. Minimum vertical gap (in PDF points)
|
|
25
|
+
between two lines for them to be considered separate
|
|
26
|
+
paragraphs.
|
|
27
|
+
table_settings: XML only. Passed straight through to pdfplumber's
|
|
28
|
+
``Page.find_tables()``; use it to tune table detection for
|
|
29
|
+
unusual documents (e.g. ``{"vertical_strategy": "text"}`` for
|
|
30
|
+
tables with no ruling lines). Caution: this is applied to the
|
|
31
|
+
whole page, not just the table region. Benchmarked across 1,200
|
|
32
|
+
synthetic financial-report PDFs (see benchmarks/RESULTS.md),
|
|
33
|
+
a text-based strategy recovered borderless tables but also
|
|
34
|
+
misread ordinary paragraph prose elsewhere on the same page as
|
|
35
|
+
table cells in 97.9% of documents, corrupting paragraph output.
|
|
36
|
+
Only override this when the *entire page* is tabular with no
|
|
37
|
+
surrounding narrative text; the line-based default is safe on
|
|
38
|
+
mixed-content pages (it simply misses unruled tables rather
|
|
39
|
+
than corrupting anything).
|
|
40
|
+
cell_na: XML only. String used for empty/``None`` table cells.
|
|
41
|
+
page_separator: Text only. Inserted between pages in text output;
|
|
42
|
+
``{page}`` is replaced with the 1-based page number.
|
|
43
|
+
keep_layout: Text only. Whether pypdf preserves the PDF's visual
|
|
44
|
+
whitespace layout (``extraction_mode="layout"``), which keeps
|
|
45
|
+
columns/tables roughly aligned, versus plain reading-order text
|
|
46
|
+
with normalized whitespace (``extraction_mode="plain"``).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
line_tolerance: float = 3.0
|
|
50
|
+
paragraph_gap: float = 6.0
|
|
51
|
+
table_settings: Optional[Dict[str, Any]] = None
|
|
52
|
+
cell_na: str = ""
|
|
53
|
+
page_separator: str = "\n----- Page {page} -----\n"
|
|
54
|
+
keep_layout: bool = True
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Raw text extraction from PDF, backed by pypdf.
|
|
2
|
+
|
|
3
|
+
This is intentionally independent of :mod:`xml2table.pdf_extract` (the
|
|
4
|
+
pdfplumber-backed paragraph/table detector used for XML output): pypdf is a
|
|
5
|
+
much lighter dependency, so plain ``pdf_to_text`` doesn't need to pull in
|
|
6
|
+
pdfplumber/Pillow/pypdfium2 just to get a document's text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional, Union
|
|
13
|
+
|
|
14
|
+
from .exceptions import MissingOptionalDependencyError, PDFExtractionError
|
|
15
|
+
from .pdf_options import PDFOptions
|
|
16
|
+
|
|
17
|
+
PdfSource = Union[str, Path]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _require_pypdf():
|
|
21
|
+
try:
|
|
22
|
+
import pypdf
|
|
23
|
+
except ImportError as exc: # pragma: no cover - exercised only without pypdf
|
|
24
|
+
raise MissingOptionalDependencyError(
|
|
25
|
+
"pypdf is required for PDF text extraction; install it with "
|
|
26
|
+
"'pip install xml2table[pdf-text]' (or 'xml2table[pdf]' for XML support too)"
|
|
27
|
+
) from exc
|
|
28
|
+
return pypdf
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _extract_page_texts(source: PdfSource, options: PDFOptions) -> List[str]:
|
|
32
|
+
pypdf = _require_pypdf()
|
|
33
|
+
path = Path(source)
|
|
34
|
+
mode = "layout" if options.keep_layout else "plain"
|
|
35
|
+
try:
|
|
36
|
+
reader = pypdf.PdfReader(str(path))
|
|
37
|
+
return [(page.extract_text(extraction_mode=mode) or "").strip("\n") for page in reader.pages]
|
|
38
|
+
except MissingOptionalDependencyError:
|
|
39
|
+
raise
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
raise PDFExtractionError(f"Failed to read PDF {path}: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def extract_text(source: PdfSource, options: Optional[PDFOptions] = None) -> str:
|
|
45
|
+
"""Extract a PDF's text, page by page, in reading order.
|
|
46
|
+
|
|
47
|
+
With ``options.keep_layout`` (the default), pypdf's ``"layout"``
|
|
48
|
+
extraction mode is used, which preserves the PDF's whitespace so
|
|
49
|
+
columns and simple tables stay visually aligned. Set it to ``False``
|
|
50
|
+
for plain, whitespace-normalized text instead.
|
|
51
|
+
"""
|
|
52
|
+
options = options or PDFOptions()
|
|
53
|
+
page_texts = _extract_page_texts(source, options)
|
|
54
|
+
|
|
55
|
+
output = ""
|
|
56
|
+
for number, text in enumerate(page_texts, start=1):
|
|
57
|
+
if number > 1:
|
|
58
|
+
output += options.page_separator.format(page=number)
|
|
59
|
+
output += text
|
|
60
|
+
if not output.endswith("\n"):
|
|
61
|
+
output += "\n"
|
|
62
|
+
return output
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write_text(source: PdfSource, destination: Union[str, Path], options: Optional[PDFOptions] = None) -> Path:
|
|
66
|
+
"""Extract a PDF's text and write it to ``destination``, returning the path."""
|
|
67
|
+
destination = Path(destination)
|
|
68
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
destination.write_text(extract_text(source, options), encoding="utf-8")
|
|
70
|
+
return destination
|