Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Publish the in-browser demo (crates/docling-wasm/www) to GitHub Pages, so
# docling.rs can be tried from any device without a checkout or a toolchain.
#
# The site is fully static: the wasm module is built here and copied in next to
# the page. No models are published — they are large, and the page resolves them
# at runtime (device file → same-origin ./models/ → Hugging Face), so the
# declarative converters and text-layer PDFs work immediately and OCR works as
# soon as the visitor supplies models.
#
# GitHub Pages cannot send COOP/COEP headers, which ONNX Runtime Web needs for
# threads; www/coi.js installs a service worker that adds them, so the deployed
# page still gets multi-threaded wasm.
#
# One-time setup in the repository: Settings → Pages → Build and deployment →
# Source: "GitHub Actions".
name: pages

on:
push:
branches: [master]
paths:
- "crates/docling-wasm/**"
- "crates/docling/**"
- "crates/docling-core/**"
- "crates/docling-pdf/**"
- ".github/workflows/pages.yml"
# Deploy on demand too (e.g. the first run, before any matching push).
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# One deployment at a time; let a running one finish so the site is never
# left half-published.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
name: build the demo
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install Rust (stable + wasm32 target)
run: |
rustup toolchain install stable --profile minimal
rustup default stable
rustup target add wasm32-unknown-unknown

- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: ${{ runner.os }}-cargo-v2-wasm-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-v2-wasm-

- name: Build docling-wasm (release)
run: cargo build -p docling-wasm --target wasm32-unknown-unknown --release --locked

