Skip to content

Commit 5115c40

Browse files
authored
Merge pull request #160 from artiz/claude/wasm-unified-demo
wasm: one demo page with the whole pipeline (#157)
2 parents 68963db + 3e02f01 commit 5115c40

21 files changed

Lines changed: 2119 additions & 717 deletions

File tree

.github/workflows/pages.yml

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Publish the in-browser demo (crates/docling-wasm/www) to GitHub Pages, so
2+
# docling.rs can be tried from any device without a checkout or a toolchain.
3+
#
4+
# The site is fully static: the wasm module is built here and copied in next to
5+
# the page. No models are published — they are large, and the page resolves them
6+
# at runtime (device file → same-origin ./models/ → Hugging Face), so the
7+
# declarative converters and text-layer PDFs work immediately and OCR works as
8+
# soon as the visitor supplies models.
9+
#
10+
# GitHub Pages cannot send COOP/COEP headers, which ONNX Runtime Web needs for
11+
# threads; www/coi.js installs a service worker that adds them, so the deployed
12+
# page still gets multi-threaded wasm.
13+
#
14+
# One-time setup in the repository: Settings → Pages → Build and deployment →
15+
# Source: "GitHub Actions".
16+
name: pages
17+
18+
on:
19+
push:
20+
branches: [master]
21+
paths:
22+
- "crates/docling-wasm/**"
23+
- "crates/docling/**"
24+
- "crates/docling-core/**"
25+
- "crates/docling-pdf/**"
26+
- ".github/workflows/pages.yml"
27+
# Deploy on demand too (e.g. the first run, before any matching push).
28+
workflow_dispatch:
29+
30+
permissions:
31+
contents: read
32+
pages: write
33+
id-token: write
34+
35+
# One deployment at a time; let a running one finish so the site is never
36+
# left half-published.
37+
concurrency:
38+
group: pages
39+
cancel-in-progress: false
40+
41+
jobs:
42+
build:
43+
name: build the demo
44+
runs-on: ubuntu-latest
45+
steps:
46+
- uses: actions/checkout@v7
47+
48+
- name: Install Rust (stable + wasm32 target)
49+
run: |
50+
rustup toolchain install stable --profile minimal
51+
rustup default stable
52+
rustup target add wasm32-unknown-unknown
53+
54+
- uses: actions/cache@v4
55+
with:
56+
path: |
57+
~/.cargo/registry/index
58+
~/.cargo/registry/cache
59+
~/.cargo/git/db
60+
target
61+
key: ${{ runner.os }}-cargo-v2-wasm-${{ hashFiles('**/Cargo.lock') }}
62+
restore-keys: ${{ runner.os }}-cargo-v2-wasm-
63+
64+
- name: Build docling-wasm (release)
65+
run: cargo build -p docling-wasm --target wasm32-unknown-unknown --release --locked
66+
67+
- name: Generate the JS bindings
68+
run: |
69+
# Pin wasm-bindgen-cli to the wasm-bindgen version in Cargo.lock: a
70+
# mismatch produces glue the module rejects at load time.
71+
version=$(cargo metadata --format-version 1 --locked \
72+
| python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"]=="wasm-bindgen"))')
73+
echo "wasm-bindgen $version"
74+
cargo install wasm-bindgen-cli --version "$version" --locked
75+
wasm-bindgen --target web --out-dir crates/docling-wasm/www/pkg \
76+
target/wasm32-unknown-unknown/release/docling_wasm.wasm
77+
78+
- name: Assemble the site
79+
run: |
80+
mkdir -p site
81+
cp -r crates/docling-wasm/www/. site/
82+
# Jekyll would swallow paths that start with an underscore.
83+
touch site/.nojekyll
84+
ls -la site site/pkg
85+
86+
- uses: actions/upload-pages-artifact@v3
87+
with:
88+
path: site
89+
90+
deploy:
91+
name: deploy
92+
needs: build
93+
runs-on: ubuntu-latest
94+
environment:
95+
name: github-pages
96+
url: ${{ steps.deployment.outputs.page_url }}
97+
steps:
98+
- id: deployment
99+
uses: actions/deploy-pages@v4

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ pipeline is supported, validated byte-for-byte against live docling. See
1212
[`docs/MIGRATION.md`](./docs/MIGRATION.md) for the full architecture, the Python → Rust
1313
mapping, and per-format conformance.
1414

15+
**[Try it in your browser](https://docling-project.github.io/docling.rs/)**
16+
the whole converter compiled to wasm: drop a DOCX, PDF, XLSX, EPUB … and get
17+
Markdown, docling JSON or DocLang XML back. Nothing is uploaded; the page runs
18+
entirely on your device, phone included. Scanned pages can be OCR'd there too
19+
(layout + PP-OCR + TableFormer via ONNX Runtime Web) once you point it at the
20+
models. See [`crates/docling-wasm`](./crates/docling-wasm/README.md).
21+
1522
Developed with **Claude Code** and _[TENET](https://github.com/artiz/tenet/tree/master)_ (minimalistic spec driven design framework).
1623

1724
## Status

crates/docling-pdf/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,10 @@ tokenizers = { version = "0.20", default-features = false, features = ["onig"],
6969
[target.'cfg(target_arch = "wasm32")'.dependencies]
7070
lopdf = { version = "0.44", features = ["wasm_js"] }
7171

72+
# The text-layer diagnostic example reaches for lopdf directly to tell a load
73+
# failure apart from "loaded, but no text came out".
74+
[dev-dependencies]
75+
lopdf = "0.44"
76+
7277
[lib]
7378
name = "docling_pdf"
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
//! Run **exactly** what a wasm build does with a PDF: `convert_text_layer` —
2+
//! the pure-Rust content-stream parser, no pdfium, no ONNX. Use it to tell a
3+
//! browser "this PDF needs OCR" apart from a genuine scan: the browser falls
4+
//! back to OCR precisely when this produces no nodes, so an empty result here
5+
//! reproduces that decision offline.
6+
//!
7+
//! Note this is *not* what the CLI's `--no-ocr` runs — that goes through
8+
//! pdfium's text extraction, which can read layers this parser cannot. When the
9+
//! two disagree, the breakdown below says which stage lost the text.
10+
//!
11+
//! ```bash
12+
//! cargo run -p docling-pdf --no-default-features --example text_layer -- <pdf>
13+
//! ```
14+
//! (`--no-default-features` keeps pdfium/onnxruntime out of the build.)
15+
16+
fn main() {
17+
let path = match std::env::args().nth(1) {
18+
Some(p) => p,
19+
None => {
20+
eprintln!("usage: text_layer <pdf>");
21+
std::process::exit(2);
22+
}
23+
};
24+
let bytes = std::fs::read(&path).expect("read the pdf");
25+
let name = std::path::Path::new(&path)
26+
.file_name()
27+
.map(|n| n.to_string_lossy().into_owned())
28+
.unwrap_or_else(|| path.clone());
29+
30+
// Stage 1 — does lopdf load the document at all? `textparse` swallows this
31+
// failure (an unloadable PDF and a scan both come out empty), so ask
32+
// directly: encryption, a damaged xref or an unsupported filter all land
33+
// here, and they mean something very different from "no text layer".
34+
match lopdf::Document::load_mem(&bytes) {
35+
Err(e) => {
36+
eprintln!("1. lopdf load: FAILED — {e}");
37+
eprintln!(" (the browser reports this as \"no embedded text layer\")");
38+
eprintln!(
39+
" xref repair: {}",
40+
docling_pdf::textparse::xref_repair_status(&bytes)
41+
);
42+
probe_tail(&bytes);
43+
}
44+
Ok(doc) => {
45+
eprintln!(
46+
"1. lopdf load: ok — {} page(s), version {}, encrypted: {}",
47+
doc.get_pages().len(),
48+
doc.version,
49+
doc.is_encrypted()
50+
);
51+
}
52+
}
53+
54+
// Stage 2 — raw line cells out of the content streams. Zero here with a
55+
// successful load means the text is there but we cannot decode it (font
56+
// encoding, an unhandled operator, a filter), not that the page is a scan.
57+
let pages = docling_pdf::textparse::pdf_textlines(&bytes);
58+
let cells: usize = pages.iter().map(|(_, _, c)| c.len()).sum();
59+
eprintln!("2. text lines: {cells} across {} page(s)", pages.len());
60+
if cells == 0 {
61+
eprintln!(
62+
" where it is lost:{}",
63+
docling_pdf::textparse::content_diagnosis(&bytes)
64+
);
65+
}
66+
for (i, (_, _, c)) in pages.iter().enumerate().take(3) {
67+
if let Some(first) = c.iter().find(|c| !c.text.trim().is_empty()) {
68+
eprintln!(" page {}: first line {:?}", i + 1, first.text);
69+
}
70+
}
71+
72+
// Stage 3 — the assembled document, i.e. what the browser actually gets.
73+
match docling_pdf::convert_text_layer(&bytes, &name) {
74+
Ok(doc) => {
75+
let md = doc.export_to_markdown();
76+
eprintln!(
77+
"3. convert_text_layer: {} node(s), {} chars — {}",
78+
doc.nodes.len(),
79+
md.len(),
80+
if doc.nodes.is_empty() {
81+
"EMPTY: a browser build would fall back to OCR here"
82+
} else {
83+
"the browser would use this directly (no OCR)"
84+
}
85+
);
86+
println!("{md}");
87+
}
88+
Err(e) => {
89+
eprintln!("3. convert_text_layer: FAILED — {e}");
90+
std::process::exit(1);
91+
}
92+
}
93+
}
94+
95+
/// When the document will not load, show the cross-reference machinery the
96+
/// parser choked on: the last `startxref`, where it points, and what actually
97+
/// sits there. A classic `xref` table, an xref *stream* (`/Type /XRef`), bytes
98+
/// past the end of the file or plain garbage each point at a different repair,
99+
/// and this is enough to tell them apart without handing the PDF around.
100+
fn probe_tail(bytes: &[u8]) {
101+
let find_last = |needle: &[u8]| {
102+
bytes
103+
.windows(needle.len())
104+
.rposition(|w| w == needle)
105+
.map(|i| (i, needle.len()))
106+
};
107+
let printable = |b: &[u8]| -> String {
108+
b.iter()
109+
.map(|&c| {
110+
if (0x20..0x7f).contains(&c) {
111+
c as char
112+
} else if c == b'\n' || c == b'\r' {
113+
'\u{23ce}'
114+
} else {
115+
'.'
116+
}
117+
})
118+
.collect()
119+
};
120+
121+
eprintln!(" file: {} bytes", bytes.len());
122+
eprintln!(
123+
" markers: trailer={} startxref={} xref={} /XRef={} %%EOF={}",
124+
find_last(b"trailer").is_some(),
125+
find_last(b"startxref").is_some(),
126+
find_last(b"xref").is_some(),
127+
find_last(b"/XRef").is_some(),
128+
find_last(b"%%EOF").is_some(),
129+
);
130+
131+
// Trailing bytes after the final %%EOF confuse a strict tail scan.
132+
if let Some((i, len)) = find_last(b"%%EOF") {
133+
let after = bytes.len() - (i + len);
134+
eprintln!(" bytes after the last %%EOF: {after}");
135+
}
136+
137+
let Some((sx, _)) = find_last(b"startxref") else {
138+
eprintln!(" no startxref at all — the xref would have to be rebuilt by scanning objects");
139+
return;
140+
};
141+
let tail = &bytes[sx..bytes.len().min(sx + 60)];
142+
eprintln!(" last startxref block: {:?}", printable(tail));
143+
144+
// The number after `startxref` is the byte offset of the xref section.
145+
let digits: String = tail
146+
.iter()
147+
.skip(b"startxref".len())
148+
.skip_while(|c| c.is_ascii_whitespace())
149+
.take_while(|c| c.is_ascii_digit())
150+
.map(|&c| c as char)
151+
.collect();
152+
match digits.parse::<usize>() {
153+
Ok(off) if off < bytes.len() => {
154+
let end = bytes.len().min(off + 120);
155+
eprintln!(" at offset {off} -> {:?}", printable(&bytes[off..end]));
156+
}
157+
Ok(off) => eprintln!(" startxref points to {off}, past the end of the file"),
158+
Err(_) => eprintln!(" startxref carries no parseable offset"),
159+
}
160+
}

0 commit comments

Comments
 (0)