xml2table 0.1.0__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 (35) hide show
  1. xml2table-0.1.0/LICENSE +21 -0
  2. xml2table-0.1.0/PKG-INFO +353 -0
  3. xml2table-0.1.0/README.md +310 -0
  4. xml2table-0.1.0/pyproject.toml +58 -0
  5. xml2table-0.1.0/setup.cfg +4 -0
  6. xml2table-0.1.0/src/xml2table/__init__.py +71 -0
  7. xml2table-0.1.0/src/xml2table/__main__.py +4 -0
  8. xml2table-0.1.0/src/xml2table/cli.py +186 -0
  9. xml2table-0.1.0/src/xml2table/converter.py +168 -0
  10. xml2table-0.1.0/src/xml2table/exceptions.py +28 -0
  11. xml2table-0.1.0/src/xml2table/options.py +66 -0
  12. xml2table-0.1.0/src/xml2table/parser.py +228 -0
  13. xml2table-0.1.0/src/xml2table/pdf_converter.py +97 -0
  14. xml2table-0.1.0/src/xml2table/pdf_extract.py +213 -0
  15. xml2table-0.1.0/src/xml2table/pdf_options.py +54 -0
  16. xml2table-0.1.0/src/xml2table/pdf_text_extract.py +70 -0
  17. xml2table-0.1.0/src/xml2table/pdf_xml_writer.py +71 -0
  18. xml2table-0.1.0/src/xml2table/py.typed +0 -0
  19. xml2table-0.1.0/src/xml2table/writers/__init__.py +4 -0
  20. xml2table-0.1.0/src/xml2table/writers/csv_writer.py +50 -0
  21. xml2table-0.1.0/src/xml2table/writers/excel_writer.py +114 -0
  22. xml2table-0.1.0/src/xml2table.egg-info/PKG-INFO +353 -0
  23. xml2table-0.1.0/src/xml2table.egg-info/SOURCES.txt +33 -0
  24. xml2table-0.1.0/src/xml2table.egg-info/dependency_links.txt +1 -0
  25. xml2table-0.1.0/src/xml2table.egg-info/entry_points.txt +2 -0
  26. xml2table-0.1.0/src/xml2table.egg-info/requires.txt +21 -0
  27. xml2table-0.1.0/src/xml2table.egg-info/top_level.txt +1 -0
  28. xml2table-0.1.0/tests/test_cli.py +92 -0
  29. xml2table-0.1.0/tests/test_converter.py +64 -0
  30. xml2table-0.1.0/tests/test_parser.py +145 -0
  31. xml2table-0.1.0/tests/test_pdf_converter.py +56 -0
  32. xml2table-0.1.0/tests/test_pdf_extract.py +77 -0
  33. xml2table-0.1.0/tests/test_pdf_text_extract.py +65 -0
  34. xml2table-0.1.0/tests/test_pdf_xml_writer.py +67 -0
  35. xml2table-0.1.0/tests/test_writers.py +56 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,353 @@
