Skip to content

Commit e74aa9c

Browse files
artizclaude
andcommitted
wasm: one demo page with the whole pipeline (#157)
The browser demo had grown into three pages: index.html (declarative only, no PDF/image, no output-format parity) plus ocr.html and scan.html, which were really OCR test harnesses. Fold everything into index.html and delete the other two. The single page now covers what the module can actually do: any supported document, PDF via its text layer, and — falling back automatically when a PDF has no text layer — scanned pages and images through the ONNX pipeline. Output picks between Markdown, docling JSON and DocLang XML; a Markdown preview renders the result. The OCR half (ORT, pdf.js, models) loads lazily, so a visitor converting a DOCX downloads none of it. Supporting changes: - convert() takes `images` ("placeholder" | "embedded"), mirroring docling-serve's option, so pictures can ride along as data URIs — there is no `referenced` mode because a page cannot write files. - ScannedConverter::finish and convert_scanned_image accept "doclang" too, so both paths offer the same three output grammars; the format is threaded through worker.js and pipeline.js. - A dead module worker (blocked CDN, offline, strict extension) used to leave every pending RPC unsettled, so the page sat on "loading …" forever. onerror/onmessageerror now reject the waiters with a message that names the cause. Verified: the page reports the failure in ~30 s instead of hanging. - Drop the temporary layout-region diagnostic probe and its JS plumbing, and the per-page console timing logs. - Inline favicon so a static host never 404s on /favicon.ico. Verified in headless Chromium against the real wasm module: DOCX → md/json/ doclang, images=placeholder vs embedded (data URI present), a corpus text-layer PDF, the Markdown preview, and cross-origin isolation via coi.js (crossOriginIsolated = true, so ORT gets threads). Refs #157 Signed-off-by: artiz <artem.kustikov@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY5KAiquN4YpVf2PXEQkVT
1 parent 68963db commit e74aa9c

10 files changed

Lines changed: 606 additions & 681 deletions

File tree

crates/docling-wasm/README.md

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,59 @@ converters and the PDF text parser are pure Rust.
2626
## API
2727

2828
```ts
29-
convert(bytes: Uint8Array, filename: string, to?: "md" | "json" | "doclang"): string
29+
convert(
30+
bytes: Uint8Array,
31+
filename: string,
32+
to?: "md" | "json" | "doclang", // default "md"
33+
images?: "placeholder" | "embedded", // default "placeholder", Markdown only
34+
): string
3035
supported_extensions(): string // JSON array, e.g. for <input accept=…>
3136
version(): string
3237
```
3338

34-
The filename's extension drives format detection, same as the CLI.
39+
The filename's extension drives format detection, same as the CLI. `images`
40+
mirrors docling-serve's option: `placeholder` emits docling's `<!-- image -->`,
41+
`embedded` inlines the picture as a `data:` URI so the Markdown is
42+
self-contained (a page has no filesystem, so there is no `referenced` mode).
43+
44+
### Convert a file the user picked
45+
46+
```js
47+
import init, { convert } from "./pkg/docling_wasm.js";
48+
await init();
49+
50+
const file = input.files[0];
51+
const bytes = new Uint8Array(await file.arrayBuffer());
52+
const markdown = convert(bytes, file.name, "md");
53+
const json = convert(bytes, file.name, "json");
54+
const withPics = convert(bytes, file.name, "md", "embedded");
55+
```
56+
57+
### Scanned pages (OCR)
58+
59+
Scanned PDFs and images need the ML models; everything else runs with no
60+
network at all. The full wiring — model resolution, Web Worker, pdf.js
61+
rasterization — is [`www/index.html`](./www/index.html); the short version:
62+
63+
```js
64+
import { createOcr } from "./pipeline.js";
65+
66+
const ocr = createOcr({ onStatus: (msg) => console.log(msg) });
67+
await ocr.boot(); // wasm + layout model
68+
69+
// A standalone image is its own page.
70+
const md = await ocr.convertImage(await file.arrayBuffer(), file.name, "en", "md");
71+
72+
// A scanned PDF: feed rasterized pages in order (2 px/point = the native
73+
// pipeline's RENDER_SCALE), then finish.
74+
await ocr.startDoc("en", /* TableFormer */ false);
75+
for (const page of pages) await ocr.addPage(page.rgba, page.w, page.h, 2.0);
76+
const doc = ocr.finishDoc(file.name, "md");
77+
```
78+
79+
Models resolve **device file → local `./models/` → Hugging Face**, so a page can
80+
ship with no models and still work: `ocr.setProvidedModels({ "layout_heron_int8.onnx": buf })`
81+
takes files the user picked, and anything not provided is fetched.
3582

3683
## Build
3784

@@ -49,15 +96,20 @@ wasm-bindgen --target web --out-dir crates/docling-wasm/www/pkg \
4996

5097
## Demo
5198

52-
[`www/index.html`](./www/index.html) is a drop-a-file demo page over the
53-
module (output selector, conversion timing, automated-test hook). After the
54-
`wasm-bindgen` step above:
99+
[`www/index.html`](./www/index.html) is the whole thing on one page: drop a
100+
file, pick the output (Markdown / JSON / DocLang), pick how images render, and
101+
optionally turn on OCR for scanned pages. After the `wasm-bindgen` step above:
55102

56103
```bash
57104
python3 -m http.server -d crates/docling-wasm/www 8901
58105
# open http://127.0.0.1:8901/
59106
```
60107

108+
It is a plain static page — copy `www/` behind any web server (or into a mobile
109+
app's webview) and it works as-is. The OCR half loads lazily, so a visitor who
110+
only converts a DOCX never downloads ONNX Runtime, pdf.js or any model. PDFs
111+
try their text layer first and fall back to OCR only when there isn't one.
112+
61113
Verified end-to-end in headless Chromium: Markdown/DOCX→md, DOCX→JSON, a
62114
corpus PDF→md through the text-layer path, and the scanned-PDF error path all
63115
exercised through the real wasm module.
@@ -113,10 +165,9 @@ Stage 3 is the same but `conv.addPageTf(rgba, w, h, 2.0, layout, rec, tf)`,
113165
where `tf` is a stateful session over the three TableFormer graphs (the heavy
114166
cross-attention K/V and the growing decoder KV-cache stay on the JS side, so
115167
each decode step marshals only the last tag and gets back logits+hidden — see
116-
[`www/pipeline.js`](./www/pipeline.js)'s `JsTfSession`). The demos wire all of
117-
this up: [`www/ocr.html`](./www/ocr.html) (stage 1),
118-
[`www/scan.html`](./www/scan.html) (stages 2–3, pdf.js + a TableFormer toggle +
119-
a device model-file picker).
168+
[`www/pipeline.js`](./www/pipeline.js)'s `JsTfSession`).
169+
[`www/index.html`](./www/index.html) wires all three stages up behind the OCR
170+
language selector, the TableFormer toggle and the model picker.
120171

121172
## Run it on your phone
122173

@@ -136,8 +187,8 @@ same-origin, from a CORS host, **or straight from files on your device**.
136187
Pages or githack.) Build the wasm `pkg/` first (see **Build** above).
137188

138189
3. **Provide the models to the page**, either:
139-
- **from your device** — tap the *"Models from device"* picker in
140-
`scan.html` and select `layout_heron_int8.onnx` and, for TableFormer,
190+
- **from your device** — tap the model picker under *"Scanned PDFs and
191+
images"* and select `layout_heron_int8.onnx` and, for TableFormer,
141192
`encoder.onnx`, `decoder_kv.onnx` (+`.data`), `bbox.onnx` (+`.data`). Each
142193
file is read to an `ArrayBuffer` on the client and used directly — no
143194
download, and a single allocation per file (a 225 MB encoder over

crates/docling-wasm/src/lib.rs

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
//! const md = convert(new Uint8Array(await file.arrayBuffer()), file.name, "md");
1717
//! ```
1818
19-
use docling::{DocumentConverter, InputFormat, SourceDocument};
19+
use docling::{DocumentConverter, ImageMode, InputFormat, SourceDocument};
2020
use wasm_bindgen::prelude::*;
2121

2222
#[cfg(feature = "ocr")]
@@ -39,16 +39,28 @@ fn start() {
3939

4040
/// The whole conversion body, host-testable (`JsError` can only be
4141
/// constructed on the wasm target, so the JS boundary stays a thin shim).
42-
fn convert_impl(bytes: &[u8], filename: &str, to: Option<&str>) -> Result<String, String> {
42+
fn convert_impl(
43+
bytes: &[u8],
44+
filename: &str,
45+
to: Option<&str>,
46+
images: Option<&str>,
47+
) -> Result<String, String> {
4348
let ext = filename.rsplit('.').next().unwrap_or_default();
4449
let format = InputFormat::from_extension(ext)
4550
.ok_or_else(|| format!("unknown or unsupported extension: {filename:?}"))?;
4651
let source = SourceDocument::from_bytes(filename.to_string(), format, bytes.to_vec());
4752
let result = DocumentConverter::new()
4853
.convert(source)
4954
.map_err(|e| e.to_string())?;
55+
let image_mode = image_mode(images)?;
5056
match to.unwrap_or("md") {
51-
"md" | "markdown" => Ok(result.document.export_to_markdown()),
57+
// `Referenced` is deliberately unreachable here: it hands the caller
58+
// loose image files to write next to the Markdown, which a page with no
59+
// filesystem cannot do — the browser equivalent is `embedded`.
60+
"md" | "markdown" => Ok(result
61+
.document
62+
.export_to_markdown_with_images(image_mode, "artifacts")
63+
.0),
5264
"json" => Ok(result.document.export_to_json()),
5365
"doclang" => Ok(result.document.export_to_doclang()),
5466
other => Err(format!(
@@ -57,13 +69,36 @@ fn convert_impl(bytes: &[u8], filename: &str, to: Option<&str>) -> Result<String
5769
}
5870
}
5971

72+
/// Picture rendering for Markdown output, mirroring docling-serve's `images`
73+
/// option: `placeholder` (docling's default `<!-- image -->`) or `embedded`
74+
/// (`![Image](data:…;base64,…)`, self-contained — the only way to carry pixels
75+
/// out of a page that cannot write files).
76+
fn image_mode(images: Option<&str>) -> Result<ImageMode, String> {
77+
match images.unwrap_or("placeholder") {
78+
"placeholder" => Ok(ImageMode::Placeholder),
79+
"embedded" => Ok(ImageMode::Embedded),
80+
other => Err(format!(
81+
"unknown images={other:?} (expected \"placeholder\" or \"embedded\")"
82+
)),
83+
}
84+
}
85+
6086
/// Convert a document (as bytes + filename, the extension drives format
6187
/// detection) to `to`: `"md"` (Markdown, default), `"json"` (docling-core's
6288
/// `DoclingDocument` wire format, schema 1.10.0) or `"doclang"` (docling's
6389
/// DocLang XML serialization).
90+
///
91+
/// `images` controls how pictures render in Markdown — `"placeholder"`
92+
/// (default) or `"embedded"` (base64 data URIs), the same option
93+
/// docling-serve exposes.
6494
#[wasm_bindgen]
65-
pub fn convert(bytes: &[u8], filename: &str, to: Option<String>) -> Result<String, JsError> {
66-
convert_impl(bytes, filename, to.as_deref()).map_err(|e| JsError::new(&e))
95+
pub fn convert(
96+
bytes: &[u8],
97+
filename: &str,
98+
to: Option<String>,
99+
images: Option<String>,
100+
) -> Result<String, JsError> {
101+
convert_impl(bytes, filename, to.as_deref(), images.as_deref()).map_err(|e| JsError::new(&e))
67102
}
68103

69104
/// The file extensions this build can convert, as a JSON string array —
@@ -98,16 +133,16 @@ mod tests {
98133
#[test]
99134
fn markdown_roundtrip() {
100135
let md = b"# Title\n\nHello *world*\n";
101-
let out = convert_impl(md, "note.md", None).unwrap();
136+
let out = convert_impl(md, "note.md", None, None).unwrap();
102137
assert!(out.contains("# Title"));
103-
let json = convert_impl(md, "note.md", Some("json")).unwrap();
138+
let json = convert_impl(md, "note.md", Some("json"), None).unwrap();
104139
assert!(json.contains("\"schema_name\""));
105140
}
106141

107142
#[test]
108143
fn ml_formats_rejected() {
109144
// Images still need the full ML pipeline.
110-
let err = convert_impl(&[0x89, b'P', b'N', b'G'], "scan.png", None).unwrap_err();
145+
let err = convert_impl(&[0x89, b'P', b'N', b'G'], "scan.png", None, None).unwrap_err();
111146
assert!(
112147
err.contains("unknown or unsupported") || err.contains("pdf"),
113148
"should reject the ML-only format: {err}"
@@ -129,7 +164,7 @@ mod tests {
129164
"/../../tests/data/pdf/sources/code_and_formula.pdf"
130165
))
131166
.expect("corpus pdf");
132-
let out = convert_impl(&bytes, "code_and_formula.pdf", None).unwrap();
167+
let out = convert_impl(&bytes, "code_and_formula.pdf", None, None).unwrap();
133168
assert!(!out.trim().is_empty(), "text layer should extract");
134169
}
135170

@@ -141,7 +176,7 @@ mod tests {
141176
if docling::PDF_ML_COMPILED {
142177
return;
143178
}
144-
let err = convert_impl(b"%PDF-1.4\n%%EOF", "scan.pdf", None).unwrap_err();
179+
let err = convert_impl(b"%PDF-1.4\n%%EOF", "scan.pdf", None, None).unwrap_err();
145180
assert!(
146181
err.contains("text layer") || err.contains("OCR"),
147182
"should point at the missing text layer: {err}"
@@ -156,10 +191,29 @@ mod tests {
156191
"/../docling/tests/data/docx/sources/docx_lists.docx"
157192
))
158193
.expect("corpus docx");
159-
let out = convert_impl(&bytes, "docx_lists.docx", None).unwrap();
194+
let out = convert_impl(&bytes, "docx_lists.docx", None, None).unwrap();
160195
assert!(!out.trim().is_empty());
161196
}
162197

198+
/// `images=embedded` inlines picture bytes as data URIs (docling-serve's
199+
/// option, and the only way a page with no filesystem can carry pixels
200+
/// out); the default stays docling's `<!-- image -->` placeholder.
201+
#[test]
202+
fn embedded_images_inline_as_data_uris() {
203+
let bytes = std::fs::read(concat!(
204+
env!("CARGO_MANIFEST_DIR"),
205+
"/../docling/tests/data/docx/sources/word_image_anchors.docx"
206+
))
207+
.expect("corpus docx with images");
208+
let placeholder = convert_impl(&bytes, "word_image_anchors.docx", None, None).unwrap();
209+
assert!(placeholder.contains("<!-- image -->"), "{placeholder}");
210+
let embedded =
211+
convert_impl(&bytes, "word_image_anchors.docx", None, Some("embedded")).unwrap();
212+
assert!(embedded.contains("](data:image/"), "expected a data URI");
213+
let err = convert_impl(&bytes, "word_image_anchors.docx", None, Some("nope")).unwrap_err();
214+
assert!(err.contains("unknown images="), "{err}");
215+
}
216+
163217
#[test]
164218
fn extensions_json_parses() {
165219
let v: Vec<String> = serde_json::from_str(&supported_extensions()).unwrap();

crates/docling-wasm/src/ocr.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
//! },
2828
//! });
2929
//! ```
30-
//! (see `www/ocr.html` for the complete wiring, including output-name
30+
//! (see `www/index.html` for the complete wiring, including output-name
3131
//! discovery and model/dict caching.)
3232
3333
use docling_core::{DoclingDocument, Node};

crates/docling-wasm/src/scanned.rs

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
//! }
1919
//! const markdown = conv.finish("scan.pdf", "md");
2020
//! ```
21-
//! (`www/scan.html` is the complete wiring.)
21+
//! (`www/index.html` is the complete wiring.)
2222
2323
use docling_pdf::layout::{decode_layout, layout_input, SIDE};
2424
use docling_pdf::ocr_prep::{
@@ -33,14 +33,6 @@ use wasm_bindgen::prelude::*;
3333
use crate::ocr::{tensor_parts, RecSession};
3434
use crate::tableformer::TfSession;
3535

36-
#[wasm_bindgen]
37-
extern "C" {
38-
// On-page diagnostic sink (defined by the host — worker.js forwards it to
39-
// the main thread, which shows it; console isn't reachable on a phone).
40-
#[wasm_bindgen(js_name = __docling_diag)]
41-
fn diag(s: &str);
42-
}
43-
4436
#[wasm_bindgen]
4537
extern "C" {
4638
/// The JS-side layout session: a wrapper around an `ort.InferenceSession`
@@ -188,19 +180,6 @@ impl ScannedConverter {
188180
let regions = decode_layout(&logits, &boxes, q, c, page_w, page_h);
189181
let regions = refine_regions(regions, &[], page_w, page_h);
190182

191-
// Diagnostic: the region-label histogram, so it's visible (browser
192-
// console) whether the layout model flagged any `table` region on this
193-
// page — tables only render when it does (geometric or TableFormer).
194-
{
195-
let mut hist: std::collections::BTreeMap<&str, usize> =
196-
std::collections::BTreeMap::new();
197-
for r in &regions {
198-
*hist.entry(r.label).or_default() += 1;
199-
}
200-
let summary: Vec<String> = hist.iter().map(|(l, n)| format!("{l}×{n}")).collect();
201-
diag(&format!("layout regions: {}", summary.join(", ")));
202-
}
203-
204183
// OCR the text regions (same gather/batch/decode as native ocr_page).
205184
let (bboxes, lines) = prep_region_lines(&img, &regions, scale);
206185
let texts = self.ocr_lines(rec, &lines).await?;
@@ -268,16 +247,26 @@ impl ScannedConverter {
268247
}
269248

270249
/// Assemble the accumulated pages into the final document and render it
271-
/// as `"md"` (default) or `"json"`. Resets the converter.
250+
/// as `"md"` (default), `"json"` or `"doclang"` — the same three the
251+
/// declarative [`crate::convert`] entry point offers. Resets the converter.
272252
pub fn finish(&mut self, name: &str, to: Option<String>) -> Result<String, JsError> {
273253
let doc = finish_document(name, std::mem::take(&mut self.pages));
274-
match to.as_deref().unwrap_or("md") {
275-
"md" | "markdown" => Ok(doc.export_to_markdown()),
276-
"json" => Ok(doc.export_to_json()),
277-
other => Err(JsError::new(&format!(
278-
"unknown output format {other:?} (expected \"md\" or \"json\")"
279-
))),
280-
}
254+
render(&doc, to.as_deref())
255+
}
256+
}
257+
258+
/// Render an assembled document in one of the three output grammars. The OCR
259+
/// pipeline recovers no picture *pixels* (regions are recognized, not cropped
260+
/// out), so Markdown always uses docling's `<!-- image -->` placeholder — there
261+
/// is nothing to embed, unlike the declarative path's `images` option.
262+
fn render(doc: &docling_core::DoclingDocument, to: Option<&str>) -> Result<String, JsError> {
263+
match to.unwrap_or("md") {
264+
"md" | "markdown" => Ok(doc.export_to_markdown()),
265+
"json" => Ok(doc.export_to_json()),
266+
"doclang" => Ok(doc.export_to_doclang()),
267+
other => Err(JsError::new(&format!(
268+
"unknown output format {other:?} (expected \"md\", \"json\" or \"doclang\")"
269+
))),
281270
}
282271
}
283272

crates/docling-wasm/src/tableformer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
//! encoder's constant cross-attention K/V and `enc_out`, and the growing
1010
//! decoder KV-cache, entirely on the JS side. Each decode step sends only the
1111
//! last tag (one int) and gets back `logits` + `hidden` (525 floats). See
12-
//! `www/scan.html` for the JS wiring (the `decoder_kv` graph: N_LAYERS=6,
12+
//! `www/index.html` for the JS wiring (the `decoder_kv` graph: N_LAYERS=6,
1313
//! KV_HEADS=8, head_dim=64, cross length 784).
1414
1515
use docling_pdf::pdfium_backend::TextCell;

0 commit comments

Comments
 (0)