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/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)
@@ -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)
@@ -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