From efdb5d4a445b56152f499754b4cc374da7b115bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 13:36:34 +0000 Subject: [PATCH 1/3] fix(pdf): warn on silent TableFormer fallback; disable mem-pattern on decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TableFormer's model paths (DOCLING_TABLEFORMER_ENCODER/_DECODER/_BBOX) default to relative paths ("models/tableformer/*.onnx") when unset, unlike layout/OCR's env vars which are consistently documented with absolute paths everywhere. Every doc/script that sets up the manual local-model workflow (README.md, pdf_setup.sh, pdf_conformance.sh, pdf_groundtruth.sh, performance.sh) only exported the layout/OCR vars, never TableFormer's — so anyone running the CLI or an embedding binding (e.g. fleischwolf-node) from any directory other than the exact one the relative default resolves against silently got the geometric table-reconstruction fallback instead of ML table-structure recognition, with zero diagnostic signal. - tableformer.rs: emit a one-time stderr note when the models can't be found, naming the exact paths checked, instead of returning None with no trace. - README.md + the four pdf_*.sh/performance.sh scripts: export DOCLING_TABLEFORMER_ENCODER/_DECODER/_BBOX as absolute paths alongside layout/OCR, so local dev setups aren't CWD-dependent (fleischwolf-node's deps.js already did this correctly via installDependencies(); this brings the manual-setup docs/scripts to parity with it). - tableformer.rs: disable ONNX Runtime's memory-pattern optimizer specifically on the decoder session. The decoder's KV-cache grows by one entry every autoregressive step, so its input shapes differ on every `run()` call; memory-pattern optimization assumes stable shapes to plan buffer reuse, and strace showed the decoder's external-weights file (decoder.onnx.data) being re-opened on what looks like every decode step. This is `ort`'s own documented guidance for variable-input-size sessions — not independently verified against live models in this environment (no models available here), worth confirming with strace against a real corpus. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DofkqhMuAJbL9arnnuVisL --- README.md | 8 +++++ crates/fleischwolf-pdf/src/tableformer.rs | 36 +++++++++++++++++++++-- scripts/pdf_conformance.sh | 6 ++++ scripts/pdf_groundtruth.sh | 4 +++ scripts/pdf_setup.sh | 9 ++++++ scripts/performance.sh | 3 ++ 6 files changed, 64 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 03e46cf2..d3e1fb37 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,14 @@ export PDFIUM_DYNAMIC_LIB_PATH="$(pwd)/.pdfium/lib" export DOCLING_LAYOUT_ONNX="$(pwd)/models/layout_heron.onnx" export DOCLING_OCR_REC_ONNX="$(pwd)/models/ocr_rec.onnx" export DOCLING_OCR_DICT="$(pwd)/models/ppocr_keys_v1.txt" +# Optional (falls back to geometric table reconstruction if unset/missing — +# but the fallback is *silent*, so set these to be sure TableFormer is used, +# especially if you invoke fleischwolf from anywhere but the repo root: the +# defaults baked into the binary are relative paths, so a different working +# directory makes them silently miss even when the files exist elsewhere). +export DOCLING_TABLEFORMER_ENCODER="$(pwd)/models/tableformer/encoder.onnx" +export DOCLING_TABLEFORMER_DECODER="$(pwd)/models/tableformer/decoder.onnx" +export DOCLING_TABLEFORMER_BBOX="$(pwd)/models/tableformer/bbox.onnx" bash scripts/pdf_conformance.sh # regenerate + diff the snapshot baseline (91/91) ``` diff --git a/crates/fleischwolf-pdf/src/tableformer.rs b/crates/fleischwolf-pdf/src/tableformer.rs index 9f4f6844..2965c039 100644 --- a/crates/fleischwolf-pdf/src/tableformer.rs +++ b/crates/fleischwolf-pdf/src/tableformer.rs @@ -90,17 +90,31 @@ impl TableFormer { .iter() .any(|p| !std::path::Path::new(p).exists()) { + // The geometric fallback is a supported, intentional configuration + // (docling has no ML table-structure equivalent baked in either), so + // this stays a single quiet stderr note rather than an error — but it + // fires every process (not per-worker) so a CWD-relative default that + // silently misses its files (a very easy mistake for anything not run + // from the repo root, e.g. an embedding app) is at least visible once. + warn_missing_once(&enc, &dec, &bbx); return None; } - let build = |path: &str| -> Result { + // The decoder's KV-cache grows by one entry every autoregressive step, so + // its input shapes differ on every `run()` call. ONNX Runtime's memory + // pattern optimizer assumes stable shapes to plan buffer reuse; disabling + // it for this session avoids repeatedly re-validating/re-touching that + // plan (and the external-weights file) on each step. + let build = |path: &str, mem_pattern: bool| -> Result { Session::builder() .map_err(|e| e.to_string())? .with_intra_threads(intra) .map_err(|e| e.to_string())? + .with_memory_pattern(mem_pattern) + .map_err(|e| e.to_string())? .commit_from_file(path) .map_err(|e| format!("tableformer load {path}: {e}")) }; - match (build(&enc), build(&dec), build(&bbx)) { + match (build(&enc, true), build(&dec, false), build(&bbx, true)) { (Ok(encoder), Ok(decoder), Ok(bbox)) => Some(Self { encoder, decoder, @@ -397,6 +411,24 @@ impl TableFormer { } } +/// Note once per process that TableFormer's ONNX graphs weren't found, so tables +/// fall back to geometric reconstruction. The default paths are relative +/// (`models/tableformer/*.onnx`), which only resolves when the process's current +/// directory happens to be the repo root — a very easy miss for anything else +/// (an embedding app, a binding invoked from a different working directory, …), +/// and previously failed with no signal at all. +fn warn_missing_once(enc: &str, dec: &str, bbx: &str) { + static WARNED: std::sync::Once = std::sync::Once::new(); + WARNED.call_once(|| { + eprintln!( + "fleischwolf: TableFormer models not found (checked {enc}, {dec}, {bbx}); \ + tables will use geometric reconstruction instead of ML table-structure \ + recognition. Set DOCLING_TABLEFORMER_ENCODER / DOCLING_TABLEFORMER_DECODER \ + / DOCLING_TABLEFORMER_BBOX to enable it (see README.md)." + ); + }); +} + /// docling's preprocessing: bilinear (cv2.INTER_LINEAR) resize the crop to 448², /// normalize `(x/255 − mean)/std`, laid out as (C, W, H) — docling transposes /// (2,1,0), so width is the major spatial axis. The page→1024px box-average diff --git a/scripts/pdf_conformance.sh b/scripts/pdf_conformance.sh index f21a0716..bceae75e 100755 --- a/scripts/pdf_conformance.sh +++ b/scripts/pdf_conformance.sh @@ -10,6 +10,12 @@ export PDFIUM_DYNAMIC_LIB_PATH="${PDFIUM_DYNAMIC_LIB_PATH:-$(pwd)/.pdfium/lib}" export DOCLING_LAYOUT_ONNX="${DOCLING_LAYOUT_ONNX:-$(pwd)/models/layout_heron.onnx}" export DOCLING_OCR_REC_ONNX="${DOCLING_OCR_REC_ONNX:-$(pwd)/models/ocr_rec.onnx}" export DOCLING_OCR_DICT="${DOCLING_OCR_DICT:-$(pwd)/models/ppocr_keys_v1.txt}" +# Optional: falls back to geometric table reconstruction if missing. Exported +# explicitly (not just relying on the binary's relative-path default) so the +# regenerated baseline always reflects TableFormer when it's present locally. +export DOCLING_TABLEFORMER_ENCODER="${DOCLING_TABLEFORMER_ENCODER:-$(pwd)/models/tableformer/encoder.onnx}" +export DOCLING_TABLEFORMER_DECODER="${DOCLING_TABLEFORMER_DECODER:-$(pwd)/models/tableformer/decoder.onnx}" +export DOCLING_TABLEFORMER_BBOX="${DOCLING_TABLEFORMER_BBOX:-$(pwd)/models/tableformer/bbox.onnx}" for f in "$PDFIUM_DYNAMIC_LIB_PATH/libpdfium.so" "$DOCLING_LAYOUT_ONNX" \ "$DOCLING_OCR_REC_ONNX" "$DOCLING_OCR_DICT"; do diff --git a/scripts/pdf_groundtruth.sh b/scripts/pdf_groundtruth.sh index 891a30f8..2002a532 100755 --- a/scripts/pdf_groundtruth.sh +++ b/scripts/pdf_groundtruth.sh @@ -14,6 +14,10 @@ export PDFIUM_DYNAMIC_LIB_PATH="${PDFIUM_DYNAMIC_LIB_PATH:-$(pwd)/.pdfium/lib}" export DOCLING_LAYOUT_ONNX="${DOCLING_LAYOUT_ONNX:-$(pwd)/models/layout_heron.onnx}" export DOCLING_OCR_REC_ONNX="${DOCLING_OCR_REC_ONNX:-$(pwd)/models/ocr_rec.onnx}" export DOCLING_OCR_DICT="${DOCLING_OCR_DICT:-$(pwd)/models/ppocr_keys_v1.txt}" +# Optional: falls back to geometric table reconstruction if missing. +export DOCLING_TABLEFORMER_ENCODER="${DOCLING_TABLEFORMER_ENCODER:-$(pwd)/models/tableformer/encoder.onnx}" +export DOCLING_TABLEFORMER_DECODER="${DOCLING_TABLEFORMER_DECODER:-$(pwd)/models/tableformer/decoder.onnx}" +export DOCLING_TABLEFORMER_BBOX="${DOCLING_TABLEFORMER_BBOX:-$(pwd)/models/tableformer/bbox.onnx}" cargo build --release --quiet -p fleischwolf-cli BIN=./target/release/fleischwolf diff --git a/scripts/pdf_setup.sh b/scripts/pdf_setup.sh index 840c1093..82f80e11 100755 --- a/scripts/pdf_setup.sh +++ b/scripts/pdf_setup.sh @@ -55,3 +55,12 @@ echo " export PDFIUM_DYNAMIC_LIB_PATH=$(pwd)/.pdfium/lib" echo " export DOCLING_LAYOUT_ONNX=$(pwd)/models/layout_heron.onnx" echo " export DOCLING_OCR_REC_ONNX=$(pwd)/models/ocr_rec.onnx" echo " export DOCLING_OCR_DICT=$(pwd)/models/ppocr_keys_v1.txt" +if [ -f models/tableformer/decoder.onnx ]; then + # These default to a *relative* path if unset, which only resolves when the + # process's CWD is this repo root — export them explicitly so TableFormer + # still loads (instead of silently falling back to geometric reconstruction) + # no matter where the binary/binding is actually invoked from. + echo " export DOCLING_TABLEFORMER_ENCODER=$(pwd)/models/tableformer/encoder.onnx" + echo " export DOCLING_TABLEFORMER_DECODER=$(pwd)/models/tableformer/decoder.onnx" + echo " export DOCLING_TABLEFORMER_BBOX=$(pwd)/models/tableformer/bbox.onnx" +fi diff --git a/scripts/performance.sh b/scripts/performance.sh index 0b9be167..9bf093c4 100755 --- a/scripts/performance.sh +++ b/scripts/performance.sh @@ -46,6 +46,9 @@ RUST_BIN="$(build_rust_release)" [[ -e "$WORKSPACE_DIR/models/layout_heron.onnx" ]] && export DOCLING_LAYOUT_ONNX="${DOCLING_LAYOUT_ONNX:-$WORKSPACE_DIR/models/layout_heron.onnx}" [[ -e "$WORKSPACE_DIR/models/ocr_rec.onnx" ]] && export DOCLING_OCR_REC_ONNX="${DOCLING_OCR_REC_ONNX:-$WORKSPACE_DIR/models/ocr_rec.onnx}" [[ -e "$WORKSPACE_DIR/models/ppocr_keys_v1.txt" ]] && export DOCLING_OCR_DICT="${DOCLING_OCR_DICT:-$WORKSPACE_DIR/models/ppocr_keys_v1.txt}" +[[ -e "$WORKSPACE_DIR/models/tableformer/encoder.onnx" ]] && export DOCLING_TABLEFORMER_ENCODER="${DOCLING_TABLEFORMER_ENCODER:-$WORKSPACE_DIR/models/tableformer/encoder.onnx}" +[[ -e "$WORKSPACE_DIR/models/tableformer/decoder.onnx" ]] && export DOCLING_TABLEFORMER_DECODER="${DOCLING_TABLEFORMER_DECODER:-$WORKSPACE_DIR/models/tableformer/decoder.onnx}" +[[ -e "$WORKSPACE_DIR/models/tableformer/bbox.onnx" ]] && export DOCLING_TABLEFORMER_BBOX="${DOCLING_TABLEFORMER_BBOX:-$WORKSPACE_DIR/models/tableformer/bbox.onnx}" # Run a command RUNS times under GNU time; echo "min avg peak_rss_kb cpu%". # GNU time format: %e elapsed seconds, %P CPU percent, %M max RSS in KB. From 326095b5e71a7dd06db7e66d4c1b0fd48e8a3300 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 14:27:41 +0000 Subject: [PATCH 2/3] feat(node): host layout/TableFormer ONNX as GitHub Releases, zero-config setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installDependencies() could only auto-fetch pdfium and the OCR model; the layout model (required for any PDF/image conversion) had no public download, so every user hit a hard error on first use and had to either run the Python export toolchain locally or find/host the files themselves — with no convenient way to do either from a plain Node app. Both models are permissively licensed (docling-project/docling-layout-heron, Apache-2.0; docling-project/docling-models, CDLA-Permissive-2.0/Apache-2.0), so fleischwolf can redistribute the ONNX export with attribution: - .github/workflows/publish-models.yml: manual workflow that runs the existing scripts/export_{layout,tableformer}.py and publishes the resulting .onnx files as GitHub Release assets (tag models-v1); re-running re-uploads, handling the optional TableFormer decoder.onnx.data external-weights sidecar generically (checked per-file, not hardcoded). - MODELS_NOTICE.md: attribution for the redistributed models. - deps.js: installDependencies() now defaults `modelsUrl` to that release, so it works with zero configuration; `{ modelsUrl }` / FLEISCHWOLF_MODELS_URL still override for a custom export/host. Flattens tableformer/*.onnx to tableformer-*.onnx for the fetch (GitHub release assets can't contain "/"), and both error messages (installDependencies, assertMlReady) now print a numbered, copy-pasteable troubleshooting guide instead of a single dense paragraph, shown only when the fetch itself fails. - scripts/setup_nodejs_dependencies.sh: a curl-pipeable wrapper — run from a Node app's directory to pre-fetch everything (installs the `fleischwolf` npm dep if missing, then drives installDependencies()) ahead of time, e.g. in a container build step. - READMEs (root + fleischwolf-node): document the zero-config flow and the new script. Verified deps.js end-to-end against the real (not-yet-published) release URL: pdfium/OCR download correctly, the 404s on the unpublished model assets are caught per-file (TableFormer skips gracefully, layout falls through to the guided error) rather than crashing the whole install. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DofkqhMuAJbL9arnnuVisL --- .github/workflows/publish-models.yml | 96 +++++++++++++++++ MODELS_NOTICE.md | 25 +++++ README.md | 15 ++- crates/fleischwolf-node/README.md | 39 ++++--- crates/fleischwolf-node/deps.js | 148 +++++++++++++++++++++------ scripts/setup_nodejs_dependencies.sh | 74 ++++++++++++++ 6 files changed, 348 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/publish-models.yml create mode 100644 MODELS_NOTICE.md create mode 100755 scripts/setup_nodejs_dependencies.sh diff --git a/.github/workflows/publish-models.yml b/.github/workflows/publish-models.yml new file mode 100644 index 00000000..175b2aa0 --- /dev/null +++ b/.github/workflows/publish-models.yml @@ -0,0 +1,96 @@ +# Export the RT-DETR layout model + TableFormer to ONNX and publish them as +# GitHub Release assets, so `installDependencies()` in the `fleischwolf` npm +# package (crates/fleischwolf-node/deps.js) can fetch them with zero +# configuration instead of every user needing a local torch toolchain. +# +# Both are PyTorch→ONNX *format conversions* of docling-project's own models +# (Apache-2.0 / CDLA-Permissive-2.0 — see MODELS_NOTICE.md); no weights are +# retrained or modified here. +# +# Trigger: manual only (workflow_dispatch) — it installs torch and exports two +# models, several GB and 10-20 minutes, not something to run on every push. +# Re-running with the same tag re-uploads (clobbers) matching asset names, so +# it's safe to retry after fixing a failed export. +# +# gh workflow run publish-models.yml # tag = models-v1 +# gh workflow run publish-models.yml -f tag=models-v2 # a future re-export + +name: publish models + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish/update (bump only when the export itself changes)." + required: false + default: "models-v1" + +permissions: + contents: write # create the release + upload assets + +jobs: + publish-models: + name: export + publish + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # --- layout model (required) -------------------------------------- + - name: Install layout export deps + run: pip install --upgrade pip && pip install torch transformers onnx + + - name: Export layout model + run: python scripts/export_layout.py models/layout_heron.onnx + + # --- TableFormer (optional — a failure here doesn't block layout) --- + - name: Install TableFormer export deps + continue-on-error: true + id: tf-deps + run: pip install docling_ibm_models onnxscript onnxruntime huggingface_hub + + - name: Export TableFormer + if: steps.tf-deps.outcome == 'success' + continue-on-error: true + id: tf-export + run: python scripts/export_tableformer.py models/tableformer + + # --- stage + publish ------------------------------------------------- + # GitHub release assets can't contain "/", so tableformer/*.onnx is + # flattened to tableformer-*.onnx (deps.js downloads the matching flat + # names by default). Each file is staged only if the export produced it, + # including the optional `.onnx.data` external-weights sidecar some + # exports need above ONNX's ~2GB protobuf limit (currently just the + # TableFormer decoder, but checked for every file since that's + # export-size dependent, not fixed). + - name: Stage release assets + run: | + mkdir -p release-assets + stage() { # + [ -f "$1" ] && cp "$1" "release-assets/$2" + [ -f "$1.data" ] && cp "$1.data" "release-assets/$2.data" + } + stage models/layout_heron.onnx layout_heron.onnx + stage models/tableformer/encoder.onnx tableformer-encoder.onnx + stage models/tableformer/decoder.onnx tableformer-decoder.onnx + stage models/tableformer/bbox.onnx tableformer-bbox.onnx + echo "staged:" + ls -la release-assets/ + + - name: Publish release + run: | + if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + gh release upload "$TAG" release-assets/* --repo "${{ github.repository }}" --clobber + else + gh release create "$TAG" release-assets/* \ + --repo "${{ github.repository }}" \ + --title "fleischwolf ML models ($TAG)" \ + --notes-file MODELS_NOTICE.md + fi diff --git a/MODELS_NOTICE.md b/MODELS_NOTICE.md new file mode 100644 index 00000000..e7119274 --- /dev/null +++ b/MODELS_NOTICE.md @@ -0,0 +1,25 @@ +# Third-party model notice + +`fleischwolf`'s PDF/image pipeline uses two ONNX graphs that are **format +conversions of docling-project's own PyTorch models**, not weights fleischwolf +trains or modifies. They're licensed separately from fleischwolf's own MIT +code (see [`LICENSE`](./LICENSE)) under their upstream terms: + +| Model | Source | License | +|---|---|---| +| RT-DETR layout model (`layout_heron.onnx`) | [`docling-project/docling-layout-heron`](https://huggingface.co/docling-project/docling-layout-heron) | Apache-2.0 | +| TableFormer (`tableformer/{encoder,decoder,bbox}.onnx`) | [`docling-project/docling-models`](https://huggingface.co/docling-project/docling-models) (`model_artifacts/tableformer/accurate`) | CDLA-Permissive-2.0 / Apache-2.0 | + +`scripts/export_layout.py` and `scripts/export_tableformer.py` do the +conversion (PyTorch → ONNX via `torch.onnx.export`); no weights are retrained, +fine-tuned, or otherwise altered. `.github/workflows/publish-models.yml` runs +that conversion and republishes the resulting `.onnx` files as GitHub Release +assets on this repo (tag `models-v1`, downloaded automatically by +`installDependencies()` in the `fleischwolf` npm package — see +`crates/fleischwolf-node/deps.js`), purely to spare downstream users the +PyTorch/`transformers`/`docling_ibm_models` toolchain needed to export them +locally. + +Both upstream licenses permit redistribution with attribution; this file is +that attribution. See the linked model cards for the full license text and any +additional upstream terms. diff --git a/README.md b/README.md index d3e1fb37..6a5a4d37 100644 --- a/README.md +++ b/README.md @@ -192,9 +192,18 @@ const json = await convertFileAsync('report.docx', { to: 'json' }) Declarative formats (Markdown, HTML, DOCX, XLSX, …) work out of the box. The PDF/image pipeline needs pdfium + the ONNX models (not bundled), so it throws -until you call `installDependencies()` — which auto-downloads pdfium/OCR and -fetches the layout/TableFormer ONNX from a `modelsUrl` you host. A reusable -`Pipeline` keeps those models warm across many PDFs. +until you call `installDependencies()` — which downloads everything with zero +configuration (pdfium and OCR from their own upstream releases; the layout +model and TableFormer — PyTorch→ONNX exports of docling-project's own models, +Apache-2.0/CDLA-Permissive-2.0, see [`MODELS_NOTICE.md`](./MODELS_NOTICE.md) — +from fleischwolf's own hosted release). Pre-fetch everything ahead of time +(e.g. in a container build step) with a one-liner from your app's directory: + +```bash +curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/setup_nodejs_dependencies.sh | bash +``` + +A reusable `Pipeline` keeps those models warm across many PDFs. Runnable Node + Bun examples are in [`crates/fleischwolf-node/examples`](./crates/fleischwolf-node/examples) diff --git a/crates/fleischwolf-node/README.md b/crates/fleischwolf-node/README.md index e5ab7fc6..d56a8c53 100644 --- a/crates/fleischwolf-node/README.md +++ b/crates/fleischwolf-node/README.md @@ -118,23 +118,34 @@ What `installDependencies()` fetches, into `~/.cache/fleischwolf` (override with | --- | --- | --- | | **pdfium** | bblanchon prebuilt (auto, platform-detected) | PDF | | **OCR** rec model + dictionary | HuggingFace / GitHub (auto) | scanned pages | -| **layout** (`layout_heron.onnx`) | your `modelsUrl` (see below) | PDF **and** image | -| **TableFormer** (`tableformer/*.onnx`) | your `modelsUrl` | tables (else geometric fallback) | - -> **layout + TableFormer have no public prebuilt download.** They're PyTorch→ONNX -> exports (`docling-project/docling-layout-heron`, `docling_ibm_models`). Host the -> exported `.onnx` yourself and point `installDependencies` at the base URL via -> `{ modelsUrl }` or `FLEISCHWOLF_MODELS_URL` — it fetches `layout_heron.onnx` and -> `tableformer/{encoder,decoder,bbox}.onnx` from there. Or export them locally -> (repo `scripts/export_layout.py`, `scripts/export_tableformer.py`) and set -> `DOCLING_LAYOUT_ONNX` / `DOCLING_TABLEFORMER_*` — `installDependencies` detects -> those as already installed. Without a layout source it installs pdfium/OCR and -> throws, naming what's missing. +| **layout** (`layout_heron.onnx`) | fleischwolf's hosted release (auto) | PDF **and** image | +| **TableFormer** (`tableformer-*.onnx`) | fleischwolf's hosted release (auto) | tables (else geometric fallback) | + +> **layout + TableFormer are PyTorch→ONNX exports** +> (`docling-project/docling-layout-heron`, Apache-2.0; +> `docling-project/docling-models`, CDLA-Permissive-2.0/Apache-2.0 — see +> [`MODELS_NOTICE.md`](../../MODELS_NOTICE.md) for full attribution), not +> fleischwolf's own weights. `installDependencies()` fetches the export +> fleischwolf hosts as a GitHub Release by default — no configuration needed. +> Override with `{ modelsUrl }` / `FLEISCHWOLF_MODELS_URL` to use your own +> export/host instead (serving `layout_heron.onnx` and, optionally, +> `tableformer-{encoder,decoder,bbox}.onnx`), or export locally (repo +> `scripts/export_layout.py`, `scripts/export_tableformer.py`) and set +> `DOCLING_LAYOUT_ONNX` / `DOCLING_TABLEFORMER_*` directly — +> `installDependencies` detects those as already installed and skips fetching. +> +> Pre-fetch everything ahead of time (e.g. in a container build step), from +> your app's directory: +> ```bash +> curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/setup_nodejs_dependencies.sh | bash +> ``` ```js +await installDependencies({ onProgress: (m) => console.log(m) }) + +// or, to use your own export/host instead of fleischwolf's default: await installDependencies({ - modelsUrl: 'https://you.example/fleischwolf-models', // serves layout_heron.onnx, tableformer/*.onnx - onProgress: (m) => console.log(m), + modelsUrl: 'https://you.example/fleischwolf-models', // serves layout_heron.onnx, tableformer-*.onnx }) checkDependencies() // { home, pdfium, layout, ocr, tableformer, ready, missing } diff --git a/crates/fleischwolf-node/deps.js b/crates/fleischwolf-node/deps.js index ddd81253..e40b6b00 100644 --- a/crates/fleischwolf-node/deps.js +++ b/crates/fleischwolf-node/deps.js @@ -2,22 +2,26 @@ // // The declarative backends (Markdown, HTML, DOCX, XLSX, …) are pure Rust and // need nothing. The PDF/image path needs native assets that are NOT bundled in -// the addon (they're large and licensed separately), mirroring how Python -// docling downloads its models on first use: +// the addon (they're large and licensed separately from fleischwolf's own MIT +// code), mirroring how Python docling downloads its models on first use: // // - libpdfium (PDF text extraction + page rasterization) — required for PDF // - RT-DETR layout model (models/layout_heron.onnx) — required for PDF & image // - PP-OCR rec + dict (models/ocr_rec.onnx, ppocr_keys_v1.txt) — used for pages with no text layer // - TableFormer (models/tableformer/{encoder,decoder,bbox}.onnx) — optional; geometric fallback otherwise // -// pdfium and the OCR assets have public download URLs and are fetched -// automatically. The layout and TableFormer models are exported from PyTorch -// (docling-project/docling-layout-heron and docling_ibm_models) and have no -// public prebuilt `.onnx`, so they are downloaded from a base URL you provide -// via `installDependencies({ modelsUrl })` or the `FLEISCHWOLF_MODELS_URL` -// environment variable (point it at a host serving `layout_heron.onnx` and -// `tableformer/*.onnx`). If you exported them locally, set `DOCLING_LAYOUT_ONNX` -// etc. and they'll be detected as already installed. +// All four download automatically via `installDependencies()`, no extra +// configuration needed. pdfium and the OCR model come from their own public +// upstream releases. The layout model and TableFormer are PyTorch→ONNX exports +// of docling-project's own models (`docling-project/docling-layout-heron`, +// Apache-2.0; `docling-project/docling-models`, CDLA-Permissive-2.0 / +// Apache-2.0) — fleischwolf doesn't train or modify the weights, just converts +// their format, and redistributes the export as a GitHub Release asset on this +// repo (see `MODELS_NOTICE.md` for the full attribution) at `DEFAULT_MODELS_URL` +// below. Override with `installDependencies({ modelsUrl })` / +// `FLEISCHWOLF_MODELS_URL` to use your own export/host instead, or set +// `DOCLING_LAYOUT_ONNX` etc. directly to a local file to skip downloading +// entirely. // // Everything is installed under a single home directory (default // `~/.cache/fleischwolf`, overridable via `FLEISCHWOLF_HOME` or the `dir` @@ -42,6 +46,13 @@ const OCR_REC_URL = 'https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv3/ch_PP-OCRv3_rec_infer.onnx' const OCR_DICT_URL = 'https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/ppocr_keys_v1.txt' +// A fixed (non-"latest") tag: this repo also cuts a code release on every +// master push (see .github/workflows/ci.yml), so "latest" would almost always +// point at one of *those* instead of the model assets. Bump to models-v2 (and +// this constant) only when the export changes — see +// .github/workflows/publish-models.yml. +const DEFAULT_MODELS_URL = + 'https://github.com/artiz/fleischwolf/releases/download/models-v1' // pdfium-binaries platform tag + shared-library filename, by (platform, arch). function pdfiumPlatform() { @@ -125,6 +136,49 @@ function exportEnv(p) { if (fs.existsSync(p.tfBbox)) process.env.DOCLING_TABLEFORMER_BBOX = p.tfBbox } +/** + * A numbered, copy-pasteable walkthrough for getting the layout model (and, + * optionally, TableFormer) in place, shown when the default hosted download + * didn't succeed (or wasn't reachable) so both error sites below give the same + * concrete next steps. + */ +function layoutSetupGuide() { + return [ + 'pdfium and the OCR model download automatically. The RT-DETR layout model', + '(required) and TableFormer (optional — tables fall back to geometric', + 'reconstruction without it) normally do too, from a PyTorch→ONNX export', + `fleischwolf hosts at ${DEFAULT_MODELS_URL} (docling-project's own models,`, + 'format-converted only — see MODELS_NOTICE.md for full attribution). That', + "fetch didn't succeed here. Options:", + '', + ' 1. Check connectivity to github.com and retry:', + ' await installDependencies({ force: true })', + '', + ' 2. Export the models yourself (needs Python + torch + transformers + onnx):', + ' git clone https://github.com/artiz/fleischwolf && cd fleischwolf', + ' pip install torch transformers onnx', + ' python scripts/export_layout.py models/layout_heron.onnx', + ' # optional — also needs docling_ibm_models + onnxscript + onnxruntime:', + ' python scripts/export_tableformer.py models/tableformer', + ' then point fleischwolf at the exported files, either directly:', + ' export DOCLING_LAYOUT_ONNX=/path/to/layout_heron.onnx', + ' export DOCLING_TABLEFORMER_ENCODER=/path/to/tableformer/encoder.onnx # optional', + ' export DOCLING_TABLEFORMER_DECODER=/path/to/tableformer/decoder.onnx # optional', + ' export DOCLING_TABLEFORMER_BBOX=/path/to/tableformer/bbox.onnx # optional', + ' or by copying them into installDependencies()’s install dir', + ' (default ~/.cache/fleischwolf/models, or $FLEISCHWOLF_HOME/models) so', + " they're picked up as already installed on the next call.", + '', + ' 3. Point at a different host (your own export, an internal mirror, …):', + " await installDependencies({ modelsUrl: 'https://your-host/models' })", + ' (serving layout_heron.onnx and, optionally, tableformer-*.onnx), or set', + ' FLEISCHWOLF_MODELS_URL to the same value.', + '', + 'Declarative formats (md, html, docx, xlsx, …) need none of this — only PDF,', + 'image and METS conversion do.', + ].join('\n') +} + /** * Throw a clear, actionable error if `format` needs the ML pipeline but its * dependencies aren't installed. Called before ML conversions. @@ -140,10 +194,9 @@ function assertMlReady(format, dir) { if (missing.length === 0) return throw new Error( `Converting '${format}' requires the PDF/ML dependencies, which are not installed: ` + - `${missing.join(', ')}. Call \`await installDependencies()\` before converting ` + - `(pass { modelsUrl } or set FLEISCHWOLF_MODELS_URL so the layout/TableFormer ONNX can be ` + - `fetched; or set DOCLING_LAYOUT_ONNX / PDFIUM_DYNAMIC_LIB_PATH to local files). ` + - `Declarative formats (md, html, docx, xlsx, …) need none of this.`, + `${missing.join(', ')}.\n\n` + + `First, call \`await installDependencies()\` — it fetches pdfium and the OCR model on ` + + `its own. If it still can't get the layout model afterwards, see below.\n\n${layoutSetupGuide()}`, ) } @@ -182,6 +235,20 @@ async function ensureFile(dest, url, force, onProgress, label) { return true } +/** + * Fetch `.data` (ONNX's external-data sidecar) from `sidecarUrl` if + * the host has one, ignoring a 404/any fetch error — most exports don't need + * one (only a graph over ONNX's ~2GB protobuf limit does), so its absence is + * expected, not a failure. + */ +async function ensureOptionalSidecar(mainDest, sidecarUrl, onProgress) { + try { + await ensureFile(`${mainDest}.data`, sidecarUrl, false, onProgress, `${path.basename(mainDest)}.data`) + } catch { + // No sidecar for this export — fine. + } +} + async function installPdfium(p, force, onProgress) { if (!force && fs.existsSync(p.pdfiumLib)) return false if (process.env.PDFIUM_DYNAMIC_LIB_PATH) { @@ -216,9 +283,10 @@ async function installPdfium(p, force, onProgress) { * * @param {object} [options] * @param {string} [options.dir] install home (default ~/.cache/fleischwolf or $FLEISCHWOLF_HOME) - * @param {string} [options.modelsUrl] base URL serving layout_heron.onnx + tableformer/*.onnx + * @param {string} [options.modelsUrl] base URL serving layout_heron.onnx + tableformer-*.onnx + * (default: fleischwolf's own hosted export, DEFAULT_MODELS_URL) * @param {boolean} [options.ocr=true] also fetch the OCR model + dictionary - * @param {boolean} [options.tableformer=true] also fetch TableFormer from modelsUrl (if provided) + * @param {boolean} [options.tableformer=true] also fetch TableFormer from modelsUrl * @param {boolean} [options.force=false] re-download assets that already exist * @param {(msg: string) => void} [options.onProgress] */ @@ -240,27 +308,48 @@ async function installDependencies(options = {}) { installed.push('ppocr_keys_v1.txt') } - // 3. Layout (required) + TableFormer (optional) — from the configured base URL. - const base = (options.modelsUrl || process.env.FLEISCHWOLF_MODELS_URL || '').replace(/\/$/, '') + // 3. Layout (required) + TableFormer (optional) — from the configured base URL, + // defaulting to fleischwolf's own hosted export (DEFAULT_MODELS_URL) so this + // works with zero configuration; pass `{ modelsUrl }` / set + // `FLEISCHWOLF_MODELS_URL` to use your own export/host instead. + const base = (options.modelsUrl || process.env.FLEISCHWOLF_MODELS_URL || DEFAULT_MODELS_URL).replace( + /\/$/, + '', + ) if (!fs.existsSync(p.layout)) { - if (base) { + try { if ( await ensureFile(p.layout, `${base}/layout_heron.onnx`, options.force, onProgress, 'layout model') - ) + ) { installed.push('layout_heron.onnx') - } else { - missing.push('layout_heron.onnx') + // Large exports carry their weights in a sidecar `.onnx.data` + // (ONNX's external-data format, used above the ~2GB protobuf limit). + // Optional — most exports don't need one — so a missing sidecar is not + // an error. + await ensureOptionalSidecar(p.layout, `${base}/layout_heron.onnx.data`, onProgress) + } + } catch (e) { + // Surfaced below via status.missing + layoutSetupGuide(), with more + // actionable detail than the raw fetch error. + onProgress?.(`could not fetch layout model from ${base}: ${e.message}`) } } - if (options.tableformer !== false && base) { + if (options.tableformer !== false) { for (const [file, dest] of [ ['tableformer/encoder.onnx', p.tfEncoder], ['tableformer/decoder.onnx', p.tfDecoder], ['tableformer/bbox.onnx', p.tfBbox], ]) { + // GitHub Release assets can't contain "/", so the hosted copy is flat + // (tableformer-encoder.onnx, …); a custom `modelsUrl` host is free to + // mirror either layout, since ensureOptionalSidecar/ensureFile below try + // the flat name. + const flat = file.replace(/\//g, '-') try { - if (await ensureFile(dest, `${base}/${file}`, options.force, onProgress, file)) + if (await ensureFile(dest, `${base}/${flat}`, options.force, onProgress, file)) { installed.push(file) + await ensureOptionalSidecar(dest, `${base}/${flat}.data`, onProgress) + } } catch (e) { // TableFormer is optional (geometric fallback); note but don't fail. onProgress?.(`skipped ${file}: ${e.message}`) @@ -272,15 +361,10 @@ async function installDependencies(options = {}) { const status = checkDependencies(options) if (!status.ready) { - const hint = base - ? `layout_heron.onnx could not be fetched from ${base}.` - : `no models URL configured — pass { modelsUrl } to installDependencies() or set ` + - `FLEISCHWOLF_MODELS_URL to a host serving layout_heron.onnx (and tableformer/*.onnx). ` + - `The layout model is a PyTorch→ONNX export (docling-project/docling-layout-heron) with no ` + - `public prebuilt download; export it with the repo's scripts/export_layout.py and host it, ` + - `or set DOCLING_LAYOUT_ONNX to the local file.` throw new Error( - `installDependencies: PDF conversion is not ready. Missing: ${status.missing.join(', ')}. ${hint}`, + `installDependencies: PDF conversion is not ready. Missing: ${status.missing.join(', ')}.\n\n` + + `layout_heron.onnx could not be fetched from ${base} — check the URL is reachable and\n` + + `serves layout_heron.onnx (and, optionally, tableformer-*.onnx) at that path.\n\n${layoutSetupGuide()}`, ) } diff --git a/scripts/setup_nodejs_dependencies.sh b/scripts/setup_nodejs_dependencies.sh new file mode 100755 index 00000000..8702c96a --- /dev/null +++ b/scripts/setup_nodejs_dependencies.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Fetch the PDF/image ML pipeline's native dependencies for a Node.js/Bun app +# using the `fleischwolf` npm package. A thin, curl-pipeable convenience +# wrapper around `installDependencies()` (crates/fleischwolf-node/deps.js) — +# all the real download/path logic lives there; this just drives it from a +# directory that may not have `fleischwolf` installed yet. +# +# Run from your app's directory (where `fleischwolf` is/will be an npm dep): +# curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/setup_nodejs_dependencies.sh | bash +# or, from a checkout of this repo: +# bash scripts/setup_nodejs_dependencies.sh +# +# Downloads (to ~/.cache/fleischwolf by default; override with $FLEISCHWOLF_HOME): +# - libpdfium + the PP-OCRv3 recognition model — from their own public releases +# - the RT-DETR layout model + TableFormer — PyTorch->ONNX exports of +# docling-project's own models (Apache-2.0 / CDLA-Permissive-2.0), hosted +# as GitHub Release assets on this repo (see MODELS_NOTICE.md) +# +# Idempotent: skips anything already downloaded. Pass --force to re-fetch +# everything. Set FLEISCHWOLF_MODELS_URL to use your own model export/host +# instead of fleischwolf's. +# +# This only *caches* the files — your app still needs to call +# `await installDependencies()` once at startup (before converting any +# PDF/image) so the native pipeline in *that* process picks them up; with +# everything already on disk, that call becomes an instant no-op. +# +# Requires: node (with npm) and network access. +set -euo pipefail + +FORCE=false +for arg in "$@"; do + case "$arg" in + --force) FORCE=true ;; + *) + echo "usage: setup_nodejs_dependencies.sh [--force]" >&2 + exit 2 + ;; + esac +done + +if ! command -v node >/dev/null 2>&1; then + echo "error: node is required (https://nodejs.org)" >&2 + exit 1 +fi + +# Make sure `fleischwolf` is resolvable from the current directory — install +# it locally if it isn't (piping this script via curl means there's no +# checked-out repo/node_modules to begin with). +if ! node -e "require.resolve('fleischwolf')" >/dev/null 2>&1; then + if [ -f package.json ]; then + echo "→ installing the fleischwolf npm package" + npm install fleischwolf + else + echo "error: no package.json in $(pwd) and 'fleischwolf' isn't resolvable." >&2 + echo " run this from your Node app's directory (with a package.json), or:" >&2 + echo " npm init -y && npm install fleischwolf" >&2 + exit 1 + fi +fi + +node -e " +const { installDependencies } = require('fleischwolf'); +installDependencies({ force: ${FORCE}, onProgress: (m) => console.error(' ' + m) }) + .then((status) => { + console.error(''); + console.error('done — installed under ' + status.home); + console.error(JSON.stringify(status, null, 2)); + }) + .catch((err) => { + console.error(err.message); + process.exitCode = 1; + }); +" From c58b43d0d3537c823adaac05ae97fa25df0cd991 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 15:02:21 +0000 Subject: [PATCH 3/3] feat(node,pdf): replace installDependencies() with scripts/download_dependencies.sh installDependencies() duplicated what a plain bash script does better: fetch pdfium + the ONNX models straight into ./models and ./.pdfium, which both the Rust CLI and the Node bindings already default to relative to CWD. Removes the JS download machinery entirely (deps.js keeps only path resolution + status checks), deletes the now-pointless setup_nodejs_dependencies.sh wrapper, and adds the CWD-relative .pdfium/lib fallback to pdfium_backend.rs so the Rust side needs no env vars either. publish-models.yml now also re-hosts pdfium + the OCR model (previously only layout/TableFormer), so download_dependencies.sh talks to a single release. TableFormer assets are published under their bare names (encoder.onnx, decoder.onnx, bbox.onnx) instead of a tableformer- prefix, matching the script's fixed target paths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DofkqhMuAJbL9arnnuVisL --- .github/workflows/publish-models.yml | 49 ++- MODELS_NOTICE.md | 8 +- README.md | 17 +- crates/fleischwolf-node/README.md | 74 ++-- crates/fleischwolf-node/deps.js | 329 ++++-------------- .../examples/pdf-pipeline.mjs | 34 +- crates/fleischwolf-node/index.d.ts | 31 +- crates/fleischwolf-node/index.js | 10 +- crates/fleischwolf-node/test/smoke.mjs | 10 +- crates/fleischwolf-pdf/src/pdfium_backend.rs | 31 +- scripts/download_dependencies.sh | 88 +++++ scripts/setup_nodejs_dependencies.sh | 74 ---- 12 files changed, 284 insertions(+), 471 deletions(-) create mode 100755 scripts/download_dependencies.sh delete mode 100755 scripts/setup_nodejs_dependencies.sh diff --git a/.github/workflows/publish-models.yml b/.github/workflows/publish-models.yml index 175b2aa0..4d42034b 100644 --- a/.github/workflows/publish-models.yml +++ b/.github/workflows/publish-models.yml @@ -1,11 +1,14 @@ -# Export the RT-DETR layout model + TableFormer to ONNX and publish them as -# GitHub Release assets, so `installDependencies()` in the `fleischwolf` npm -# package (crates/fleischwolf-node/deps.js) can fetch them with zero -# configuration instead of every user needing a local torch toolchain. +# Export the RT-DETR layout model + TableFormer to ONNX, re-host pdfium + the +# OCR model, and publish everything as GitHub Release assets, so +# `scripts/download_dependencies.sh` can fetch the whole PDF/image ML stack +# from a single host (this repo's releases) with zero configuration. # -# Both are PyTorch→ONNX *format conversions* of docling-project's own models -# (Apache-2.0 / CDLA-Permissive-2.0 — see MODELS_NOTICE.md); no weights are -# retrained or modified here. +# The layout model and TableFormer are PyTorch→ONNX *format conversions* of +# docling-project's own models (Apache-2.0 / CDLA-Permissive-2.0 — see +# MODELS_NOTICE.md); no weights are retrained or modified here. pdfium and the +# OCR model are re-hosted third-party binaries, unmodified, from their own +# public releases — mirrored here only so `download_dependencies.sh` needs one +# host instead of three. # # Trigger: manual only (workflow_dispatch) — it installs torch and exports two # models, several GB and 10-20 minutes, not something to run on every push. @@ -62,13 +65,26 @@ jobs: id: tf-export run: python scripts/export_tableformer.py models/tableformer + # --- pdfium + OCR (re-hosted third-party binaries, not exports) ----- + - name: Fetch pdfium + OCR assets + run: | + mkdir -p third-party + curl -fsSL -o /tmp/pdfium.tgz \ + "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-x64.tgz" + tar -xzf /tmp/pdfium.tgz -C third-party + curl -fsSL -o third-party/ocr_rec.onnx \ + "https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv3/ch_PP-OCRv3_rec_infer.onnx" + curl -fsSL -o third-party/ppocr_keys_v1.txt \ + "https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/ppocr_keys_v1.txt" + # --- stage + publish ------------------------------------------------- # GitHub release assets can't contain "/", so tableformer/*.onnx is - # flattened to tableformer-*.onnx (deps.js downloads the matching flat - # names by default). Each file is staged only if the export produced it, - # including the optional `.onnx.data` external-weights sidecar some - # exports need above ONNX's ~2GB protobuf limit (currently just the - # TableFormer decoder, but checked for every file since that's + # flattened to bare encoder.onnx / decoder.onnx / bbox.onnx (no naming + # collision with anything else in this release; download_dependencies.sh + # downloads these exact names). Each file is staged only if the export + # produced it, including the optional `.onnx.data` external-weights + # sidecar some exports need above ONNX's ~2GB protobuf limit (currently + # just the TableFormer decoder, but checked for every file since that's # export-size dependent, not fixed). - name: Stage release assets run: | @@ -77,10 +93,13 @@ jobs: [ -f "$1" ] && cp "$1" "release-assets/$2" [ -f "$1.data" ] && cp "$1.data" "release-assets/$2.data" } + stage third-party/lib/libpdfium.so libpdfium.so + stage third-party/ocr_rec.onnx ocr_rec.onnx + stage third-party/ppocr_keys_v1.txt ppocr_keys_v1.txt stage models/layout_heron.onnx layout_heron.onnx - stage models/tableformer/encoder.onnx tableformer-encoder.onnx - stage models/tableformer/decoder.onnx tableformer-decoder.onnx - stage models/tableformer/bbox.onnx tableformer-bbox.onnx + stage models/tableformer/encoder.onnx encoder.onnx + stage models/tableformer/decoder.onnx decoder.onnx + stage models/tableformer/bbox.onnx bbox.onnx echo "staged:" ls -la release-assets/ diff --git a/MODELS_NOTICE.md b/MODELS_NOTICE.md index e7119274..3919c757 100644 --- a/MODELS_NOTICE.md +++ b/MODELS_NOTICE.md @@ -13,10 +13,10 @@ code (see [`LICENSE`](./LICENSE)) under their upstream terms: `scripts/export_layout.py` and `scripts/export_tableformer.py` do the conversion (PyTorch → ONNX via `torch.onnx.export`); no weights are retrained, fine-tuned, or otherwise altered. `.github/workflows/publish-models.yml` runs -that conversion and republishes the resulting `.onnx` files as GitHub Release -assets on this repo (tag `models-v1`, downloaded automatically by -`installDependencies()` in the `fleischwolf` npm package — see -`crates/fleischwolf-node/deps.js`), purely to spare downstream users the +that conversion (and re-hosts pdfium + the OCR model alongside it) and +publishes everything as GitHub Release assets on this repo (tag `models-v1`), +fetched by `scripts/download_dependencies.sh` — see that script and +`crates/fleischwolf-node/deps.js` — purely to spare downstream users the PyTorch/`transformers`/`docling_ibm_models` toolchain needed to export them locally. diff --git a/README.md b/README.md index 6a5a4d37..2adf017d 100644 --- a/README.md +++ b/README.md @@ -192,15 +192,15 @@ const json = await convertFileAsync('report.docx', { to: 'json' }) Declarative formats (Markdown, HTML, DOCX, XLSX, …) work out of the box. The PDF/image pipeline needs pdfium + the ONNX models (not bundled), so it throws -until you call `installDependencies()` — which downloads everything with zero -configuration (pdfium and OCR from their own upstream releases; the layout -model and TableFormer — PyTorch→ONNX exports of docling-project's own models, +until you fetch them — a one-liner from your app's directory (pdfium and OCR +from their own upstream releases; the layout model and TableFormer — +PyTorch→ONNX exports of docling-project's own models, Apache-2.0/CDLA-Permissive-2.0, see [`MODELS_NOTICE.md`](./MODELS_NOTICE.md) — -from fleischwolf's own hosted release). Pre-fetch everything ahead of time -(e.g. in a container build step) with a one-liner from your app's directory: +from fleischwolf's own hosted release), straight into `./models` and +`./.pdfium`, which the package looks for by default — no env vars needed: ```bash -curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/setup_nodejs_dependencies.sh | bash +curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/download_dependencies.sh | sh ``` A reusable `Pipeline` keeps those models warm across many PDFs. @@ -268,6 +268,11 @@ cargo run -p fleischwolf-cli -- --strict crates/fleischwolf/sample.html cargo run -p fleischwolf-cli -- --to json crates/fleischwolf/sample.html cargo run -p fleischwolf-cli -- --to json crates/fleischwolf/sample.html > out.json +# PDF/image conversion needs the ML models: scripts/download_dependencies.sh once, +# then it just works — models/ and .pdfium/lib are picked up automatically. +scripts/download_dependencies.sh +cargo run -p fleischwolf-cli -- document.pdf + # extract pictures (PDF/image inputs): embed as data URIs, or write ./artifacts/*.png cargo run -p fleischwolf-cli -- --images embedded document.pdf cargo run -p fleischwolf-cli -- --images referenced document.pdf > out.md diff --git a/crates/fleischwolf-node/README.md b/crates/fleischwolf-node/README.md index d56a8c53..81e640ae 100644 --- a/crates/fleischwolf-node/README.md +++ b/crates/fleischwolf-node/README.md @@ -93,61 +93,58 @@ for await (const chunk of streamFileMarkdown('paper.pdf')) { } ``` -### PDF / images: installing the ML models +### PDF / images: getting the ML models Declarative formats (Markdown, HTML, DOCX, XLSX, …) are pure Rust and need nothing. The **PDF/image** path needs native assets that are *not* bundled in the -addon — pdfium plus the ONNX models (layout, OCR, TableFormer) — the same way -Python docling downloads its models on first use. Converting a PDF/image/METS -input **throws** until they're installed: +addon — pdfium plus the ONNX models (layout, OCR, TableFormer). Converting a +PDF/image/METS input **throws** until they're on disk. Fetch them with a +one-liner from your app's directory (where you'll `npm install fleischwolf`): -```js -import { installDependencies, checkDependencies, convertFileAsync } from 'fleischwolf' +```bash +curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/download_dependencies.sh | sh +``` -await convertFileAsync('paper.pdf') // ❌ throws: "requires the PDF/ML dependencies … call installDependencies()" +```js +import { convertFileAsync } from 'fleischwolf' -await installDependencies() // provisions everything, then: -await convertFileAsync('paper.pdf') // ✅ works +const res = await convertFileAsync('paper.pdf', { to: 'markdown' }) // ✅ works ``` -What `installDependencies()` fetches, into `~/.cache/fleischwolf` (override with -`dir` or `$FLEISCHWOLF_HOME`), wiring the matching `DOCLING_*` / -`PDFIUM_DYNAMIC_LIB_PATH` env vars in-process: +`scripts/download_dependencies.sh` fetches everything from this repo's +[GitHub Releases](https://github.com/artiz/fleischwolf/releases) straight into +`./models` and `./.pdfium` — which this package (and the Rust CLI) look for by +default, relative to the process's current directory, so no env vars or setup +call are needed afterwards: -| Asset | Source | Required for | -| --- | --- | --- | -| **pdfium** | bblanchon prebuilt (auto, platform-detected) | PDF | -| **OCR** rec model + dictionary | HuggingFace / GitHub (auto) | scanned pages | -| **layout** (`layout_heron.onnx`) | fleischwolf's hosted release (auto) | PDF **and** image | -| **TableFormer** (`tableformer-*.onnx`) | fleischwolf's hosted release (auto) | tables (else geometric fallback) | +| Asset | Destination | +| --- | --- | +| **pdfium** | `.pdfium/lib/libpdfium.so` | +| **layout** (`layout_heron.onnx`) | `models/layout_heron.onnx` | +| **OCR** rec model + dictionary | `models/ocr_rec.onnx`, `models/ppocr_keys_v1.txt` | +| **TableFormer** | `models/tableformer/{encoder,decoder,bbox}.onnx` | > **layout + TableFormer are PyTorch→ONNX exports** > (`docling-project/docling-layout-heron`, Apache-2.0; > `docling-project/docling-models`, CDLA-Permissive-2.0/Apache-2.0 — see > [`MODELS_NOTICE.md`](../../MODELS_NOTICE.md) for full attribution), not -> fleischwolf's own weights. `installDependencies()` fetches the export -> fleischwolf hosts as a GitHub Release by default — no configuration needed. -> Override with `{ modelsUrl }` / `FLEISCHWOLF_MODELS_URL` to use your own -> export/host instead (serving `layout_heron.onnx` and, optionally, -> `tableformer-{encoder,decoder,bbox}.onnx`), or export locally (repo -> `scripts/export_layout.py`, `scripts/export_tableformer.py`) and set -> `DOCLING_LAYOUT_ONNX` / `DOCLING_TABLEFORMER_*` directly — -> `installDependencies` detects those as already installed and skips fetching. +> fleischwolf's own weights — fleischwolf hosts the converted `.onnx` as a +> GitHub Release purely so you don't need a local Python/torch toolchain. +> pdfium and the OCR model are re-hosted, unmodified, from their own public +> releases, on the same host for convenience. > -> Pre-fetch everything ahead of time (e.g. in a container build step), from -> your app's directory: +> Run it from wherever your app lives — the script only writes to `./models` +> and `./.pdfium` under the current directory, e.g. in a container build step: > ```bash -> curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/setup_nodejs_dependencies.sh | bash +> cd /path/to/your/app && curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/download_dependencies.sh | sh > ``` +> +> To use your own export/host instead, point the env vars at it directly: +> `DOCLING_LAYOUT_ONNX`, `DOCLING_OCR_REC_ONNX`, `DOCLING_OCR_DICT`, +> `DOCLING_TABLEFORMER_{ENCODER,DECODER,BBOX}`, `PDFIUM_DYNAMIC_LIB_PATH` — an +> env var always wins over the `./models` / `./.pdfium` default. ```js -await installDependencies({ onProgress: (m) => console.log(m) }) - -// or, to use your own export/host instead of fleischwolf's default: -await installDependencies({ - modelsUrl: 'https://you.example/fleischwolf-models', // serves layout_heron.onnx, tableformer-*.onnx -}) - checkDependencies() // { home, pdfium, layout, ocr, tableformer, ready, missing } ``` @@ -199,7 +196,6 @@ JSON output always embeds extracted images as data URIs. | `streamFileMarkdown(path, options?)` | `AsyncGenerator` | Markdown chunks in document order. | | `supportedFormats()` | `string[]` | Supported input format ids. | | `formatFromName(name)` | `string \| null` | Detect a format id from a filename/extension. | -| `installDependencies(options?)` | `Promise` | Download/validate the PDF/image models + pdfium. | | `checkDependencies(options?)` | `DependencyStatus` | Report which PDF/image deps are present. | `Pipeline` is the reusable warm PDF/image converter: `new Pipeline(converterOptions)` @@ -246,12 +242,12 @@ cd examples npm install node node-basic.mjs # ESM: file, bytes, JSON, reuse bun run bun-basic.ts # Bun + TypeScript: async + streaming -node pdf-pipeline.mjs # installDependencies + warm Pipeline for PDFs +node pdf-pipeline.mjs # warm Pipeline for PDFs (run scripts/download_dependencies.sh first) ``` - [`examples/node-basic.mjs`](examples/node-basic.mjs) — Node.js (ESM): file, bytes, JSON, reuse. - [`examples/bun-basic.ts`](examples/bun-basic.ts) — Bun + TypeScript, with async and streaming. -- [`examples/pdf-pipeline.mjs`](examples/pdf-pipeline.mjs) — `installDependencies` + warm `Pipeline` for PDFs. +- [`examples/pdf-pipeline.mjs`](examples/pdf-pipeline.mjs) — warm `Pipeline` for PDFs. The smoke test exercises the locally-built addon instead: `npm run build` once at the package root, then `node test/smoke.mjs` (or `bun test/smoke.mjs`). diff --git a/crates/fleischwolf-node/deps.js b/crates/fleischwolf-node/deps.js index e40b6b00..7e2f157d 100644 --- a/crates/fleischwolf-node/deps.js +++ b/crates/fleischwolf-node/deps.js @@ -1,79 +1,71 @@ -// Dependency provisioning for the PDF/image ML pipeline. +// Dependency *resolution* for the PDF/image ML pipeline. // // The declarative backends (Markdown, HTML, DOCX, XLSX, …) are pure Rust and // need nothing. The PDF/image path needs native assets that are NOT bundled in // the addon (they're large and licensed separately from fleischwolf's own MIT -// code), mirroring how Python docling downloads its models on first use: +// code): // // - libpdfium (PDF text extraction + page rasterization) — required for PDF // - RT-DETR layout model (models/layout_heron.onnx) — required for PDF & image // - PP-OCR rec + dict (models/ocr_rec.onnx, ppocr_keys_v1.txt) — used for pages with no text layer // - TableFormer (models/tableformer/{encoder,decoder,bbox}.onnx) — optional; geometric fallback otherwise // -// All four download automatically via `installDependencies()`, no extra -// configuration needed. pdfium and the OCR model come from their own public -// upstream releases. The layout model and TableFormer are PyTorch→ONNX exports -// of docling-project's own models (`docling-project/docling-layout-heron`, -// Apache-2.0; `docling-project/docling-models`, CDLA-Permissive-2.0 / -// Apache-2.0) — fleischwolf doesn't train or modify the weights, just converts -// their format, and redistributes the export as a GitHub Release asset on this -// repo (see `MODELS_NOTICE.md` for the full attribution) at `DEFAULT_MODELS_URL` -// below. Override with `installDependencies({ modelsUrl })` / -// `FLEISCHWOLF_MODELS_URL` to use your own export/host instead, or set -// `DOCLING_LAYOUT_ONNX` etc. directly to a local file to skip downloading -// entirely. -// -// Everything is installed under a single home directory (default -// `~/.cache/fleischwolf`, overridable via `FLEISCHWOLF_HOME` or the `dir` -// option), and the corresponding `DOCLING_*` / `PDFIUM_DYNAMIC_LIB_PATH` -// environment variables are set in-process so the native pipeline finds them. +// This module does NOT download anything — `scripts/download_dependencies.sh` +// does that, fetching everything from this repo's GitHub Releases straight +// into `./models` and `./.pdfium` (see MODELS_NOTICE.md for attribution: the +// layout model and TableFormer are PyTorch→ONNX exports of docling-project's +// own models, re-hosted here as a convenience). This module just resolves +// where those files (or an explicit `DOCLING_*` / `PDFIUM_DYNAMIC_LIB_PATH` +// override) should live, reports whether they're present, and wires the +// matching env vars in-process so the native pipeline finds them — mirroring +// the CWD-relative defaults already baked into the Rust pipeline itself, so a +// plain `convertFileAsync(...)` call needs no explicit setup once +// `download_dependencies.sh` has run. 'use strict' const fs = require('fs') const os = require('os') const path = require('path') -const http = require('http') -const https = require('https') -const { execFileSync } = require('child_process') // Formats whose conversion requires the ML models + native libs above. const ML_FORMATS = new Set(['pdf', 'image', 'mets_gbs']) -const PDFIUM_RELEASE = - 'https://github.com/bblanchon/pdfium-binaries/releases/latest/download' -const OCR_REC_URL = - 'https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv3/ch_PP-OCRv3_rec_infer.onnx' -const OCR_DICT_URL = - 'https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/ppocr_keys_v1.txt' -// A fixed (non-"latest") tag: this repo also cuts a code release on every -// master push (see .github/workflows/ci.yml), so "latest" would almost always -// point at one of *those* instead of the model assets. Bump to models-v2 (and -// this constant) only when the export changes — see -// .github/workflows/publish-models.yml. -const DEFAULT_MODELS_URL = - 'https://github.com/artiz/fleischwolf/releases/download/models-v1' - -// pdfium-binaries platform tag + shared-library filename, by (platform, arch). -function pdfiumPlatform() { - const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : process.arch +// pdfium's shared-library filename, by platform. +function pdfiumLibName() { switch (process.platform) { case 'linux': - return { tag: `linux-${arch}`, lib: 'libpdfium.so' } + return 'libpdfium.so' case 'darwin': - return { tag: `mac-${arch}`, lib: 'libpdfium.dylib' } + return 'libpdfium.dylib' case 'win32': - return { tag: `win-${arch}`, lib: 'pdfium.dll' } + return 'pdfium.dll' default: throw new Error(`unsupported platform for pdfium: ${process.platform}/${process.arch}`) } } -/** Resolve the install home directory (absolute). */ +/** + * Resolve the install home directory (absolute), and which `pdfium/`-vs- + * `.pdfium/` layout it uses. Precedence: an explicit `dir` > `$FLEISCHWOLF_HOME` + * > the current directory, *if* it already has a local `models/` or `.pdfium/` + * (the layout `scripts/download_dependencies.sh` and `scripts/pdf_setup.sh` + * both produce, and the one the native Rust pipeline's own env-var-less + * defaults already resolve — `models/layout_heron.onnx`, `.pdfium/lib/…` — + * relative to *its* CWD) > `~/.cache/fleischwolf`. This lets a plain + * `convertFileAsync(...)` call succeed with zero setup (no env vars) whenever + * the app is run from a directory that already has the dependencies + * downloaded next to it. + */ function homeDir(dir) { - if (dir) return path.resolve(dir) - if (process.env.FLEISCHWOLF_HOME) return path.resolve(process.env.FLEISCHWOLF_HOME) - return path.join(os.homedir(), '.cache', 'fleischwolf') + if (dir) return { home: path.resolve(dir), dotPdfium: false } + if (process.env.FLEISCHWOLF_HOME) return { home: path.resolve(process.env.FLEISCHWOLF_HOME), dotPdfium: false } + const cwd = process.cwd() + const hasLocal = + fs.existsSync(path.join(cwd, 'models', 'layout_heron.onnx')) || + fs.existsSync(path.join(cwd, '.pdfium', 'lib', pdfiumLibName())) + if (hasLocal) return { home: cwd, dotPdfium: true } + return { home: path.join(os.homedir(), '.cache', 'fleischwolf'), dotPdfium: false } } /** @@ -82,16 +74,16 @@ function homeDir(dir) { * is honored), else the path under the install home directory. */ function resolvePaths(dir) { - const home = homeDir(dir) + const { home, dotPdfium } = homeDir(dir) const models = path.join(home, 'models') - const { lib } = pdfiumPlatform() - const pdfiumLibDir = process.env.PDFIUM_DYNAMIC_LIB_PATH || path.join(home, 'pdfium', 'lib') + const pdfiumLibDir = + process.env.PDFIUM_DYNAMIC_LIB_PATH || path.join(home, dotPdfium ? '.pdfium' : 'pdfium', 'lib') return { home, models, pdfiumLibDir, - pdfiumLib: path.join(pdfiumLibDir, lib), + pdfiumLib: path.join(pdfiumLibDir, pdfiumLibName()), layout: process.env.DOCLING_LAYOUT_ONNX || path.join(models, 'layout_heron.onnx'), ocrRec: process.env.DOCLING_OCR_REC_ONNX || path.join(models, 'ocr_rec.onnx'), ocrDict: process.env.DOCLING_OCR_DICT || path.join(models, 'ppocr_keys_v1.txt'), @@ -104,8 +96,8 @@ function resolvePaths(dir) { } /** - * Report which dependencies are present on disk, without downloading anything. - * `ready` is true when the minimum for PDF (pdfium + layout) is present. + * Report which dependencies are present on disk. `ready` is true when the + * minimum for PDF (pdfium + layout) is present. */ function checkDependencies(options = {}) { const p = resolvePaths(options.dir) @@ -137,54 +129,43 @@ function exportEnv(p) { } /** - * A numbered, copy-pasteable walkthrough for getting the layout model (and, - * optionally, TableFormer) in place, shown when the default hosted download - * didn't succeed (or wasn't reachable) so both error sites below give the same - * concrete next steps. + * A copy-pasteable next step, shown when a PDF/image/METS conversion is + * attempted without the dependencies on disk. */ -function layoutSetupGuide() { +function downloadGuide() { return [ - 'pdfium and the OCR model download automatically. The RT-DETR layout model', - '(required) and TableFormer (optional — tables fall back to geometric', - 'reconstruction without it) normally do too, from a PyTorch→ONNX export', - `fleischwolf hosts at ${DEFAULT_MODELS_URL} (docling-project's own models,`, - 'format-converted only — see MODELS_NOTICE.md for full attribution). That', - "fetch didn't succeed here. Options:", + 'Run this once from your app\'s directory (fetches pdfium + the ONNX', + 'models — layout, OCR, TableFormer — from this repo\'s GitHub Releases', + 'straight into ./models and ./.pdfium, which this package looks for by', + 'default; no env vars needed afterwards):', + '', + ' curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/download_dependencies.sh | sh', '', - ' 1. Check connectivity to github.com and retry:', - ' await installDependencies({ force: true })', + 'or, from a checkout of the repo:', '', - ' 2. Export the models yourself (needs Python + torch + transformers + onnx):', - ' git clone https://github.com/artiz/fleischwolf && cd fleischwolf', - ' pip install torch transformers onnx', - ' python scripts/export_layout.py models/layout_heron.onnx', - ' # optional — also needs docling_ibm_models + onnxscript + onnxruntime:', - ' python scripts/export_tableformer.py models/tableformer', - ' then point fleischwolf at the exported files, either directly:', - ' export DOCLING_LAYOUT_ONNX=/path/to/layout_heron.onnx', - ' export DOCLING_TABLEFORMER_ENCODER=/path/to/tableformer/encoder.onnx # optional', - ' export DOCLING_TABLEFORMER_DECODER=/path/to/tableformer/decoder.onnx # optional', - ' export DOCLING_TABLEFORMER_BBOX=/path/to/tableformer/bbox.onnx # optional', - ' or by copying them into installDependencies()’s install dir', - ' (default ~/.cache/fleischwolf/models, or $FLEISCHWOLF_HOME/models) so', - " they're picked up as already installed on the next call.", + ' scripts/download_dependencies.sh', '', - ' 3. Point at a different host (your own export, an internal mirror, …):', - " await installDependencies({ modelsUrl: 'https://your-host/models' })", - ' (serving layout_heron.onnx and, optionally, tableformer-*.onnx), or set', - ' FLEISCHWOLF_MODELS_URL to the same value.', + 'TableFormer is optional (tables fall back to geometric reconstruction', + 'without it). To use your own export/host instead, point the DOCLING_*', + 'env vars at it directly: DOCLING_LAYOUT_ONNX, DOCLING_OCR_REC_ONNX,', + 'DOCLING_OCR_DICT, DOCLING_TABLEFORMER_{ENCODER,DECODER,BBOX},', + 'PDFIUM_DYNAMIC_LIB_PATH — see MODELS_NOTICE.md for licensing.', '', - 'Declarative formats (md, html, docx, xlsx, …) need none of this — only PDF,', - 'image and METS conversion do.', + 'Declarative formats (md, html, docx, xlsx, …) need none of this — only', + 'PDF, image and METS conversion do.', ].join('\n') } /** * Throw a clear, actionable error if `format` needs the ML pipeline but its - * dependencies aren't installed. Called before ML conversions. + * dependencies aren't installed. Called before ML conversions; also wires up + * the `DOCLING_*` / `PDFIUM_DYNAMIC_LIB_PATH` env vars for whatever is present, + * so a checkout with `scripts/download_dependencies.sh` already run just works. */ function assertMlReady(format, dir) { if (!ML_FORMATS.has(format)) return + const p = resolvePaths(dir) + exportEnv(p) const status = checkDependencies({ dir }) // Image needs layout (+OCR), but not pdfium; PDF/METS need both. const needPdfium = format !== 'image' @@ -194,186 +175,12 @@ function assertMlReady(format, dir) { if (missing.length === 0) return throw new Error( `Converting '${format}' requires the PDF/ML dependencies, which are not installed: ` + - `${missing.join(', ')}.\n\n` + - `First, call \`await installDependencies()\` — it fetches pdfium and the OCR model on ` + - `its own. If it still can't get the layout model afterwards, see below.\n\n${layoutSetupGuide()}`, - ) -} - -// --- downloading ----------------------------------------------------------- - -function download(url, dest, onProgress) { - return new Promise((resolve, reject) => { - const tmp = `${dest}.download` - const client = url.startsWith('http://') ? http : https - const req = client.get(url, { headers: { 'User-Agent': 'fleischwolf-node' } }, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - res.resume() - return download(res.headers.location, dest, onProgress).then(resolve, reject) - } - if (res.statusCode !== 200) { - res.resume() - return reject(new Error(`GET ${url} → HTTP ${res.statusCode}`)) - } - fs.mkdirSync(path.dirname(dest), { recursive: true }) - const out = fs.createWriteStream(tmp) - res.pipe(out) - out.on('finish', () => out.close(() => { - fs.renameSync(tmp, dest) - resolve(dest) - })) - out.on('error', reject) - }) - req.on('error', reject) - }) -} - -async function ensureFile(dest, url, force, onProgress, label) { - if (!force && fs.existsSync(dest)) return false - onProgress?.(`downloading ${label}`) - await download(url, dest, onProgress) - return true -} - -/** - * Fetch `.data` (ONNX's external-data sidecar) from `sidecarUrl` if - * the host has one, ignoring a 404/any fetch error — most exports don't need - * one (only a graph over ONNX's ~2GB protobuf limit does), so its absence is - * expected, not a failure. - */ -async function ensureOptionalSidecar(mainDest, sidecarUrl, onProgress) { - try { - await ensureFile(`${mainDest}.data`, sidecarUrl, false, onProgress, `${path.basename(mainDest)}.data`) - } catch { - // No sidecar for this export — fine. - } -} - -async function installPdfium(p, force, onProgress) { - if (!force && fs.existsSync(p.pdfiumLib)) return false - if (process.env.PDFIUM_DYNAMIC_LIB_PATH) { - // The user pointed us at a pdfium directory that doesn't contain the lib. - throw new Error( - `PDFIUM_DYNAMIC_LIB_PATH is set to '${p.pdfiumLibDir}' but no pdfium library was found there.`, - ) - } - const { tag } = pdfiumPlatform() - const url = `${PDFIUM_RELEASE}/pdfium-${tag}.tgz` - const home = p.home - const pdfiumRoot = path.join(home, 'pdfium') - fs.mkdirSync(pdfiumRoot, { recursive: true }) - const tgz = path.join(pdfiumRoot, 'pdfium.tgz') - onProgress?.(`downloading pdfium (${tag})`) - await download(url, tgz) - onProgress?.('extracting pdfium') - // pdfium-binaries ships a .tgz; use the system `tar` (present on Linux, macOS, - // and Windows 10+). The archive lays out lib/ which matches pdfiumLibDir. - execFileSync('tar', ['-xzf', tgz, '-C', pdfiumRoot]) - fs.rmSync(tgz, { force: true }) - if (!fs.existsSync(p.pdfiumLib)) { - throw new Error(`pdfium extracted but ${p.pdfiumLib} is missing (unexpected archive layout)`) - } - return true -} - -/** - * Download and install everything the PDF/image pipeline needs, then point the - * process at it. Idempotent: skips assets already present (pass `{ force: true }` - * to re-download). Returns a status report. - * - * @param {object} [options] - * @param {string} [options.dir] install home (default ~/.cache/fleischwolf or $FLEISCHWOLF_HOME) - * @param {string} [options.modelsUrl] base URL serving layout_heron.onnx + tableformer-*.onnx - * (default: fleischwolf's own hosted export, DEFAULT_MODELS_URL) - * @param {boolean} [options.ocr=true] also fetch the OCR model + dictionary - * @param {boolean} [options.tableformer=true] also fetch TableFormer from modelsUrl - * @param {boolean} [options.force=false] re-download assets that already exist - * @param {(msg: string) => void} [options.onProgress] - */ -async function installDependencies(options = {}) { - const p = resolvePaths(options.dir) - const onProgress = options.onProgress - const installed = [] - const missing = [] - fs.mkdirSync(p.models, { recursive: true }) - - // 1. pdfium (required for PDF). - if (await installPdfium(p, options.force, onProgress)) installed.push('pdfium') - - // 2. OCR recognition model + dictionary (for pages without a text layer). - if (options.ocr !== false) { - if (await ensureFile(p.ocrRec, OCR_REC_URL, options.force, onProgress, 'OCR model')) - installed.push('ocr_rec.onnx') - if (await ensureFile(p.ocrDict, OCR_DICT_URL, options.force, onProgress, 'OCR dictionary')) - installed.push('ppocr_keys_v1.txt') - } - - // 3. Layout (required) + TableFormer (optional) — from the configured base URL, - // defaulting to fleischwolf's own hosted export (DEFAULT_MODELS_URL) so this - // works with zero configuration; pass `{ modelsUrl }` / set - // `FLEISCHWOLF_MODELS_URL` to use your own export/host instead. - const base = (options.modelsUrl || process.env.FLEISCHWOLF_MODELS_URL || DEFAULT_MODELS_URL).replace( - /\/$/, - '', + `${missing.join(', ')}.\n\n${downloadGuide()}`, ) - if (!fs.existsSync(p.layout)) { - try { - if ( - await ensureFile(p.layout, `${base}/layout_heron.onnx`, options.force, onProgress, 'layout model') - ) { - installed.push('layout_heron.onnx') - // Large exports carry their weights in a sidecar `.onnx.data` - // (ONNX's external-data format, used above the ~2GB protobuf limit). - // Optional — most exports don't need one — so a missing sidecar is not - // an error. - await ensureOptionalSidecar(p.layout, `${base}/layout_heron.onnx.data`, onProgress) - } - } catch (e) { - // Surfaced below via status.missing + layoutSetupGuide(), with more - // actionable detail than the raw fetch error. - onProgress?.(`could not fetch layout model from ${base}: ${e.message}`) - } - } - if (options.tableformer !== false) { - for (const [file, dest] of [ - ['tableformer/encoder.onnx', p.tfEncoder], - ['tableformer/decoder.onnx', p.tfDecoder], - ['tableformer/bbox.onnx', p.tfBbox], - ]) { - // GitHub Release assets can't contain "/", so the hosted copy is flat - // (tableformer-encoder.onnx, …); a custom `modelsUrl` host is free to - // mirror either layout, since ensureOptionalSidecar/ensureFile below try - // the flat name. - const flat = file.replace(/\//g, '-') - try { - if (await ensureFile(dest, `${base}/${flat}`, options.force, onProgress, file)) { - installed.push(file) - await ensureOptionalSidecar(dest, `${base}/${flat}.data`, onProgress) - } - } catch (e) { - // TableFormer is optional (geometric fallback); note but don't fail. - onProgress?.(`skipped ${file}: ${e.message}`) - } - } - } - - exportEnv(p) - const status = checkDependencies(options) - - if (!status.ready) { - throw new Error( - `installDependencies: PDF conversion is not ready. Missing: ${status.missing.join(', ')}.\n\n` + - `layout_heron.onnx could not be fetched from ${base} — check the URL is reachable and\n` + - `serves layout_heron.onnx (and, optionally, tableformer-*.onnx) at that path.\n\n${layoutSetupGuide()}`, - ) - } - - return { ...status, installed, missing } } module.exports = { ML_FORMATS, - installDependencies, checkDependencies, assertMlReady, resolvePaths, diff --git a/crates/fleischwolf-node/examples/pdf-pipeline.mjs b/crates/fleischwolf-node/examples/pdf-pipeline.mjs index ae0dbb7f..092184fa 100644 --- a/crates/fleischwolf-node/examples/pdf-pipeline.mjs +++ b/crates/fleischwolf-node/examples/pdf-pipeline.mjs @@ -1,39 +1,25 @@ -// Converting PDFs/images: installing the ML dependencies and reusing a warm -// Pipeline. Run with: +// Converting PDFs/images, and reusing a warm Pipeline. Run with: // +// ../../../scripts/download_dependencies.sh # once, from the repo root // node examples/pdf-pipeline.mjs path/to/document.pdf // // The PDF/image path needs native assets that aren't bundled in the addon -// (pdfium + the layout/OCR/TableFormer ONNX models). `installDependencies()` -// provisions them; without it, converting a PDF throws a clear error. +// (pdfium + the layout/OCR/TableFormer ONNX models). +// `scripts/download_dependencies.sh` fetches them straight into ./models and +// ./.pdfium, which this package looks for by default — no env vars, no setup +// call needed. Without it, converting a PDF throws a clear error pointing here. -import { installDependencies, checkDependencies, convertFileAsync, Pipeline } from 'fleischwolf' +import { checkDependencies, convertFileAsync, Pipeline } from 'fleischwolf' const file = process.argv[2] ?? 'document.pdf' -// 1. Without the models, PDF conversion throws — the guard points you here. -console.log('deps before:', checkDependencies()) -try { - await convertFileAsync(file) -} catch (err) { - console.log('\nexpected (models not installed yet):\n ', err.message.split('.')[0], '…\n') -} - -// 2. Install them. pdfium + OCR download automatically; the layout + TableFormer -// ONNX come from a base URL you host (or set FLEISCHWOLF_MODELS_URL). If you -// exported the models locally, set DOCLING_LAYOUT_ONNX etc. instead and this -// call just validates + wires them up. -await installDependencies({ - modelsUrl: process.env.FLEISCHWOLF_MODELS_URL, // e.g. https://you.example/fleischwolf-models - onProgress: (m) => console.log(' ·', m), -}) -console.log('deps after:', checkDependencies(), '\n') +console.log('deps:', checkDependencies()) -// 3. Convert. For a single file, convertFileAsync is fine (runs off the event loop): +// A single file: convertFileAsync is fine (runs off the event loop). const res = await convertFileAsync(file, { to: 'markdown' }) console.log(res.content.slice(0, 500)) -// 4. For MANY PDFs, reuse a warm Pipeline so the models load once instead of per call: +// For MANY PDFs, reuse a warm Pipeline so the models load once instead of per call: const pipeline = new Pipeline({ strict: true }) for (const path of process.argv.slice(2)) { const r = pipeline.convertFile(path, { to: 'json' }) diff --git a/crates/fleischwolf-node/index.d.ts b/crates/fleischwolf-node/index.d.ts index 7913ef2c..34d4bd4f 100644 --- a/crates/fleischwolf-node/index.d.ts +++ b/crates/fleischwolf-node/index.d.ts @@ -68,36 +68,11 @@ export interface DependencyStatus { missing: string[] } -/** Options for {@link installDependencies}. */ -export interface InstallOptions { - /** Install home (default `~/.cache/fleischwolf` or `$FLEISCHWOLF_HOME`). */ - dir?: string - /** Base URL serving `layout_heron.onnx` and `tableformer/*.onnx`. Falls back to `$FLEISCHWOLF_MODELS_URL`. */ - modelsUrl?: string - /** Also fetch the OCR model + dictionary (default true). */ - ocr?: boolean - /** Also fetch TableFormer from `modelsUrl` if provided (default true). */ - tableformer?: boolean - /** Re-download assets that already exist (default false). */ - force?: boolean - /** Progress callback. */ - onProgress?: (message: string) => void -} - /** - * Download and install everything the PDF/image pipeline needs (pdfium + OCR - * always; layout + TableFormer from `modelsUrl`/`FLEISCHWOLF_MODELS_URL`), then - * point the process at it. Idempotent. Rejects if PDF conversion cannot be made - * ready (e.g. no models URL and no local layout model). - * - * @example - * import { installDependencies, convertFileAsync } from 'fleischwolf' - * await installDependencies({ modelsUrl: 'https://example.com/fleischwolf-models' }) - * const res = await convertFileAsync('paper.pdf', { to: 'markdown' }) + * Report which PDF/image dependencies are present on disk. Fetch them with + * `scripts/download_dependencies.sh` (see the package README) — this function + * only reports status, it does not download anything. */ -export declare function installDependencies(options?: InstallOptions): Promise - -/** Report which PDF/image dependencies are present, without downloading. */ export declare function checkDependencies(options?: { dir?: string }): DependencyStatus // --- streaming -------------------------------------------------------------- diff --git a/crates/fleischwolf-node/index.js b/crates/fleischwolf-node/index.js index 602609bc..1f295fe9 100644 --- a/crates/fleischwolf-node/index.js +++ b/crates/fleischwolf-node/index.js @@ -3,7 +3,8 @@ // Wraps the native N-API binding (loaded by `native.js`, which picks the right // prebuilt `.node` for the host platform) with two things: // 1. dependency guards — converting a PDF/image/METS input throws a clear -// error unless the ML models + pdfium are installed (see installDependencies); +// error unless the ML models + pdfium are on disk (see +// scripts/download_dependencies.sh); // 2. a `streamFileMarkdown` async generator over Markdown chunks. // // Works in Node.js and Bun (Bun implements N-API). @@ -11,11 +12,7 @@ 'use strict' const native = require('./native.js') -const { - installDependencies, - checkDependencies, - assertMlReady, -} = require('./deps.js') +const { checkDependencies, assertMlReady } = require('./deps.js') // Resolve the format id of an input for the ML guard. Uses the native // extension→format map; falls back to an explicitly-passed format string. @@ -170,7 +167,6 @@ module.exports.convertFileAsync = convertFileAsync module.exports.DocumentConverter = DocumentConverter module.exports.Pipeline = Pipeline module.exports.streamFileMarkdown = streamFileMarkdown -module.exports.installDependencies = installDependencies module.exports.checkDependencies = checkDependencies module.exports.supportedFormats = native.supportedFormats module.exports.formatFromName = native.formatFromName diff --git a/crates/fleischwolf-node/test/smoke.mjs b/crates/fleischwolf-node/test/smoke.mjs index b5562d13..44a6a843 100644 --- a/crates/fleischwolf-node/test/smoke.mjs +++ b/crates/fleischwolf-node/test/smoke.mjs @@ -96,24 +96,24 @@ async function main() { // These assume the ML models are NOT installed (true on a fresh CI checkout). const depsInstalled = checkDependencies().ready if (!depsInstalled) { - await check('convert PDF (sync) throws pointing at installDependencies', () => { + await check('convert PDF (sync) throws pointing at download_dependencies.sh', () => { assert.throws( () => convert({ name: 'doc.pdf', data: Buffer.from('%PDF-1.4') }), - /installDependencies/, + /download_dependencies\.sh/, ) }) await check('convertFileAsync PDF rejects (not a sync throw)', async () => { - await assert.rejects(convertFileAsync('missing.pdf'), /installDependencies/) + await assert.rejects(convertFileAsync('missing.pdf'), /download_dependencies\.sh/) }) await check('image bytes are guarded too', () => { - assert.throws(() => convert({ name: 'scan.png', data: Buffer.from([0]) }), /installDependencies/) + assert.throws(() => convert({ name: 'scan.png', data: Buffer.from([0]) }), /download_dependencies\.sh/) }) await check('Pipeline convertFile is guarded', () => { const pipe = new Pipeline() - assert.throws(() => pipe.convertFile('x.pdf'), /installDependencies/) + assert.throws(() => pipe.convertFile('x.pdf'), /download_dependencies\.sh/) }) } else { console.log(' -- ML deps installed; skipping guard checks') diff --git a/crates/fleischwolf-pdf/src/pdfium_backend.rs b/crates/fleischwolf-pdf/src/pdfium_backend.rs index 40a5c3f6..429223b0 100644 --- a/crates/fleischwolf-pdf/src/pdfium_backend.rs +++ b/crates/fleischwolf-pdf/src/pdfium_backend.rs @@ -66,9 +66,6 @@ pub struct PdfDocument { pub pages: Vec, } -/// Bind to the pdfium dynamic library. Honors `PDFIUM_DYNAMIC_LIB_PATH` (a -/// directory or file), else the directory of the current exe, else the system -/// library — mirroring how a deployment ships `libpdfium` alongside the binary. /// Whether to use the docling-parse line sanitizer ([`crate::dp_lines`]) for prose /// reconstruction — the default. Set `DOCLING_LEGACY_LINES` to fall back to the /// older gap-heuristic `lines_from_glyphs`. @@ -97,16 +94,34 @@ pub(crate) fn use_parser_code() -> bool { std::env::var("DOCLING_PDFIUM_WORDS").is_err() && std::env::var("DOCLING_PDFIUM_TEXT").is_err() } +/// Try binding pdfium from a directory (or a literal library file path): +/// `/` first, else `` itself as the file. +fn try_bind_dir(path: &str) -> Option> { + let name = Pdfium::pdfium_platform_library_name_at_path(path); + if let Ok(b) = Pdfium::bind_to_library(&name) { + return Some(b); + } + Pdfium::bind_to_library(path).ok() +} + +/// Bind to the pdfium dynamic library. Honors `PDFIUM_DYNAMIC_LIB_PATH` (a +/// directory or file) first; else falls back to `.pdfium/lib` relative to the +/// current directory (the layout `scripts/download_dependencies.sh` and +/// `scripts/pdf_setup.sh` both produce); else the system library. fn bind() -> Result { if let Ok(path) = std::env::var("PDFIUM_DYNAMIC_LIB_PATH") { - let name = Pdfium::pdfium_platform_library_name_at_path(&path); - if let Ok(b) = Pdfium::bind_to_library(&name) { - return Ok(Pdfium::new(b)); - } - if let Ok(b) = Pdfium::bind_to_library(&path) { + if let Some(b) = try_bind_dir(&path) { return Ok(Pdfium::new(b)); } } + // No env var (or it didn't resolve): fall back to `.pdfium/lib` relative to + // the current directory — mirroring `layout.rs`/`ocr.rs`'s `models/…` + // defaults — the layout `scripts/download_dependencies.sh` (and + // `scripts/pdf_setup.sh`) produce, so a checkout with the dependencies + // downloaded next to it needs no env var at all. + if let Some(b) = try_bind_dir(".pdfium/lib") { + return Ok(Pdfium::new(b)); + } Pdfium::bind_to_system_library().map(Pdfium::new) } diff --git a/scripts/download_dependencies.sh b/scripts/download_dependencies.sh new file mode 100755 index 00000000..c9cc0b8c --- /dev/null +++ b/scripts/download_dependencies.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env sh +# Fetch the PDF/image ML pipeline's native dependencies — pdfium + the ONNX +# models (layout, OCR, TableFormer) — from this repo's GitHub Releases, +# straight into the current directory. No npm, no Python, no env vars needed +# afterwards: both the Rust CLI and the Node.js/Bun bindings look for +# `models/` and `.pdfium/lib` relative to the process's current directory by +# default. +# +# Run from your app's directory (or a checkout of this repo): +# scripts/download_dependencies.sh +# or, without a checkout: +# curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/download_dependencies.sh | sh +# +# Then either: +# cargo run -p fleischwolf-cli -- +# or: +# npm i fleischwolf +# node -e "import { convertFileAsync } from 'fleischwolf'; const r = await convertFileAsync('example.pdf', { to: 'markdown' }); console.log(r.content) " +# +# Downloads (from https://github.com/artiz/fleischwolf/releases, tag +# models-v1 by default — override the base with $FLEISCHWOLF_MODELS_URL): +# .pdfium/lib/libpdfium.so (Linux x64) +# models/layout_heron.onnx +# models/ocr_rec.onnx +# models/ppocr_keys_v1.txt +# models/tableformer/encoder.onnx +# models/tableformer/decoder.onnx (+ decoder.onnx.data, if the export needs it) +# models/tableformer/bbox.onnx +# +# pdfium is Linux x64 only for now, matching what's hosted in the release; for +# other platforms (or to build the models from source) see scripts/pdf_setup.sh. +# +# Idempotent: skips files already on disk. Pass --force to re-fetch everything. +set -eu + +BASE_URL="${FLEISCHWOLF_MODELS_URL:-https://github.com/artiz/fleischwolf/releases/download/models-v1}" + +FORCE=false +for arg in "$@"; do + case "$arg" in + --force) FORCE=true ;; + *) + echo "usage: download_dependencies.sh [--force]" >&2 + exit 2 + ;; + esac +done + +if ! command -v curl >/dev/null 2>&1; then + echo "error: curl is required" >&2 + exit 1 +fi + +mkdir -p .pdfium/lib models/tableformer + +fetch() { # + if [ "$FORCE" = false ] && [ -f "$2" ]; then + echo " = $2 (already present)" + return 0 + fi + echo " > $2" + curl -fsSL -o "$2.download" "$1" + mv "$2.download" "$2" +} + +fetch_optional() { # — ignore a missing/failed asset (sidecar files) + if [ "$FORCE" = false ] && [ -f "$2" ]; then + return 0 + fi + if curl -fsSL -o "$2.download" "$1" 2>/dev/null; then + mv "$2.download" "$2" + echo " > $2" + else + rm -f "$2.download" + fi +} + +echo "fetching fleischwolf ML dependencies from $BASE_URL" +fetch "$BASE_URL/libpdfium.so" .pdfium/lib/libpdfium.so +fetch "$BASE_URL/layout_heron.onnx" models/layout_heron.onnx +fetch "$BASE_URL/ocr_rec.onnx" models/ocr_rec.onnx +fetch "$BASE_URL/ppocr_keys_v1.txt" models/ppocr_keys_v1.txt +fetch "$BASE_URL/encoder.onnx" models/tableformer/encoder.onnx +fetch "$BASE_URL/decoder.onnx" models/tableformer/decoder.onnx +fetch_optional "$BASE_URL/decoder.onnx.data" models/tableformer/decoder.onnx.data +fetch "$BASE_URL/bbox.onnx" models/tableformer/bbox.onnx + +echo "done — models/ and .pdfium/lib populated in $(pwd)" diff --git a/scripts/setup_nodejs_dependencies.sh b/scripts/setup_nodejs_dependencies.sh deleted file mode 100755 index 8702c96a..00000000 --- a/scripts/setup_nodejs_dependencies.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# Fetch the PDF/image ML pipeline's native dependencies for a Node.js/Bun app -# using the `fleischwolf` npm package. A thin, curl-pipeable convenience -# wrapper around `installDependencies()` (crates/fleischwolf-node/deps.js) — -# all the real download/path logic lives there; this just drives it from a -# directory that may not have `fleischwolf` installed yet. -# -# Run from your app's directory (where `fleischwolf` is/will be an npm dep): -# curl -fsSL https://raw.githubusercontent.com/artiz/fleischwolf/master/scripts/setup_nodejs_dependencies.sh | bash -# or, from a checkout of this repo: -# bash scripts/setup_nodejs_dependencies.sh -# -# Downloads (to ~/.cache/fleischwolf by default; override with $FLEISCHWOLF_HOME): -# - libpdfium + the PP-OCRv3 recognition model — from their own public releases -# - the RT-DETR layout model + TableFormer — PyTorch->ONNX exports of -# docling-project's own models (Apache-2.0 / CDLA-Permissive-2.0), hosted -# as GitHub Release assets on this repo (see MODELS_NOTICE.md) -# -# Idempotent: skips anything already downloaded. Pass --force to re-fetch -# everything. Set FLEISCHWOLF_MODELS_URL to use your own model export/host -# instead of fleischwolf's. -# -# This only *caches* the files — your app still needs to call -# `await installDependencies()` once at startup (before converting any -# PDF/image) so the native pipeline in *that* process picks them up; with -# everything already on disk, that call becomes an instant no-op. -# -# Requires: node (with npm) and network access. -set -euo pipefail - -FORCE=false -for arg in "$@"; do - case "$arg" in - --force) FORCE=true ;; - *) - echo "usage: setup_nodejs_dependencies.sh [--force]" >&2 - exit 2 - ;; - esac -done - -if ! command -v node >/dev/null 2>&1; then - echo "error: node is required (https://nodejs.org)" >&2 - exit 1 -fi - -# Make sure `fleischwolf` is resolvable from the current directory — install -# it locally if it isn't (piping this script via curl means there's no -# checked-out repo/node_modules to begin with). -if ! node -e "require.resolve('fleischwolf')" >/dev/null 2>&1; then - if [ -f package.json ]; then - echo "→ installing the fleischwolf npm package" - npm install fleischwolf - else - echo "error: no package.json in $(pwd) and 'fleischwolf' isn't resolvable." >&2 - echo " run this from your Node app's directory (with a package.json), or:" >&2 - echo " npm init -y && npm install fleischwolf" >&2 - exit 1 - fi -fi - -node -e " -const { installDependencies } = require('fleischwolf'); -installDependencies({ force: ${FORCE}, onProgress: (m) => console.error(' ' + m) }) - .then((status) => { - console.error(''); - console.error('done — installed under ' + status.home); - console.error(JSON.stringify(status, null, 2)); - }) - .catch((err) => { - console.error(err.message); - process.exitCode = 1; - }); -"