- name: Generate the JS bindings
run: |
# Pin wasm-bindgen-cli to the wasm-bindgen version in Cargo.lock: a
# mismatch produces glue the module rejects at load time.
version=$(cargo metadata --format-version 1 --locked \
| python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"]=="wasm-bindgen"))')
echo "wasm-bindgen $version"
cargo install wasm-bindgen-cli --version "$version" --locked
wasm-bindgen --target web --out-dir crates/docling-wasm/www/pkg \
target/wasm32-unknown-unknown/release/docling_wasm.wasm

- name: Assemble the site
run: |
mkdir -p site
cp -r crates/docling-wasm/www/. site/
# Jekyll would swallow paths that start with an underscore.
touch site/.nojekyll
ls -la site site/pkg

- uses: actions/upload-pages-artifact@v3
with:
path: site

deploy:
name: deploy
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ pipeline is supported, validated byte-for-byte against live docling. See
[`docs/MIGRATION.md`](./docs/MIGRATION.md) for the full architecture, the Python → Rust
mapping, and per-format conformance.

**▶ [Try it in your browser](https://docling-project.github.io/docling.rs/)** —
the whole converter compiled to wasm: drop a DOCX, PDF, XLSX, EPUB … and get
Markdown, docling JSON or DocLang XML back. Nothing is uploaded; the page runs
entirely on your device, phone included. Scanned pages can be OCR'd there too
(layout + PP-OCR + TableFormer via ONNX Runtime Web) once you point it at the
models. See [`crates/docling-wasm`](./crates/docling-wasm/README.md).

Developed with **Claude Code** and _[TENET](https://github.com/artiz/tenet/tree/master)_ (minimalistic spec driven design framework).

## Status
Expand Down
5 changes: 5 additions & 0 deletions crates/docling-pdf/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,10 @@ tokenizers = { version = "0.20", default-features = false, features = ["onig"],
[target.'cfg(target_arch = "wasm32")'.dependencies]
lopdf = { version = "0.44", features = ["wasm_js"] }

# The text-layer diagnostic example reaches for lopdf directly to tell a load
# failure apart from "loaded, but no text came out".
[dev-dependencies]
lopdf = "0.44"

[lib]
name = "docling_pdf"
160 changes: 160 additions & 0 deletions crates/docling-pdf/examples/text_layer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//! Run **exactly** what a wasm build does with a PDF: `convert_text_layer` —
//! the pure-Rust content-stream parser, no pdfium, no ONNX. Use it to tell a
//! browser "this PDF needs OCR" apart from a genuine scan: the browser falls
//! back to OCR precisely when this produces no nodes, so an empty result here
//! reproduces that decision offline.
//!
//! Note this is *not* what the CLI's `--no-ocr` runs — that goes through
//! pdfium's text extraction, which can read layers this parser cannot. When the
//! two disagree, the breakdown below says which stage lost the text.
//!
//! ```bash
//! cargo run -p docling-pdf --no-default-features --example text_layer -- <pdf>
//! ```
//! (`--no-default-features` keeps pdfium/onnxruntime out of the build.)

fn main() {
let path = match std::env::args().nth(1) {
Some(p) => p,
None => {
eprintln!("usage: text_layer <pdf>");
std::process::exit(2);
}
};
let bytes = std::fs::read(&path).expect("read the pdf");
let name = std::path::Path::new(&path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.clone());

// Stage 1 — does lopdf load the document at all? `textparse` swallows this
// failure (an unloadable PDF and a scan both come out empty), so ask
// directly: encryption, a damaged xref or an unsupported filter all land
// here, and they mean something very different from "no text layer".
match lopdf::Document::load_mem(&bytes) {
Err(e) => {
eprintln!("1. lopdf load: FAILED — {e}");
eprintln!(" (the browser reports this as \"no embedded text layer\")");
eprintln!(
" xref repair: {}",
docling_pdf::textparse::xref_repair_status(&bytes)
);
probe_tail(&bytes);
}
Ok(doc) => {
eprintln!(
"1. lopdf load: ok — {} page(s), version {}, encrypted: {}",
doc.get_pages().len(),
doc.version,
doc.is_encrypted()
);
}
}

// Stage 2 — raw line cells out of the content streams. Zero here with a
// successful load means the text is there but we cannot decode it (font
// encoding, an unhandled operator, a filter), not that the page is a scan.
let pages = docling_pdf::textparse::pdf_textlines(&bytes);
let cells: usize = pages.iter().map(|(_, _, c)| c.len()).sum();
eprintln!("2. text lines: {cells} across {} page(s)", pages.len());
if cells == 0 {
eprintln!(
" where it is lost:{}",
docling_pdf::textparse::content_diagnosis(&bytes)
);
}
for (i, (_, _, c)) in pages.iter().enumerate().take(3) {
if let Some(first) = c.iter().find(|c| !c.text.trim().is_empty()) {
eprintln!(" page {}: first line {:?}", i + 1, first.text);
}
}

// Stage 3 — the assembled document, i.e. what the browser actually gets.
match docling_pdf::convert_text_layer(&bytes, &name) {
Ok(doc) => {
let md = doc.export_to_markdown();
eprintln!(
"3. convert_text_layer: {} node(s), {} chars — {}",
doc.nodes.len(),
md.len(),
if doc.nodes.is_empty() {
"EMPTY: a browser build would fall back to OCR here"
} else {
"the browser would use this directly (no OCR)"
}
);
println!("{md}");
}
Err(e) => {
eprintln!("3. convert_text_layer: FAILED — {e}");
std::process::exit(1);
}
}
}

/// When the document will not load, show the cross-reference machinery the
/// parser choked on: the last `startxref`, where it points, and what actually
/// sits there. A classic `xref` table, an xref *stream* (`/Type /XRef`), bytes
/// past the end of the file or plain garbage each point at a different repair,
/// and this is enough to tell them apart without handing the PDF around.
fn probe_tail(bytes: &[u8]) {
let find_last = |needle: &[u8]| {
bytes
.windows(needle.len())
.rposition(|w| w == needle)
.map(|i| (i, needle.len()))
};
let printable = |b: &[u8]| -> String {
b.iter()
.map(|&c| {
if (0x20..0x7f).contains(&c) {
c as char
} else if c == b'\n' || c == b'\r' {
'\u{23ce}'
} else {
'.'
}
})
.collect()
};

eprintln!(" file: {} bytes", bytes.len());
eprintln!(
" markers: trailer={} startxref={} xref={} /XRef={} %%EOF={}",
find_last(b"trailer").is_some(),
find_last(b"startxref").is_some(),
find_last(b"xref").is_some(),
find_last(b"/XRef").is_some(),
find_last(b"%%EOF").is_some(),
);

// Trailing bytes after the final %%EOF confuse a strict tail scan.
if let Some((i, len)) = find_last(b"%%EOF") {
let after = bytes.len() - (i + len);
eprintln!(" bytes after the last %%EOF: {after}");
}

let Some((sx, _)) = find_last(b"startxref") else {
eprintln!(" no startxref at all — the xref would have to be rebuilt by scanning objects");
return;
};
let tail = &bytes[sx..bytes.len().min(sx + 60)];
eprintln!(" last startxref block: {:?}", printable(tail));

// The number after `startxref` is the byte offset of the xref section.
let digits: String = tail
.iter()
.skip(b"startxref".len())
.skip_while(|c| c.is_ascii_whitespace())
.take_while(|c| c.is_ascii_digit())
.map(|&c| c as char)
.collect();
match digits.parse::<usize>() {
Ok(off) if off < bytes.len() => {
let end = bytes.len().min(off + 120);
eprintln!(" at offset {off} -> {:?}", printable(&bytes[off..end]));
}
Ok(off) => eprintln!(" startxref points to {off}, past the end of the file"),
Err(_) => eprintln!(" startxref carries no parseable offset"),
}
}
Loading
Loading