Skip to content

Commit 24163e2

Browse files
authored
Merge pull request #167 from artiz/claude/wasm-npm-package
wasm: publish docling.rs-wasm to npm; document the Tauri paths
2 parents e10dea3 + 73098dc commit 24163e2

6 files changed

Lines changed: 346 additions & 7 deletions

File tree

.github/workflows/npm-publish.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,89 @@ jobs:
366366
run: cd cuda/pkg && npm publish --access public
367367
env:
368368
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
369+
370+
# docling.rs-wasm: the browser/WebAssembly package (no native matrix — one
371+
# Linux runner compiles wasm32-unknown-unknown and publishes). Same
372+
# ref-selection and idempotency as the main package: builds the checked-out
373+
# workspace version, skips cleanly when that version is already on npm.
374+
publish-wasm:
375+
name: publish docling.rs-wasm
376+
runs-on: ubuntu-latest
377+
defaults:
378+
run:
379+
working-directory: .
380+
steps:
381+
- uses: actions/checkout@v7
382+
with:
383+
ref: ${{ inputs.tag || github.ref }}
384+
385+
- name: Install Rust (stable + wasm32 target)
386+
run: |
387+
rustup toolchain install stable --profile minimal
388+
rustup default stable
389+
rustup target add wasm32-unknown-unknown
390+
391+
- uses: actions/cache@v4
392+
with:
393+
path: |
394+
~/.cargo/registry/index
395+
~/.cargo/registry/cache
396+
~/.cargo/git/db
397+
~/.cargo/bin
398+
target
399+
key: ${{ runner.os }}-cargo-v2-wasm-npm-${{ hashFiles('**/Cargo.lock') }}
400+
restore-keys: ${{ runner.os }}-cargo-v2-wasm-npm-
401+
402+
- uses: actions/setup-node@v4
403+
with:
404+
node-version: 22
405+
registry-url: "https://registry.npmjs.org"
406+
407+
- name: Install wasm-bindgen-cli (pinned to Cargo.lock)
408+
run: |
409+
# A CLI/crate version mismatch produces glue the module rejects at
410+
# load time — pin to the workspace's wasm-bindgen (same as pages.yml).
411+
version=$(cargo metadata --format-version 1 --locked \
412+
| python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"]=="wasm-bindgen"))')
413+
echo "wasm-bindgen $version"
414+
command -v wasm-bindgen >/dev/null && [ "$(wasm-bindgen --version)" = "wasm-bindgen $version" ] \
415+
|| cargo install wasm-bindgen-cli --version "$version" --locked
416+
417+
- name: Build the package
418+
run: bash scripts/ci/build_wasm_pkg.sh target/npm-wasm
419+
420+
# Skip cleanly if this version is already on npm (idempotent re-runs).
421+
- name: Check if already published
422+
id: wcheck
423+
run: |
424+
v="$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
425+
echo "version=$v" >> "$GITHUB_OUTPUT"
426+
if npm view "docling.rs-wasm@$v" version >/dev/null 2>&1; then
427+
echo "published=true" >> "$GITHUB_OUTPUT"
428+
echo "docling.rs-wasm@$v is already on npm — skipping."
429+
else
430+
echo "published=false" >> "$GITHUB_OUTPUT"
431+
fi
432+
433+
# Load the module in Node off the packed tarball (exactly what npm will
434+
# serve) and convert a real file before publishing anything.
435+
- name: Validate the packed package
436+
if: steps.wcheck.outputs.published == 'false'
437+
run: |
438+
(cd target/npm-wasm && npm pack >/dev/null)
439+
mkdir -p /tmp/wasm-check && tar -xzf target/npm-wasm/*.tgz -C /tmp/wasm-check --strip-components=1
440+
node --input-type=module -e '
441+
import { readFileSync } from "node:fs";
442+
const m = await import("/tmp/wasm-check/web/docling_wasm.js");
443+
await m.default({ module_or_path: readFileSync("/tmp/wasm-check/web/docling_wasm_bg.wasm") });
444+
const html = new TextEncoder().encode("<html><body><h1>T</h1><p>ok</p></body></html>");
445+
const md = m.convert(html, "t.html", "md");
446+
if (!md.includes("# T")) throw new Error("bad output: " + md);
447+
console.log("docling.rs-wasm@" + m.version() + " converts: " + JSON.stringify(md));
448+
'
449+
450+
- name: Publish
451+
if: steps.wcheck.outputs.published == 'false'
452+
run: cd target/npm-wasm && npm publish --access public
453+
env:
454+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,18 @@ pipelines) compile to `wasm32-unknown-unknown`:
152152
[`crates/docling-wasm`](./crates/docling-wasm) exposes
153153
`convert(bytes, filename, to)` → Markdown / docling JSON / DocLang via
154154
`wasm-bindgen`, so DOCX/HTML/XLSX/PPTX/EPUB/… convert **fully client-side**
155-
no server, ~1.9 MB gzipped module, no models to download — something Python
156-
docling has no equivalent for. Digital PDFs convert too: the opt-in
155+
no server, ~3.4 MB gzipped module, no models to download — something Python
156+
docling has no equivalent for. Ready to use from npm:
157+
158+
```bash
159+
npm i docling.rs-wasm
160+
```
161+
162+
```js
163+
import { convert } from "docling.rs-wasm"; // bundlers
164+
// import init, { convert } from "docling.rs-wasm/web"; await init(); // no bundler
165+
const markdown = convert(bytes, file.name, "md");
166+
``` Digital PDFs convert too: the opt-in
157167
`pdf-text` feature runs docling-pdf's pure-Rust text-layer parser (the same
158168
extraction as `--no-ocr`: flat paragraphs, no headings/tables/pictures),
159169
while scanned PDFs get a clear "needs OCR" error instead of an empty
@@ -1007,7 +1017,7 @@ on a 1913-page document — see [`docs/PDF_CONFORMANCE.md`](./docs/PDF_CONFORMAN
10071017
| `docling-serve` | HTTP conversion API over a warm pipeline | `docling-serve` |
10081018
| `docling-node` | Node.js / Bun N-API bindings | https://www.npmjs.com/package/docling.rs |
10091019
| `docling-py` | Python bindings | https://pypi.org/project/docling-rs |
1010-
| `docling-wasm` | WebAssembly bindings (declarative converters + PDF text layer in the browser) | |
1020+
| `docling-wasm` | WebAssembly bindings (declarative converters + PDF text layer in the browser) | https://www.npmjs.com/package/docling.rs-wasm |
10111021
| `docling-rag` | RAG layer: chunking, embeddings, vector search, REST API ||
10121022

10131023
## License

crates/docling-wasm/README.md

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,33 @@ images/audio/METS are rejected with a "rebuild with …" message. Remote
1818
`<img src>` images stay placeholders (no network in the module); embedded
1919
images work normally.
2020

21-
**Size: ~5.6 MB raw, ~1.9 MB gzipped** (measured on this crate at 0.41.x,
22-
`--release` with the workspace's `lto = "thin"`; no `wasm-opt` pass — one
23-
typically shaves another 10–15%). No models are involved: the declarative
24-
converters and the PDF text parser are pure Rust.
21+
**Size: ~11 MB raw, ~3.4 MB gzipped** (measured at 0.49.x — the module now
22+
also carries the browser ML pipeline's pre/post-processing: layout decode, OCR
23+
prep, TableFormer core; `--release` with the workspace's `lto = "thin"`, no
24+
`wasm-opt` pass — one typically shaves another 10–15%). No models are involved
25+
for the declarative converters and the PDF text parser: pure Rust.
26+
27+
## Install from npm
28+
29+
```bash
30+
npm i docling.rs-wasm
31+
```
32+
33+
```js
34+
// Bundlers (Vite, webpack 5 with experiments.asyncWebAssembly):
35+
import { convert } from "docling.rs-wasm";
36+
37+
// No bundler (plain <script type="module">): the /web target + explicit init.
38+
import init, { convert } from "docling.rs-wasm/web";
39+
await init();
40+
```
41+
42+
The package ships both wasm-bindgen targets — `bundler` (the default export)
43+
and `web` (self-initializing, fetches `docling_wasm_bg.wasm` next to the JS).
44+
It is assembled by `scripts/ci/build_wasm_pkg.sh` and published by the
45+
`npm publish` workflow alongside [`docling.rs`](https://www.npmjs.com/package/docling.rs)
46+
(the native Node bindings — prefer those on servers: full ML pipeline, no wasm
47+
limits).
2548

2649
## API
2750

@@ -260,6 +283,64 @@ Resolution order for every model is **device file → local `./models/` →
260283
`MODEL_BASE` (Hugging Face)**. Tables need no upload beyond the model files;
261284
the image never leaves the page.
262285

286+
## Tauri and other desktop shells
287+
288+
Two ways to put docling.rs in a desktop app, and they are not equivalent:
289+
290+
**Native backend (recommended for Tauri).** A Tauri app's backend *is* Rust,
291+
so skip wasm entirely and depend on the real crates — the webview only renders
292+
the result. This gets the full native pipeline: pdfium rendering, the complete
293+
ML stack (RT-DETR + TableFormer + OCR + enrichment), GPU execution providers,
294+
multi-threading, memory-mapped models — none of which fit the wasm build.
295+
296+
```toml
297+
# src-tauri/Cargo.toml
298+
[dependencies]
299+
docling = { version = "0.49" } # full default features (ml pipeline)
300+
```
301+
302+
```rust
303+
// src-tauri/src/lib.rs
304+
use docling::{DocumentConverter, SourceDocument};
305+
306+
#[tauri::command]
307+
async fn convert_document(path: String) -> Result<String, String> {
308+
// The ML pipeline is CPU-heavy — keep it off the event loop.
309+
tauri::async_runtime::spawn_blocking(move || {
310+
let source = SourceDocument::from_file(&path).map_err(|e| e.to_string())?;
311+
let result = DocumentConverter::new()
312+
.convert(source)
313+
.map_err(|e| e.to_string())?;
314+
Ok(result.document.export_to_markdown())
315+
})
316+
.await
317+
.map_err(|e| e.to_string())?
318+
}
319+
```
320+
321+
Ship the `models/` directory as a Tauri resource (or fetch on first run with
322+
`scripts/install/download_dependencies.sh`'s URLs) and point the resolver at
323+
it — model paths resolve CWD-relative with env overrides
324+
(`PDFIUM_DYNAMIC_LIB_PATH`, `DOCLING_LAYOUT_ONNX`, …), so set the resource dir
325+
as the working directory or export the variables before the first conversion.
326+
327+
**Wasm in the webview.** `docling.rs-wasm` runs unchanged inside Tauri's (or
328+
Electron's) webview — worth it only when the same SPA must also ship as a
329+
plain web page and you want one code path. The wasm limits apply (declarative
330+
formats + PDF text layer; browser ML needs ONNX Runtime Web + models). One
331+
Tauri-specific note: multi-threaded ONNX Runtime Web needs COOP/COEP headers —
332+
on GitHub Pages the demo fakes them with a service worker (`www/coi.js`), but
333+
in Tauri you control the protocol, so serve the app with
334+
`Cross-Origin-Opener-Policy: same-origin` and
335+
`Cross-Origin-Embedder-Policy: require-corp` directly (e.g. via the
336+
`app.security.headers` config or a custom protocol handler) and drop the
337+
service-worker shim.
338+
339+
**Electron.** Same fork: prefer the native
340+
[`docling.rs`](https://www.npmjs.com/package/docling.rs) N-API package in the
341+
main process (`ipcMain.handle("convert", …)`), keep `docling.rs-wasm` for
342+
renderer-only/sandboxed setups.
343+
263344
## Performance & threading
264345

265346
- **Threads.** ONNX Runtime Web runs single-threaded unless the page is

crates/docling-wasm/npm/README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# docling.rs-wasm
2+
3+
[docling.rs](https://github.com/docling-project/docling.rs) compiled to
4+
WebAssembly: convert **DOCX, HTML, XLSX, PPTX, CSV, AsciiDoc, EPUB, ODF,
5+
Markdown, WebVTT, Email, MHTML, JATS, USPTO, XBRL, LaTeX, JSON, DocLang — and
6+
the embedded text layer of PDFs** — to Markdown / docling JSON / DocLang XML
7+
**entirely in the browser**. No server; the file never leaves the page.
8+
9+
~1.9 MB gzipped, no models needed for any of the above. Scanned PDFs and
10+
images additionally work through the in-browser ML pipeline (RT-DETR layout +
11+
PP-OCRv3 + TableFormer via [ONNX Runtime Web](https://www.npmjs.com/package/onnxruntime-web))
12+
once you provide the models — see the
13+
[full docs](https://github.com/docling-project/docling.rs/tree/master/crates/docling-wasm#readme)
14+
and the [live demo](https://docling-project.github.io/docling.rs/).
15+
16+
## Bundlers (Vite, webpack, …)
17+
18+
```js
19+
import { convert, supported_extensions } from "docling.rs-wasm";
20+
21+
const file = input.files[0];
22+
const bytes = new Uint8Array(await file.arrayBuffer());
23+
const markdown = convert(bytes, file.name, "md");
24+
const json = convert(bytes, file.name, "json");
25+
const withPics = convert(bytes, file.name, "md", "embedded"); // data: URIs
26+
```
27+
28+
(The bundler target loads the wasm as a module import — Vite needs no config;
29+
webpack 5 needs `experiments.asyncWebAssembly = true`.)
30+
31+
## No bundler (plain `<script type="module">`)
32+
33+
```js
34+
import init, { convert } from "docling.rs-wasm/web";
35+
await init(); // fetches docling_wasm_bg.wasm next to the JS
36+
37+
const markdown = convert(bytes, file.name, "md");
38+
```
39+
40+
## API
41+
42+
```ts
43+
convert(
44+
bytes: Uint8Array,
45+
filename: string, // extension drives format detection
46+
to?: "md" | "json" | "doclang", // default "md"
47+
images?: "placeholder" | "embedded", // default "placeholder", Markdown only
48+
max_pages?: number, // convert only the first N PDF pages
49+
): string
50+
supported_extensions(): string // JSON array, e.g. for <input accept=…>
51+
version(): string
52+
```
53+
54+
Structured digital-PDF conversion (`DigitalConverter`: headings, lists,
55+
tables, pictures via the layout model), scanned-document OCR
56+
(`ScannedConverter`) and TableFormer table structure are exported too — they
57+
need ONNX Runtime Web sessions on the JS side; the
58+
[crate README](https://github.com/docling-project/docling.rs/tree/master/crates/docling-wasm#readme)
59+
has the complete wiring, and the
60+
[demo page source](https://github.com/docling-project/docling.rs/tree/master/crates/docling-wasm/www)
61+
is a working reference.
62+
63+
## Tauri / Electron
64+
65+
The package runs in any webview as-is — but in a Tauri app you usually want
66+
the **native** pipeline in the Rust backend instead (full OCR, TableFormer,
67+
GPU, no wasm limits). See
68+
[“Tauri and other desktop shells”](https://github.com/docling-project/docling.rs/tree/master/crates/docling-wasm#tauri-and-other-desktop-shells)
69+
in the crate README.
70+
71+
## Related packages
72+
73+
- [`docling.rs`](https://www.npmjs.com/package/docling.rs) — native Node.js /
74+
Bun bindings with the full ML pipeline (use this on servers).
75+
- [`docling.rs-cuda`](https://www.npmjs.com/package/docling.rs-cuda) — the
76+
same with CUDA execution providers.
77+
- [`docling-rs`](https://pypi.org/project/docling-rs/) — Python wheel.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "docling.rs-wasm",
3+
"version": "0.0.0",
4+
"description": "In-browser document conversion: DOCX, HTML, XLSX, PPTX, EPUB, Markdown, … and the text layer of PDFs → Markdown / docling JSON / DocLang, fully client-side (docling.rs compiled to WebAssembly)",
5+
"license": "MIT",
6+
"repository": {
7+
"type": "git",
8+
"url": "git+https://github.com/docling-project/docling.rs.git",
9+
"directory": "crates/docling-wasm"
10+
},
11+
"homepage": "https://github.com/docling-project/docling.rs/tree/master/crates/docling-wasm#readme",
12+
"keywords": [
13+
"docling",
14+
"document",
15+
"conversion",
16+
"markdown",
17+
"pdf",
18+
"docx",
19+
"xlsx",
20+
"pptx",
21+
"wasm",
22+
"webassembly",
23+
"browser",
24+
"ocr"
25+
],
26+
"type": "module",
27+
"main": "bundler/docling_wasm.js",
28+
"types": "bundler/docling_wasm.d.ts",
29+
"exports": {
30+
".": {
31+
"types": "./bundler/docling_wasm.d.ts",
32+
"default": "./bundler/docling_wasm.js"
33+
},
34+
"./web": {
35+
"types": "./web/docling_wasm.d.ts",
36+
"default": "./web/docling_wasm.js"
37+
},
38+
"./web/docling_wasm_bg.wasm": "./web/docling_wasm_bg.wasm",
39+
"./bundler/docling_wasm_bg.wasm": "./bundler/docling_wasm_bg.wasm"
40+
},
41+
"sideEffects": [
42+
"./bundler/docling_wasm.js"
43+
],
44+
"files": [
45+
"bundler",
46+
"web",
47+
"README.md",
48+
"LICENSE"
49+
]
50+
}

scripts/ci/build_wasm_pkg.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Assemble the `docling.rs-wasm` npm package: compile docling-wasm for
4+
# wasm32-unknown-unknown, run wasm-bindgen for the `bundler` (import from
5+
# "docling.rs-wasm") and `web` (import from "docling.rs-wasm/web" + init())
6+
# targets, and lay the results out next to the package skeleton from
7+
# crates/docling-wasm/npm/, versioned from the workspace Cargo.toml.
8+
#
9+
# The wasm-bindgen CLI must match the wasm-bindgen crate version in Cargo.lock
10+
# (the generated glue is version-locked); npm-publish.yml installs the pinned
11+
# version the same way pages.yml does.
12+
#
13+
# Usage: scripts/ci/build_wasm_pkg.sh [outdir] (default target/npm-wasm)
14+
set -euo pipefail
15+
cd "$(dirname "$0")/../.."
16+
17+
out="${1:-target/npm-wasm}"
18+
19+
cargo build -p docling-wasm --target wasm32-unknown-unknown --release --locked
20+
wasm="target/wasm32-unknown-unknown/release/docling_wasm.wasm"
21+
22+
rm -rf "$out"
23+
mkdir -p "$out"
24+
for tgt in bundler web; do
25+
wasm-bindgen --target "$tgt" --out-dir "$out/$tgt" "$wasm"
26+
done
27+
28+
cp crates/docling-wasm/npm/package.json crates/docling-wasm/npm/README.md "$out/"
29+
cp LICENSE "$out/"
30+
31+
version="$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
32+
(cd "$out" && npm version "$version" --no-git-tag-version --allow-same-version >/dev/null)
33+
34+
echo ">> $out ready: docling.rs-wasm@$version"
35+
du -sh "$out"/bundler/docling_wasm_bg.wasm

0 commit comments

Comments
 (0)