Skip to content

Commit 80894da

Browse files
authored
Merge pull request #16 from artiz/feature/extend-docs-formats-support
Enhance JATS, ODF, and DOCX backends with improved content extraction
2 parents f22b7fe + e257b80 commit 80894da

57 files changed

Lines changed: 57712 additions & 2427 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ models/
1919
# Python bytecode cache from the comparison scripts
2020
__pycache__/
2121
*.pyc
22+
23+
# Local docling ground-truth env & scratch dir for this migration work
24+
/.venv-gt
25+
/scratch-gt

MIGRATION.md

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ phased plan is kept at the end as history.)
77

88
> **Status: the format migration is essentially complete.** Every document
99
> format in docling's pipeline except **audio/ASR** is supported, plus Markdown
10-
> (legacy + a Rust-only *strict* mode), docling-native **JSON** output, and
11-
> **image extraction**. The declarative formats are pure-Rust and checked
12-
> byte-for-byte against *live* docling; the PDF/image/METS ML path lives in
13-
> `fleischwolf-pdf` (a pure-Rust PDF text parser + pdfium rasterization + ONNX
10+
> (legacy + a Rust-only *strict* mode), docling-native **JSON** output, **image
11+
> extraction**, and **MHTML** (a fleischwolf-only extension docling doesn't
12+
> have). The declarative formats are pure-Rust and checked byte-for-byte
13+
> against *live* docling; the PDF/image/METS ML path lives in `fleischwolf-pdf`
14+
> (a pure-Rust PDF text parser + pdfium rasterization + ONNX
1415
> layout/TableFormer/OCR + a port of docling-parse's line sanitizer) and is also
1516
> measured byte-for-byte against live docling — **6 / 14 PDF fixtures exact, 7 / 14
1617
> whitespace-normalized** (see `PDF_CONFORMANCE.md`), with a snapshot baseline
17-
> guarding against regressions. `cargo test` is green (unit tests + a 131-source
18+
> guarding against regressions. `cargo test` is green (unit tests + a 133-source
1819
> output-regression suite).
1920
2021
---
@@ -36,7 +37,8 @@ crates/
3637
├── fleischwolf-core/ # DoclingDocument, Node model, markdown.rs, json.rs, base64.rs, labels.rs
3738
├── fleischwolf/ # DocumentConverter, source/format detection, backend/*.rs, ooxml.rs
3839
├── fleischwolf-pdf/ # pdfium_backend, layout (RT-DETR/ONNX), ocr (PP-OCRv3/ONNX), assemble, mets
39-
└── fleischwolf-cli/ # `--strict`, `--to md|json`, `--images placeholder|embedded|referenced`
40+
├── fleischwolf-cli/ # `--strict`, `--to md|json`, `--images placeholder|embedded|referenced`
41+
└── fleischwolf-node/ # Node.js/Bun N-API bindings (napi-rs), published to npm as `fleischwolf`
4042
```
4143

4244
The public API is unchanged from day one:
@@ -73,12 +75,13 @@ PyPI; run via `scripts/conformance.sh <fmt>`), not the committed groundtruth
7375
| WebVTT | `webvtt.rs` | **4/4 exact** |
7476
| Email (.eml) | `email.rs` (mail-parser) | **2/2 exact** |
7577
| EPUB | `epub.rs` → HTML backend | core exact (shares HTML residual) |
76-
| ODF (odt/ods/odp) | `odf.rs` | core; residual in §5 |
77-
| JATS | `jats.rs` (roxmltree) | core ~60% (metadata + sections + paragraphs) |
78+
| ODF (odt/ods/odp) | `odf.rs` | core + list continuation + rich table cells + ODS table regions; residual in §5 |
79+
| JATS | `jats.rs` (roxmltree) | metadata + full `<body>`/`<back>` (tables, figures, references, lists, footnotes, formulas) |
7880
| USPTO | `uspto.rs` | modern `us-patent-*-v4x` core; residual in §5 |
7981
| XBRL | `xbrl.rs` | arelle-free core (dei facts → title, `*TextBlock` → HTML) |
8082
| JSON-docling | `docling_json.rs` (serde_json) | reads docling's native JSON; ~51/145 round-trip exact |
8183
| LaTeX | `latex.rs` (scanner) | simple `.tex` ≈ live; multi-file arxiv out of scope |
84+
| MHTML (.mhtml/.mht) | `mhtml.rs` (mail-parser) → HTML backend | **fleischwolf extension — no docling backend to compare against**; embedded images resolved by `Content-Location`/`cid:` |
8285

8386
Shared OOXML infrastructure (`ooxml.rs`): a `zip` reader, `.rels` parsing, part
8487
content-type resolution, and image extraction — reused by DOCX/PPTX/XLSX/EPUB.
@@ -208,15 +211,23 @@ when the TableFormer graphs aren't present.)
208211
- **Older patent schemas.** USPTO covers the modern `v4x` XML only; the
209212
`pap-v1` / 2001-era `pa`/`pg` schemas and the legacy **APS text** (`pftaps`)
210213
format are not handled (two files even use HTML entities roxmltree rejects).
211-
- **JATS article-body machinery** — tables, figures, references/citations, lists
212-
and formula rendering inside `<body>` (metadata + sections + paragraphs are
213-
done).
214-
- **ODF deep quirks** — mixed-style list continuation, empty-list-item level
215-
collapse, ODS sheet→table region detection with numeric alignment, rich table
216-
cells.
217-
- **DOCX long tail** — full Word multilevel list/heading *shared* numbering,
218-
position-sorted textbox/shape-text layout, advanced OMML + inline-equation
219-
spacing.
214+
- **ODF presentation title/shape/notes** — slide-title heading detection, free
215+
shape-text extraction and the drop of speaker-notes on `.odp` slides. The
216+
mixed-style **list continuation**, empty-list-item level collapse,
217+
**ODS sheet→table region detection with numeric alignment**, and **rich table
218+
cells** are now done (a flood-fill splits a sheet into its disconnected data
219+
regions; `<text:list>` siblings continue numbering across an empty nested item;
220+
a cell holding lists/nested tables/images/multiple paragraphs renders its full
221+
block content flattened into the cell while a plain cell stays unformatted, and
222+
merged cells leave their covered columns blank). What remains on `.odt` is
223+
charts/embedded-object frames (`text_document_02`).
224+
- **DOCX grouped/anchored drawings** — position-sorted layout of grouped shapes
225+
and `<mc:AlternateContent>` image de-duplication (`drawingml` fixture). The
226+
Word multilevel list/heading *shared* numbering and **advanced OMML +
227+
inline-equation spacing** are now done (inline equations reproduce docling's
228+
inline-group spacing and stay attached to their list item; `\operatorname`
229+
functions, limit-label space escaping and the two-space symbol padding match
230+
pylatexenc byte-for-byte).
220231
- **HTML browser-render subsystem** — nav/visibility suppression (`wiki_duck`),
221232
form key-value-pair regions (`kvp_data_example`), deep nested-table cell padding
222233
from rendered bounding boxes. ~4 HTML fixtures + KVP.
@@ -226,9 +237,7 @@ when the TableFormer graphs aren't present.)
226237

227238
## 6. Extensions
228239

229-
- Support **MHTML** format, check if https://crates.io/crates/mail-parser has support for it.
230240
- **PyO3 bindings** (`fleischwolf-py`) for a strangler-fig drop-in.
231-
- **nodejs/bun TypeScript** bindings
232241
- **C++** bindings
233242
- `fleischwolf-rag` - basic documents processing/chunking/vectorization/semantic-search system with pluggable DB support, PostgreSQL/SQLite, embedding with small ONNX local model (test options, dimensions >= 1024).
234243

@@ -238,7 +247,7 @@ when the TableFormer graphs aren't present.)
238247
regression suite** (`crates/fleischwolf/tests/regression.rs`): every
239248
declarative source under `crates/fleischwolf/tests/data/<fmt>/sources/` is
240249
converted to legacy Markdown, strict Markdown and docling JSON and compared to
241-
committed fixtures (131 sources × 3). `FLEISCHWOLF_REGEN=1` refreshes them.
250+
committed fixtures (133 sources × 3). `FLEISCHWOLF_REGEN=1` refreshes them.
242251
The JSON fixtures double as a docling-core load check.
243252
- **Snapshot harness**`scripts/pdf_conformance.sh` regenerates and diffs the
244253
PDF/image/METS baseline (needs pdfium + the ONNX models; **91/91 exact**).

README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,17 @@ the full architecture, the Python → Rust mapping, and the phased plan.
2727
## Status
2828

2929
The public API works end to end across **Markdown, CSV, HTML, AsciiDoc, DOCX,
30-
PPTX, XLSX, EPUB, ODF, WebVTT, Email, JATS, USPTO, XBRL, LaTeX, JSON, PDF,
31-
images and METS** — plus Markdown / docling-JSON output and image extraction.
32-
The discriminative PDF/image pipeline lives in `fleischwolf-pdf`: a pure-Rust PDF
33-
text parser, pdfium for page rasterization, and an ONNX layout/TableFormer/OCR
34-
stack. Audio/ASR is the main format still on the roadmap (see `MIGRATION.md`).
30+
PPTX, XLSX, EPUB, ODF, WebVTT, Email, MHTML, JATS, USPTO, XBRL, LaTeX, JSON,
31+
PDF, images and METS** — plus Markdown / docling-JSON output and image
32+
extraction. MHTML is a fleischwolf-only extension (docling has no MHTML
33+
backend): saved-webpage `.mhtml`/`.mht` archives are parsed as a MIME message
34+
with [`mail-parser`](https://crates.io/crates/mail-parser) (which conforms to
35+
[RFC 2557](https://datatracker.ietf.org/doc/html/rfc2557), the MHTML spec) and
36+
routed through the HTML backend, with embedded images resolved from the
37+
archive by `Content-Location`/`cid:`. The discriminative PDF/image pipeline
38+
lives in `fleischwolf-pdf`: a pure-Rust PDF text parser, pdfium for page
39+
rasterization, and an ONNX layout/TableFormer/OCR stack. Audio/ASR is the main
40+
format still on the roadmap (see `MIGRATION.md`).
3541

3642
Output is checked against upstream Python docling — declarative formats
3743
byte-for-byte against live docling, the ML pipeline against a deterministic
@@ -70,8 +76,10 @@ round-trips to the same Markdown.
7076
### Image extraction
7177

7278
Backends that have the image populate `Node::Picture { image }`: the PDF/image
73-
pipeline crops figure regions, the DOCX / PPTX backends pull embedded image
74-
blobs, and — opt-in — the HTML / EPUB backends fetch `<img src>` (see below).
79+
pipeline crops figure regions, the DOCX / PPTX / MHTML backends pull embedded
80+
image blobs (MHTML resolves `<img src>` against the archive's own MIME parts —
81+
no network/filesystem access needed, so it's on by default), and — opt-in —
82+
the HTML / EPUB backends fetch `<img src>` (see below).
7583
Pick how pictures render with an [`ImageMode`] — the analogue of docling's
7684
`image_mode`:
7785

crates/fleischwolf-core/src/markdown.rs

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,61 @@ fn ext_for(mimetype: &str) -> &str {
396396
///
397397
/// Each cell is first escaped (`\n` → space, `|` → `&#124;`) so it can't break the
398398
/// table. Row 0 is the header.
399+
/// Whether a table cell counts as a number for column alignment, matching
400+
/// `tabulate`'s detection: an ordinary float/int (`f64`-parseable, covering
401+
/// `1e2`/`inf`/`+1.5`) **or** a thousands-separated number like `7,015`.
402+
fn is_number_cell(t: &str) -> bool {
403+
t.parse::<f64>().is_ok() || is_thousands_number(t)
404+
}
405+
406+
/// A number with comma thousands-separators, per `tabulate`'s
407+
/// `_float_with_thousands_separators` regex
408+
/// (`^(([+-]?[0-9]{1,3})(?:,([0-9]{3}))*)?(?(1)\.[0-9]*|\.[0-9]+)?$`): the
409+
/// integer part is 1–3 digits then any number of `,ddd` groups; the fraction is
410+
/// optional (and, without an integer part, must have at least one digit).
411+
fn is_thousands_number(t: &str) -> bool {
412+
let b = t.as_bytes();
413+
let mut i = 0;
414+
let start = i;
415+
if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
416+
i += 1;
417+
}
418+
// First digit chunk: 1–3 digits.
419+
let d0 = i;
420+
while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
421+
i += 1;
422+
}
423+
let has_int = i > d0;
424+
if has_int {
425+
// Subsequent `,ddd` groups (exactly three digits each).
426+
while i + 3 < b.len() + 1
427+
&& b.get(i) == Some(&b',')
428+
&& b.get(i + 1).is_some_and(u8::is_ascii_digit)
429+
&& b.get(i + 2).is_some_and(u8::is_ascii_digit)
430+
&& b.get(i + 3).is_some_and(u8::is_ascii_digit)
431+
{
432+
i += 4;
433+
}
434+
} else {
435+
// A sign only counts with an integer part.
436+
i = start;
437+
}
438+
// Optional fraction.
439+
if i < b.len() && b[i] == b'.' {
440+
i += 1;
441+
let f0 = i;
442+
while i < b.len() && b[i].is_ascii_digit() {
443+
i += 1;
444+
}
445+
if !has_int && i == f0 {
446+
return false; // `.` with no digits and no integer part
447+
}
448+
} else if !has_int {
449+
return false; // neither integer nor fractional part
450+
}
451+
i == b.len()
452+
}
453+
399454
fn render_table(table: &Table, compact: bool) -> String {
400455
if table.rows.is_empty() {
401456
return String::new();
@@ -442,14 +497,24 @@ fn render_table(table: &Table, compact: bool) -> String {
442497
let dw = |s: &str| s.chars().count();
443498
let data_rows = 1..grid.len();
444499

445-
// A column is right-aligned when it has data and every data cell is numeric.
500+
// A column is right-aligned when at least one data cell is numeric and every
501+
// non-empty data cell is numeric — matching `tabulate`'s column typing, where
502+
// empty cells are "missing" (ignored) and a number may carry thousands
503+
// separators (`7,015`), which a plain `f64` parse rejects.
446504
let right: Vec<bool> = (0..num_cols)
447505
.map(|c| {
448-
!data_rows.is_empty()
449-
&& data_rows.clone().all(|r| {
450-
let t = grid[r][c].trim();
451-
!t.is_empty() && t.parse::<f64>().is_ok()
452-
})
506+
let mut any = false;
507+
for r in data_rows.clone() {
508+
let t = grid[r][c].trim();
509+
if t.is_empty() {
510+
continue;
511+
}
512+
if !is_number_cell(t) {
513+
return false;
514+
}
515+
any = true;
516+
}
517+
any
453518
})
454519
.collect();
455520

crates/fleischwolf-node/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ pub fn supported_formats() -> Vec<String> {
662662
"latex",
663663
"email",
664664
"epub",
665+
"mhtml",
665666
]
666667
.iter()
667668
.map(|s| s.to_string())

0 commit comments

Comments
 (0)