1
+ Metadata-Version: 2.4
2
+ Name: xml2table
3
+ Version: 0.1.0
4
+ Summary: Convert XML and PDF documents to CSV, Excel, XML, and text with a small, predictable SDK.
5
+ Author-email: Meet2147 <meetjethwa3@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Meet2147/pythonLibraries/tree/main/xml2table
8
+ Project-URL: Repository, https://github.com/Meet2147/pythonLibraries
9
+ Project-URL: Issues, https://github.com/Meet2147/pythonLibraries/issues
10
+ Keywords: xml,pdf,csv,excel,xlsx,convert,flatten,etl
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: Text Processing :: Markup :: XML
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: openpyxl>=3.1
27
+ Provides-Extra: pandas
28
+ Requires-Dist: pandas>=1.3; extra == "pandas"
29
+ Provides-Extra: lxml
30
+ Requires-Dist: lxml>=4.9; extra == "lxml"
31
+ Provides-Extra: pdf-text
32
+ Requires-Dist: pypdf>=4.0; extra == "pdf-text"
33
+ Provides-Extra: pdf
34
+ Requires-Dist: pdfplumber>=0.10; extra == "pdf"
35
+ Requires-Dist: pypdf>=4.0; extra == "pdf"
36
+ Provides-Extra: dev
37
+ Requires-Dist: pytest>=7.0; extra == "dev"
38
+ Requires-Dist: pandas>=1.3; extra == "dev"
39
+ Requires-Dist: pdfplumber>=0.10; extra == "dev"
40
+ Requires-Dist: pypdf>=4.0; extra == "dev"
41
+ Requires-Dist: fpdf2>=2.7; extra == "dev"
42
+ Dynamic: license-file
43
+
44
+ # xml2table
45
+
46
+ A small, predictable Python SDK for converting XML and PDF documents to
47
+ CSV, Excel, XML, and plain text.
48
+
49
+ - **XML → CSV / Excel.** One core recursive flattening function turns
50
+ nested XML into flat rows; CSV and Excel are thin writers on top of it.
51
+ No data loss by default: repeated elements can be joined into one cell,
52
+ exploded into extra rows, or spread across indexed columns — you choose.
53
+ - **PDF → XML.** A structure-preserving extractor ([`pdfplumber`](https://github.com/jsvine/pdfplumber)-backed)
54
+ groups a PDF's words into paragraphs and detects tables, keeping both in
55
+ their original top-to-bottom reading order — no data lost, no tables
56
+ flattened into loose words.
57
+ - **PDF → text.** A fast, lightweight raw-text extractor
58
+ ([`pypdf`](https://github.com/py-pdf/pypdf)-backed) that doesn't need
59
+ table detection at all — just the PDF's text, in reading order, with its
60
+ visual whitespace layout preserved by default.
61
+ - **Small dependency footprint.** Only [`openpyxl`](https://openpyxl.readthedocs.io/)
62
+ is required for the XML-to-table side. `pandas` (DataFrames), `pypdf`
63
+ (PDF text), and `pdfplumber` (PDF XML/tables) are all optional extras —
64
+ importing `xml2table` never requires any of them.
65
+ - **Three ways in, for each conversion:** one-line functions, a reusable
66
+ converter object, or the `xml2table` CLI.
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ pip install -e . # from a checkout: XML -> CSV/Excel only
72
+ pip install -e ".[pandas]" # + optional DataFrame support
73
+ pip install -e ".[pdf-text]" # + PDF -> text (pypdf only, lightweight)
74
+ pip install -e ".[pdf]" # + PDF -> XML and text (pdfplumber + pypdf)
75
+ ```
76
+
77
+ ## Quick start
78
+
79
+ ```python
80
+ from xml2table import xml_to_csv, xml_to_excel
81
+
82
+ xml_to_csv("orders.xml", "orders.csv")
83
+ xml_to_excel("orders.xml", "orders.xlsx")
84
+ ```
85
+
86
+ Given:
87
+
88
+ ```xml
89
+ <orders>
90
+ <order id="1">
91
+ <customer><name>Jane Doe</name></customer>
92
+ <total>99.99</total>
93
+ </order>
94
+ </orders>
95
+ ```
96
+
97
+ you get one row per `<order>`:
98
+
99
+ | @id | customer.name | total |
100
+ |-----|----------------|-------|
101
+ | 1 | Jane Doe | 99.99 |
102
+
103
+ Nested elements are flattened with `.`-separated keys, attributes get an
104
+ `@` prefix, and the record element (`order` here) is auto-detected as "the
105
+ repeated child of the root". When detection is ambiguous, pass
106
+ `record_path` explicitly.
107
+
108
+ ## Reusable converter
109
+
110
+ Parse once, write many times:
111
+
112
+ ```python
113
+ from xml2table import XMLConverter, FlattenOptions
114
+
115
+ converter = XMLConverter("orders.xml", options=FlattenOptions(record_path="Order"))
116
+ converter.to_csv("orders.csv")
117
+ converter.to_excel("orders.xlsx", sheet_name="Orders")
118
+ rows = converter.to_records() # list[dict]
119
+ df = converter.to_dataframe() # requires pandas
120
+ ```
121
+
122
+ ## Handling repeated elements (`array_mode`)
123
+
124
+ Given an order with two line items, `FlattenOptions.array_mode` controls the
125
+ shape of the output:
126
+
127
+ | mode | Result | Use when |
128
+ |------|--------|----------|
129
+ | `"join"` (default) | One row per order; items collapsed into one joined cell | You just want a quick, human-readable table |
130
+ | `"explode"` | One row per item (order fields repeat) | You want a normalized, analysis-ready table, like a SQL join |
131
+ | `"index"` | One row per order; items spread into `items.item.0.*`, `items.item.1.*`, ... | You need every field as its own column with no row duplication |
132
+
133
+ ```python
134
+ from xml2table import FlattenOptions, xml_to_records
135
+
136
+ xml_to_records("orders.xml", options=FlattenOptions(array_mode="explode"))
137
+ ```
138
+
139
+ ## Multi-sheet Excel from one document
140
+
141
+ Turn different parts of the same XML document into separate, related sheets
142
+ (e.g. an "orders" table and an "items" table):
143
+
144
+ ```python
145
+ from xml2table import xml_to_excel
146
+
147
+ xml_to_excel(
148
+ "orders.xml", "orders.xlsx",
149
+ sheets={"orders": "Order", "items": ".//Order/Items/Item"},
150
+ )
151
+ ```
152
+
153
+ ## Record paths
154
+
155
+ `record_path` uses the same syntax as
156
+ [`Element.findall`](https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.Element.findall)
157
+ (a subset of XPath): `"Order"`, `"Orders/Order"`, `".//Item"`,
158
+ `"Order[@status='shipped']"`, etc.
159
+
160
+ ## CLI (XML)
161
+
162
+ ```bash
163
+ xml2table csv orders.xml orders.csv --record-path Order --array-mode explode
164
+ xml2table excel orders.xml orders.xlsx --sheet-name Orders
165
+ xml2table excel orders.xml orders.xlsx --sheet orders=Order --sheet items=".//Item"
166
+ ```
167
+
168
+ Run `xml2table csv --help` or `xml2table excel --help` for all flags
169
+ (`--separator`, `--attribute-prefix`, `--no-attributes`, `--keep-namespaces`,
170
+ `--delimiter`, `--encoding`, ...).
171
+
172
+ ## PDF → XML / text
173
+
174
+ `pdf_to_xml` and `pdf_to_text` are two independent, purpose-built backends
175
+ behind one options object and one CLI command:
176
+
177
+ - **`pdf_to_xml`** (pdfplumber) groups the PDF's words into paragraphs (by
178
+ line, then by vertical gap) and detects tables separately via ruling
179
+ lines or text alignment, then places both back in the order they appear
180
+ on the page. Nothing is dropped, and table text never bleeds into
181
+ surrounding paragraphs.
182
+ - **`pdf_to_text`** (pypdf) is a much lighter path: it just extracts each
183
+ page's text in reading order, preserving the PDF's visual whitespace
184
+ layout by default (so simple tables and columns still read naturally)
185
+ without doing any table detection.
186
+
187
+ ```python
188
+ from xml2table import pdf_to_xml, pdf_to_text
189
+
190
+ pdf_to_xml("invoice.pdf", "invoice.xml") # paragraphs + tables (needs xml2table[pdf])
191
+ pdf_to_text("invoice.pdf", "invoice.txt") # fast raw text (needs xml2table[pdf-text])
192
+ ```
193
+
194
+ `invoice.xml` looks like:
195
+
196
+ ```xml
197
+ <document source="invoice.pdf" pages="1">
198
+ <page number="1" width="595.28" height="841.89">
199
+ <paragraph bbox="42.83,43.13,159.90,61.13">Invoice #1024</paragraph>
200
+ <table bbox="40.00,220.00,540.00,316.00" rows="4" cols="3">
201
+ <row><cell>Item</cell><cell>Qty</cell><cell>Price</cell></row>
202
+ <row><cell>Widget</cell><cell>2</cell><cell>$10.00</cell></row>
203
+ ...
204
+ </table>
205
+ </page>
206
+ </document>
207
+ ```
208
+
209
+ `invoice.txt` is pypdf's layout-preserving text, e.g.:
210
+
211
+ ```
212
+ Invoice #1024
213
+
214
+ Bill To: Jane Doe
215
+ 123 Example Street
216
+ Springfield, USA
217
+
218
+ Thank you for your business. Payment is due within thirty days...
219
+
220
+ Item Qty Price
221
+ Widget 2 $10.00
222
+ ...
223
+ ```
224
+
225
+ Reuse one `PDFConverter` for both (it lazily parses with pdfplumber only if
226
+ you call `to_xml()`/`pages`, and always uses pypdf for `to_text()`):
227
+
228
+ ```python
229
+ from xml2table import PDFConverter
230
+
231
+ converter = PDFConverter("invoice.pdf")
232
+ converter.to_xml("invoice.xml")
233
+ converter.to_text("invoice.txt")
234
+ for page in converter.pages:
235
+ print(page.number, len(page.paragraphs), len(page.tables))
236
+ ```
237
+
238
+ ### `PDFOptions` reference
239
+
240
+ | Option | Default | Used by | Description |
241
+ |--------|---------|---------|--------------|
242
+ | `line_tolerance` | `3.0` | XML | Max vertical gap (points) for words to count as the same line |
243
+ | `paragraph_gap` | `6.0` | XML | Min vertical gap (points) between lines that starts a new paragraph |
244
+ | `table_settings` | `None` | XML | Passed through to pdfplumber's `find_tables()` for unusual tables (e.g. borderless). **Caution:** this applies to the whole page, not just the table — see [Validation](#validation-tested-on-1200-financial-report-pdfs) below before using it on documents with narrative text. |
245
+ | `cell_na` | `""` | XML | String used for empty/missing table cells |
246
+ | `page_separator` | `"\n----- Page {page} -----\n"` | text | Inserted between pages |
247
+ | `keep_layout` | `True` | text | Preserve the PDF's whitespace layout (pypdf `"layout"` mode) vs. plain, whitespace-normalized text |
248
+
249
+ ### CLI (PDF)
250
+
251
+ ```bash
252
+ xml2table pdf invoice.pdf invoice.xml --to xml
253
+ xml2table pdf invoice.pdf invoice.txt --to text
254
+ ```
255
+
256
+ Run `xml2table pdf --help` for all flags (`--line-tolerance`,
257
+ `--paragraph-gap`, `--cell-na`, `--page-separator`, `--no-layout`).
258
+
259
+ ## Validation: tested on 1,200 financial-report PDFs
260
+
261
+ `pdf_to_xml` and `pdf_to_text` were benchmarked against 1,200 generated
262
+ financial-report PDFs (balance sheets, income statements, cash-flow
263
+ statements, MD&A-style narrative text, footnotes — real financial
264
+ formatting: `$1,234,567`, `(123,456)` negatives, `N/A` blanks, multi-page,
265
+ ruled and borderless tables) with **exact ground truth** for every paragraph
266
+ and table cell, so the numbers below are measured, not estimated. Full
267
+ methodology, the generator, and raw per-document results are in
268
+ [`benchmarks/RESULTS.md`](benchmarks/RESULTS.md).
269
+
270
+ | Metric | Result |
271
+ |---|---|
272
+ | Documents converted without error | **1,200 / 1,200 (100%)** |
273
+ | Paragraph text fidelity (XML) | **100.000%** |
274
+ | Ruled-table shape + cell fidelity | **100.000%** |
275
+ | Borderless-table shape detection | 0.000% (documented limitation — see below) |
276
+ | Raw-text content recall (`pdf_to_text`) | **100.000%**, including for borderless tables |
277
+ | Throughput | 20.4 PDFs/sec (`pdf_to_xml`), 184.6 PDFs/sec (`pdf_to_text`) |
278
+
279
+ **The one real limitation, quantified:** pdfplumber's default table finder
280
+ needs ruling lines, so it doesn't detect borderless (text-only-aligned)
281
+ tables — but nothing is lost when it doesn't: the un-detected table's text
282
+ still comes through as ordinary paragraph text (`pdf_to_text` recall stays
283
+ at 100%). We also tested the obvious "fix" — pdfplumber's `table_settings`
284
+ override for borderless tables — across all 1,200 documents, and it made
285
+ things *worse*: because the override applies to the whole page, it started
286
+ misreading ordinary paragraph sentences as table cells, corrupting
287
+ paragraph output in **97.9% of documents** (paragraph fidelity dropped from
288
+ 100% to 13.9%). We did not ship that as a recommended workaround; see
289
+ `PDFOptions.table_settings`'s docstring and `benchmarks/RESULTS.md` for the
290
+ full numbers and why.
291
+
292
+ We could not download real financial filings for this test — this sandboxed
293
+ session's network policy blocks direct access to sites like sec.gov — so
294
+ the corpus is synthetic but built to real financial-statement conventions
295
+ specifically so every value has a known-correct answer to grade against.
296
+ `benchmarks/RESULTS.md` explains this in more detail and gives the exact
297
+ commands to reproduce or extend the benchmark (e.g. against real filings, on
298
+ a machine with broader network access).
299
+
300
+ ## `FlattenOptions` reference
301
+
302
+ | Option | Default | Description |
303
+ |--------|---------|--------------|
304
+ | `record_path` | `None` (auto-detect) | Path to the repeated record element |
305
+ | `attribute_prefix` | `"@"` | Prefix for attribute-derived columns |
306
+ | `text_key` | `"#text"` | Key for an element's own text when it also has attributes/children |
307
+ | `separator` | `"."` | Separator for nested key paths |
308
+ | `array_mode` | `"join"` | `"join"`, `"explode"`, or `"index"` |
309
+ | `join_separator` | `"; "` | Separator used by `"join"` mode |
310
+ | `include_attributes` | `True` | Include XML attributes as columns |
311
+ | `strip_namespaces` | `True` | Strip `{namespace}` from tag/attribute names |
312
+ | `encoding` | `"utf-8"` | Text encoding for reads/writes |
313
+
314
+ ## Errors
315
+
316
+ All exceptions inherit from `xml2table.XMLConversionError`:
317
+
318
+ - `XMLParseError` — malformed XML input
319
+ - `RecordPathNotFoundError` — `record_path` matched nothing, or automatic
320
+ record detection was ambiguous (the error message tells you what to pass)
321
+ - `PDFExtractionError` — a PDF file could not be opened or parsed
322
+ - `MissingOptionalDependencyError` — e.g. calling `to_dataframe()` without
323
+ `pandas`, `pdf_to_xml()` without `pdfplumber`, or `pdf_to_text()` without
324
+ `pypdf`, installed
325
+
326
+ ## Development
327
+
328
+ ```bash
329
+ pip install -e ".[dev]" # includes pandas, pdfplumber, pypdf, and fpdf2 (for regenerating PDF fixtures)
330
+ pytest
331
+ python examples/quickstart.py
332
+ ```
333
+
334
+ Project layout:
335
+
336
+ ```
337
+ src/xml2table/
338
+ parser.py # XML -> list[dict] flattening engine
339
+ options.py # FlattenOptions
340
+ converter.py # XMLConverter + module-level convenience functions
341
+ writers/ # CSV and Excel output
342
+ pdf_extract.py # pdfplumber: PDF -> Document(pages of Paragraph/Table), in reading order
343
+ pdf_xml_writer.py # Document -> XML
344
+ pdf_text_extract.py # pypdf: PDF -> plain text, independent of pdf_extract.py
345
+ pdf_options.py # PDFOptions
346
+ pdf_converter.py # PDFConverter + pdf_to_xml/pdf_to_text
347
+ cli.py # `xml2table` command-line tool
348
+ tests/
349
+ fixtures/ # sample XML documents and PDF fixtures (fixtures/pdf/)
350
+ test_*.py
351
+ examples/
352
+ quickstart.py
353
+ ```
@@ -0,0 +1,310 @@
1
+ # xml2table
2
+
3
+ A small, predictable Python SDK for converting XML and PDF documents to
4
+ CSV, Excel, XML, and plain text.
5
+
6
+ - **XML → CSV / Excel.** One core recursive flattening function turns
7
+ nested XML into flat rows; CSV and Excel are thin writers on top of it.
8
+ No data loss by default: repeated elements can be joined into one cell,
9
+ exploded into extra rows, or spread across indexed columns — you choose.
10
+ - **PDF → XML.** A structure-preserving extractor ([`pdfplumber`](https://github.com/jsvine/pdfplumber)-backed)
11
+ groups a PDF's words into paragraphs and detects tables, keeping both in
12
+ their original top-to-bottom reading order — no data lost, no tables
13
+ flattened into loose words.
14
+ - **PDF → text.** A fast, lightweight raw-text extractor
15
+ ([`pypdf`](https://github.com/py-pdf/pypdf)-backed) that doesn't need
16
+ table detection at all — just the PDF's text, in reading order, with its
17
+ visual whitespace layout preserved by default.
18
+ - **Small dependency footprint.** Only [`openpyxl`](https://openpyxl.readthedocs.io/)
19
+ is required for the XML-to-table side. `pandas` (DataFrames), `pypdf`
20
+ (PDF text), and `pdfplumber` (PDF XML/tables) are all optional extras —
21
+ importing `xml2table` never requires any of them.
22
+ - **Three ways in, for each conversion:** one-line functions, a reusable
23
+ converter object, or the `xml2table` CLI.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install -e . # from a checkout: XML -> CSV/Excel only
29
+ pip install -e ".[pandas]" # + optional DataFrame support
30
+ pip install -e ".[pdf-text]" # + PDF -> text (pypdf only, lightweight)
31
+ pip install -e ".[pdf]" # + PDF -> XML and text (pdfplumber + pypdf)
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ from xml2table import xml_to_csv, xml_to_excel
38
+
39
+ xml_to_csv("orders.xml", "orders.csv")
40
+ xml_to_excel("orders.xml", "orders.xlsx")
41
+ ```
42
+
43
+ Given:
44
+
45
+ ```xml
46
+ <orders>
47
+ <order id="1">
48
+ <customer><name>Jane Doe</name></customer>
49
+ <total>99.99</total>
50
+ </order>
51
+ </orders>
52
+ ```
53
+
54
+ you get one row per `<order>`:
55
+
56
+ | @id | customer.name | total |
57
+ |-----|----------------|-------|
58
+ | 1 | Jane Doe | 99.99 |
59
+
60
+ Nested elements are flattened with `.`-separated keys, attributes get an
61
+ `@` prefix, and the record element (`order` here) is auto-detected as "the
62
+ repeated child of the root". When detection is ambiguous, pass
63
+ `record_path` explicitly.
64
+
65
+ ## Reusable converter
66
+
67
+ Parse once, write many times:
68
+
69
+ ```python
70
+ from xml2table import XMLConverter, FlattenOptions
71
+
72
+ converter = XMLConverter("orders.xml", options=FlattenOptions(record_path="Order"))
73
+ converter.to_csv("orders.csv")
74
+ converter.to_excel("orders.xlsx", sheet_name="Orders")
75
+ rows = converter.to_records() # list[dict]
76
+ df = converter.to_dataframe() # requires pandas
77
+ ```
78
+
79
+ ## Handling repeated elements (`array_mode`)
80
+
81
+ Given an order with two line items, `FlattenOptions.array_mode` controls the
82
+ shape of the output:
83
+
84
+ | mode | Result | Use when |
85
+ |------|--------|----------|
86
+ | `"join"` (default) | One row per order; items collapsed into one joined cell | You just want a quick, human-readable table |
87
+ | `"explode"` | One row per item (order fields repeat) | You want a normalized, analysis-ready table, like a SQL join |
88
+ | `"index"` | One row per order; items spread into `items.item.0.*`, `items.item.1.*`, ... | You need every field as its own column with no row duplication |
89
+
90
+ ```python
91
+ from xml2table import FlattenOptions, xml_to_records
92
+
93
+ xml_to_records("orders.xml", options=FlattenOptions(array_mode="explode"))
94
+ ```
95
+
96
+ ## Multi-sheet Excel from one document
97
+
98
+ Turn different parts of the same XML document into separate, related sheets
99
+ (e.g. an "orders" table and an "items" table):
100
+
101
+ ```python
102
+ from xml2table import xml_to_excel
103
+
104
+ xml_to_excel(
105
+ "orders.xml", "orders.xlsx",
106
+ sheets={"orders": "Order", "items": ".//Order/Items/Item"},
107
+ )
108
+ ```
109
+
110
+ ## Record paths
111
+
112
+ `record_path` uses the same syntax as
113
+ [`Element.findall`](https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.Element.findall)
114
+ (a subset of XPath): `"Order"`, `"Orders/Order"`, `".//Item"`,
115
+ `"Order[@status='shipped']"`, etc.
116
+
117
+ ## CLI (XML)
118
+
119
+ ```bash
120
+ xml2table csv orders.xml orders.csv --record-path Order --array-mode explode
121
+ xml2table excel orders.xml orders.xlsx --sheet-name Orders
122
+ xml2table excel orders.xml orders.xlsx --sheet orders=Order --sheet items=".//Item"
123
+ ```
124
+
125
+ Run `xml2table csv --help` or `xml2table excel --help` for all flags
126
+ (`--separator`, `--attribute-prefix`, `--no-attributes`, `--keep-namespaces`,
127
+ `--delimiter`, `--encoding`, ...).
128
+
129
+ ## PDF → XML / text
130
+
131
+ `pdf_to_xml` and `pdf_to_text` are two independent, purpose-built backends
132
+ behind one options object and one CLI command:
133
+
134
+ - **`pdf_to_xml`** (pdfplumber) groups the PDF's words into paragraphs (by
135
+ line, then by vertical gap) and detects tables separately via ruling
136
+ lines or text alignment, then places both back in the order they appear
137
+ on the page. Nothing is dropped, and table text never bleeds into
138
+ surrounding paragraphs.
139
+ - **`pdf_to_text`** (pypdf) is a much lighter path: it just extracts each
140
+ page's text in reading order, preserving the PDF's visual whitespace
141
+ layout by default (so simple tables and columns still read naturally)
142
+ without doing any table detection.
143
+
144
+ ```python
145
+ from xml2table import pdf_to_xml, pdf_to_text
146
+
147
+ pdf_to_xml("invoice.pdf", "invoice.xml") # paragraphs + tables (needs xml2table[pdf])
148
+ pdf_to_text("invoice.pdf", "invoice.txt") # fast raw text (needs xml2table[pdf-text])
149
+ ```
150
+
151
+ `invoice.xml` looks like:
152
+
153
+ ```xml
154
+ <document source="invoice.pdf" pages="1">
155
+ <page number="1" width="595.28" height="841.89">
156
+ <paragraph bbox="42.83,43.13,159.90,61.13">Invoice #1024</paragraph>
157
+ <table bbox="40.00,220.00,540.00,316.00" rows="4" cols="3">
158
+ <row><cell>Item</cell><cell>Qty</cell><cell>Price</cell></row>
159
+ <row><cell>Widget</cell><cell>2</cell><cell>$10.00</cell></row>
160
+ ...
161
+ </table>
162
+ </page>
163
+ </document>
164
+ ```
165
+
166
+ `invoice.txt` is pypdf's layout-preserving text, e.g.:
167
+
168
+ ```
169
+ Invoice #1024
170
+
171
+ Bill To: Jane Doe
172
+ 123 Example Street
173
+ Springfield, USA
174
+
175
+ Thank you for your business. Payment is due within thirty days...
176
+
177
+ Item Qty Price
178
+ Widget 2 $10.00
179
+ ...
180
+ ```
181
+
182
+ Reuse one `PDFConverter` for both (it lazily parses with pdfplumber only if
183
+ you call `to_xml()`/`pages`, and always uses pypdf for `to_text()`):
184
+
185
+ ```python
186
+ from xml2table import PDFConverter
187
+
188
+ converter = PDFConverter("invoice.pdf")
189
+ converter.to_xml("invoice.xml")
190
+ converter.to_text("invoice.txt")
191
+ for page in converter.pages:
192
+ print(page.number, len(page.paragraphs), len(page.tables))
193
+ ```
194
+
195
+ ### `PDFOptions` reference
196
+
197
+ | Option | Default | Used by | Description |
198
+ |--------|---------|---------|--------------|
199
+ | `line_tolerance` | `3.0` | XML | Max vertical gap (points) for words to count as the same line |
200
+ | `paragraph_gap` | `6.0` | XML | Min vertical gap (points) between lines that starts a new paragraph |
201
+ | `table_settings` | `None` | XML | Passed through to pdfplumber's `find_tables()` for unusual tables (e.g. borderless). **Caution:** this applies to the whole page, not just the table — see [Validation](#validation-tested-on-1200-financial-report-pdfs) below before using it on documents with narrative text. |
202
+ | `cell_na` | `""` | XML | String used for empty/missing table cells |
203
+ | `page_separator` | `"\n----- Page {page} -----\n"` | text | Inserted between pages |
204
+ | `keep_layout` | `True` | text | Preserve the PDF's whitespace layout (pypdf `"layout"` mode) vs. plain, whitespace-normalized text |
205
+
206
+ ### CLI (PDF)
207
+
208
+ ```bash
209
+ xml2table pdf invoice.pdf invoice.xml --to xml
210
+ xml2table pdf invoice.pdf invoice.txt --to text
211
+ ```
212
+
213
+ Run `xml2table pdf --help` for all flags (`--line-tolerance`,
214
+ `--paragraph-gap`, `--cell-na`, `--page-separator`, `--no-layout`).
215
+
216
+ ## Validation: tested on 1,200 financial-report PDFs
217
+
218
+ `pdf_to_xml` and `pdf_to_text` were benchmarked against 1,200 generated
219
+ financial-report PDFs (balance sheets, income statements, cash-flow
220
+ statements, MD&A-style narrative text, footnotes — real financial
221
+ formatting: `$1,234,567`, `(123,456)` negatives, `N/A` blanks, multi-page,
222
+ ruled and borderless tables) with **exact ground truth** for every paragraph
223
+ and table cell, so the numbers below are measured, not estimated. Full
224
+ methodology, the generator, and raw per-document results are in
225
+ [`benchmarks/RESULTS.md`](benchmarks/RESULTS.md).
226
+
227
+ | Metric | Result |
228
+ |---|---|
229
+ | Documents converted without error | **1,200 / 1,200 (100%)** |
230
+ | Paragraph text fidelity (XML) | **100.000%** |
231
+ | Ruled-table shape + cell fidelity | **100.000%** |
232
+ | Borderless-table shape detection | 0.000% (documented limitation — see below) |
233
+ | Raw-text content recall (`pdf_to_text`) | **100.000%**, including for borderless tables |
234
+ | Throughput | 20.4 PDFs/sec (`pdf_to_xml`), 184.6 PDFs/sec (`pdf_to_text`) |
235
+
236
+ **The one real limitation, quantified:** pdfplumber's default table finder
237
+ needs ruling lines, so it doesn't detect borderless (text-only-aligned)
238
+ tables — but nothing is lost when it doesn't: the un-detected table's text
239
+ still comes through as ordinary paragraph text (`pdf_to_text` recall stays
240
+ at 100%). We also tested the obvious "fix" — pdfplumber's `table_settings`
241
+ override for borderless tables — across all 1,200 documents, and it made
242
+ things *worse*: because the override applies to the whole page, it started
243
+ misreading ordinary paragraph sentences as table cells, corrupting
244
+ paragraph output in **97.9% of documents** (paragraph fidelity dropped from
245
+ 100% to 13.9%). We did not ship that as a recommended workaround; see
246
+ `PDFOptions.table_settings`'s docstring and `benchmarks/RESULTS.md` for the
247
+ full numbers and why.
248
+
249
+ We could not download real financial filings for this test — this sandboxed
250
+ session's network policy blocks direct access to sites like sec.gov — so
251
+ the corpus is synthetic but built to real financial-statement conventions
252
+ specifically so every value has a known-correct answer to grade against.
253
+ `benchmarks/RESULTS.md` explains this in more detail and gives the exact
254
+ commands to reproduce or extend the benchmark (e.g. against real filings, on
255
+ a machine with broader network access).
256
+
257
+ ## `FlattenOptions` reference
258
+
259
+ | Option | Default | Description |
260
+ |--------|---------|--------------|
261
+ | `record_path` | `None` (auto-detect) | Path to the repeated record element |
262
+ | `attribute_prefix` | `"@"` | Prefix for attribute-derived columns |
263
+ | `text_key` | `"#text"` | Key for an element's own text when it also has attributes/children |
264
+ | `separator` | `"."` | Separator for nested key paths |
265
+ | `array_mode` | `"join"` | `"join"`, `"explode"`, or `"index"` |
266
+ | `join_separator` | `"; "` | Separator used by `"join"` mode |
267
+ | `include_attributes` | `True` | Include XML attributes as columns |
268
+ | `strip_namespaces` | `True` | Strip `{namespace}` from tag/attribute names |
269
+ | `encoding` | `"utf-8"` | Text encoding for reads/writes |
270
+
271
+ ## Errors
272
+
273
+ All exceptions inherit from `xml2table.XMLConversionError`:
274
+
275
+ - `XMLParseError` — malformed XML input
276
+ - `RecordPathNotFoundError` — `record_path` matched nothing, or automatic
277
+ record detection was ambiguous (the error message tells you what to pass)
278
+ - `PDFExtractionError` — a PDF file could not be opened or parsed
279
+ - `MissingOptionalDependencyError` — e.g. calling `to_dataframe()` without
280
+ `pandas`, `pdf_to_xml()` without `pdfplumber`, or `pdf_to_text()` without
281
+ `pypdf`, installed
282
+
283
+ ## Development
284
+
285
+ ```bash
286
+ pip install -e ".[dev]" # includes pandas, pdfplumber, pypdf, and fpdf2 (for regenerating PDF fixtures)
287
+ pytest
288
+ python examples/quickstart.py
289
+ ```
290
+
291
+ Project layout:
292
+
293
+ ```
294
+ src/xml2table/
295
+ parser.py # XML -> list[dict] flattening engine
296
+ options.py # FlattenOptions
297
+ converter.py # XMLConverter + module-level convenience functions
298
+ writers/ # CSV and Excel output
299
+ pdf_extract.py # pdfplumber: PDF -> Document(pages of Paragraph/Table), in reading order
300
+ pdf_xml_writer.py # Document -> XML
301
+ pdf_text_extract.py # pypdf: PDF -> plain text, independent of pdf_extract.py
302
+ pdf_options.py # PDFOptions
303
+ pdf_converter.py # PDFConverter + pdf_to_xml/pdf_to_text
304
+ cli.py # `xml2table` command-line tool
305
+ tests/
306
+ fixtures/ # sample XML documents and PDF fixtures (fixtures/pdf/)
307
+ test_*.py
308
+ examples/
309
+ quickstart.py
310
+ ```