diff --git a/.gitignore b/.gitignore
index 85f1b14..ddb381c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ Cargo.lock.bak
.vscode/
*.swp
gotenberg-8.29-test-example/
+.worktrees/
+tmp/
diff --git a/Cargo.lock b/Cargo.lock
index b2f942f..111aed9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -747,6 +747,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -840,6 +849,16 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "ctor"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
+dependencies = [
+ "quote",
+ "syn",
+]
+
[[package]]
name = "cucumber"
version = "0.21.1"
@@ -1081,16 +1100,22 @@ name = "engine"
version = "0.1.0"
dependencies = [
"axum 0.8.9",
+ "base64 0.22.1",
"chromiumoxide",
+ "dirs",
+ "flate2",
"futures-util",
"humantime-serde",
"image",
"lopdf",
"proptest",
"pulldown-cmark",
+ "reqwest 0.12.28",
"serde",
"serde_json",
+ "sha2",
"static_assertions",
+ "tar",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -1098,7 +1123,9 @@ dependencies = [
"tracing",
"tracing-subscriber",
"urlencoding",
+ "walkdir",
"which 7.0.3",
+ "zip",
]
[[package]]
@@ -1138,6 +1165,17 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "filetime"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "libredox",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1904,6 +1942,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "inflections"
version = "1.1.1"
@@ -1978,6 +2025,15 @@ dependencies = [
[[package]]
name = "js"
version = "0.1.0"
+dependencies = [
+ "engine",
+ "napi",
+ "napi-build",
+ "napi-derive",
+ "serde",
+ "serde_json",
+ "tokio",
+]
[[package]]
name = "js-sys"
@@ -2032,13 +2088,26 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
[[package]]
name = "libredox"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
+ "bitflags",
"libc",
+ "plain",
+ "redox_syscall 0.7.4",
]
[[package]]
@@ -2158,6 +2227,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -2228,6 +2306,66 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "napi"
+version = "2.16.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
+dependencies = [
+ "bitflags",
+ "ctor",
+ "napi-derive",
+ "napi-sys",
+ "once_cell",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
+[[package]]
+name = "napi-build"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
+
+[[package]]
+name = "napi-derive"
+version = "2.16.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
+dependencies = [
+ "cfg-if",
+ "convert_case",
+ "napi-derive-backend",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "napi-derive-backend"
+version = "1.0.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
+dependencies = [
+ "convert_case",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "semver",
+ "syn",
+]
+
+[[package]]
+name = "napi-sys"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
+dependencies = [
+ "libloading",
+]
+
[[package]]
name = "nom"
version = "7.1.3"
@@ -2411,7 +2549,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
- "redox_syscall",
+ "redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -2516,6 +2654,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
[[package]]
name = "png"
version = "0.18.1"
@@ -2529,6 +2673,12 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2692,6 +2842,92 @@ checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "py"
version = "0.1.0"
+dependencies = [
+ "engine",
+ "parking_lot",
+ "pyo3",
+ "pyo3-async-runtimes",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+]
+
+[[package]]
+name = "pyo3"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
+dependencies = [
+ "cfg-if",
+ "indoc",
+ "libc",
+ "memoffset",
+ "once_cell",
+ "portable-atomic",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+ "unindent",
+]
+
+[[package]]
+name = "pyo3-async-runtimes"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2529f0be73ffd2be0cc43c013a640796558aa12d7ca0aab5cc14f375b4733031"
+dependencies = [
+ "futures",
+ "once_cell",
+ "pin-project-lite",
+ "pyo3",
+ "tokio",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
+dependencies = [
+ "once_cell",
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "pyo3-build-config",
+ "quote",
+ "syn",
+]
[[package]]
name = "quick-error"
@@ -2878,6 +3114,15 @@ dependencies = [
"bitflags",
]
+[[package]]
+name = "redox_syscall"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "redox_users"
version = "0.4.6"
@@ -3418,8 +3663,8 @@ dependencies = [
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
+ "ulid",
"url",
- "uuid",
"zip",
]
@@ -3655,6 +3900,23 @@ dependencies = [
"windows",
]
+[[package]]
+name = "tar"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4132,6 +4394,16 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+[[package]]
+name = "ulid"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
+dependencies = [
+ "rand 0.9.4",
+ "web-time",
+]
+
[[package]]
name = "unarray"
version = "0.1.4"
@@ -4156,6 +4428,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -4168,6 +4446,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "unindent"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -4922,6 +5206,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
[[package]]
name = "xz2"
version = "0.1.7"
diff --git a/Cargo.toml b/Cargo.toml
index dae59e0..efc55db 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,9 @@ authors = []
# Internal
engine = { path = "crates/engine" }
+# Encoding
+base64 = "0.22"
+
# Async / runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
@@ -69,7 +72,7 @@ url = "2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# Identifiers
-uuid = { version = "1", features = ["v4"] }
+ulid = "1"
# CLI
assert_cmd = "2"
@@ -83,10 +86,17 @@ walkdir = "2"
lopdf = "0.34"
zip = "2"
+# Chrome auto-download
+sha2 = "0.10"
+flate2 = "1"
+tar = "0.4"
+dirs = "5"
+
# Bindings
pyo3 = { version = "0.22", features = ["extension-module"] }
-napi = { version = "2", features = ["napi8"] }
+napi = { version = "2", features = ["napi8", "tokio_rt", "serde-json"] }
napi-derive = "2"
+napi-build = "2"
[profile.release]
opt-level = 3
diff --git a/Dockerfile b/Dockerfile
index e5d40bf..35fd7e4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -178,13 +178,25 @@ LABEL org.opencontainers.image.title="Folio" \
org.opencontainers.image.authors="Folio Team" \
org.opencontainers.image.source="https://github.com/been-there-done-that/folio"
-RUN apt-get update -qq && apt-get upgrade -yqq && \
- DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
+# Install LibreOffice from Debian bookworm-backports (newer than bookworm's 7.4).
+# python3-uno must match the LO version so it is also pulled from backports.
+RUN echo "deb http://deb.debian.org/debian bookworm-backports main" \
+ > /etc/apt/sources.list.d/backports.list && \
+ apt-get update -qq && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -y -t bookworm-backports --no-install-recommends \
libreoffice-writer \
libreoffice-calc \
libreoffice-impress \
libreoffice-draw \
- && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
+ python3-uno && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ python3-minimal \
+ python3-pip && \
+ pip3 install --no-cache-dir --break-system-packages unoserver==2.2.1 && \
+ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
+
+# Force headless SVP rendering backend — skips virtual display probe (~50–100ms).
+ENV SAL_USE_VCLPLUGIN=svp
COPY --link --chown="${FOLIO_USER_UID}:${FOLIO_USER_GID}" \
--from=builder-full /app/target/release/folio-server /usr/bin/
@@ -195,7 +207,7 @@ USER folio
WORKDIR /home/folio
EXPOSE 3000
-HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
+HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
ENTRYPOINT ["/usr/bin/tini", "--"]
@@ -248,13 +260,25 @@ LABEL org.opencontainers.image.title="Folio (LibreOffice)" \
org.opencontainers.image.authors="Folio Team" \
org.opencontainers.image.source="https://github.com/been-there-done-that/folio"
-RUN apt-get update -qq && apt-get upgrade -yqq && \
- DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
+# Install LibreOffice from Debian bookworm-backports (newer than bookworm's 7.4).
+# python3-uno must match the LO version so it is also pulled from backports.
+RUN echo "deb http://deb.debian.org/debian bookworm-backports main" \
+ > /etc/apt/sources.list.d/backports.list && \
+ apt-get update -qq && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -y -t bookworm-backports --no-install-recommends \
libreoffice-writer \
libreoffice-calc \
libreoffice-impress \
libreoffice-draw \
- && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
+ python3-uno && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ python3-minimal \
+ python3-pip && \
+ pip3 install --no-cache-dir --break-system-packages unoserver==2.2.1 && \
+ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
+
+# Force headless SVP rendering backend — skips virtual display probe (~50–100ms).
+ENV SAL_USE_VCLPLUGIN=svp
COPY --link --chown="${FOLIO_USER_UID}:${FOLIO_USER_GID}" \
--from=builder-libreoffice /app/target/release/folio-server /usr/bin/
diff --git a/Dockerfile.test b/Dockerfile.test
index a84f1e1..cac399e 100644
--- a/Dockerfile.test
+++ b/Dockerfile.test
@@ -1,70 +1,74 @@
-# Dockerfile for running tests with all dependencies
-# Uses cargo-chef for efficient dependency caching
+# Dockerfile for running the full test suite (unit + BDD) with all dependencies.
+# Mirrors the folio production image: Chromium + LibreOffice + unoserver.
-FROM rust:1.88 AS chef
+ARG RUST_VERSION=1.88
+
+# =============================================================================
+# Stage: chef — cargo-chef for dependency caching
+# =============================================================================
+FROM rust:${RUST_VERSION} AS chef
WORKDIR /app
RUN cargo install cargo-chef --locked
+# =============================================================================
+# Stage: planner — produce recipe.json
+# =============================================================================
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
+# =============================================================================
+# Stage: builder — compile everything including folio-server binary
+# =============================================================================
FROM chef AS builder
-# Install system dependencies including Chrome (Chromium) and LibreOffice
-RUN apt-get update && apt-get install -y \
- libgtk-3-0 \
- libx11-xcb1 \
- libxcomposite1 \
- libxcursor1 \
- libxdamage1 \
- libxi6 \
- libxtst6 \
- libnss3 \
- libcups2 \
- libxss1 \
- libxrandr2 \
- libasound2 \
- libatk1.0-0 \
- libatk-bridge2.0-0 \
- libpangocairo-1.0-0 \
- libpango-1.0-0 \
- libcairo2 \
- libgdk-pixbuf2.0-0 \
- libgconf-2-4 \
- libgdm1 \
- libglib2.0-0 \
- libgl1-mesa-glx \
- fonts-liberation \
- xdg-utils \
- wget \
- curl \
- unzip \
+# Runtime deps for Chromium and LibreOffice needed at test time
+RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
+ # Chromium
chromium \
- libreoffice \
- jq \
- poppler-utils \
- default-jre-headless \
- && rm -rf /var/lib/apt/lists/*
-
-# Install verapdf (PDF/A validator)
-RUN VERAPDF_VERSION=1.26.2 && \
- curl -fsSL "https://software.verapdf.org/releases/${VERAPDF_VERSION}/verapdf-greenfield-${VERAPDF_VERSION}-CLI.zip" \
- -o /tmp/verapdf.zip && \
- unzip /tmp/verapdf.zip -d /opt && \
- mv "/opt/verapdf-greenfield-${VERAPDF_VERSION}" /opt/verapdf && \
- ln -s /opt/verapdf/verapdf /usr/local/bin/verapdf && \
- rm /tmp/verapdf.zip
+ libgtk-3-0 libx11-xcb1 libxcomposite1 libxcursor1 \
+ libxdamage1 libxi6 libxtst6 libnss3 libcups2 libxss1 \
+ libxrandr2 libasound2 libatk1.0-0 libatk-bridge2.0-0 \
+ libpangocairo-1.0-0 libpango-1.0-0 libcairo2 \
+ libgdk-pixbuf2.0-0 libglib2.0-0 libgl1-mesa-glx \
+ fonts-liberation \
+ # LibreOffice + python3-uno (same source as production image)
+ && echo "deb http://deb.debian.org/debian bookworm-backports main" \
+ > /etc/apt/sources.list.d/backports.list \
+ && apt-get update -qq \
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y -t bookworm-backports \
+ -qq --no-install-recommends \
+ libreoffice-writer \
+ libreoffice-calc \
+ libreoffice-impress \
+ libreoffice-draw \
+ python3-uno \
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
+ python3-minimal python3-pip \
+ # PDF tools used by BDD assertion steps
+ poppler-utils \
+ qpdf \
+ ghostscript \
+ && pip3 install --no-cache-dir --break-system-packages unoserver==2.2.1 \
+ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
ENV CHROME_PATH=/usr/bin/chromium
+ENV CHROME_BIN=/usr/bin/chromium
+# Required so Chromium can run as root inside the container
+ENV FOLIO_NO_SANDBOX=true
+ENV SAL_USE_VCLPLUGIN=svp
ENV RUST_LOG=info
-# Build dependencies (cached layer) - only re-runs if recipe.json changes
+# Cache dependencies
COPY --from=planner /app/recipe.json recipe.json
-RUN cargo chef cook --release --recipe-path recipe.json
+RUN cargo chef cook --recipe-path recipe.json
+RUN cargo chef cook --recipe-path recipe.json --tests
-# Copy source code (only invalidates cache if source changes)
+# Build everything: folio-server binary + all test binaries
COPY . .
+RUN cargo test --no-run
-# Run tests by default
-CMD ["cargo", "test", "--release", "--", "--ignored", "--test-threads=1"]
+# Run pre-compiled test binaries — no recompilation at container start.
+# --test-threads=1 because integration tests share a single unoserver
+# instance on port 2003; running them in parallel triggers contention.
+CMD ["cargo", "test", "--no-fail-fast", "--", "--test-threads=1"]
diff --git a/README.md b/README.md
index db61948..2d42938 100644
--- a/README.md
+++ b/README.md
@@ -1,154 +1,162 @@
-# Folio
-
-
+
+Folio
+
-
-
-
-
-
-
-
-
-
-
-
+ A Rust-native, Gotenberg-compatible PDF service — with a live operator console.
- A modern, Rust-native PDF generation engine
- True browser-grade fidelity • Gotenberg-compatible API • Memory safe
+
+
+
---
-## 📖 Table of Contents
-
-- [What is Folio?](#what-is-folio)
-- [Why Folio?](#why-folio)
-- [Quick Start](#quick-start)
-- [Usage Modes](#usage-modes)
-- [Features](#features)
-- [Documentation](#documentation)
-- [Project Structure](#project-structure)
-- [Development](#development)
-- [Testing](#testing)
-- [Roadmap](#roadmap)
-- [Contributing](#contributing)
-- [License](#license)
+Folio converts **HTML, URLs, Markdown, and Office documents** into PDFs using
+real Chrome under the hood. It speaks the same HTTP API as
+[Gotenberg](https://github.com/gotenberg/gotenberg), so most existing
+clients can point at Folio with only a base-URL change.
+
+Unlike Gotenberg, Folio also runs as a **Rust library, a CLI, and a single
+binary** — and ships with a live operator console at `/_/` so you can see
+what your PDF service is actually doing without wiring up Grafana first.
+
+> **Status:** active. Core conversions and PDF ops are production-ready.
+> Webhook callback delivery, batch ZIP output, and a few advanced Chromium
+> options are still in progress — see the [feature comparison](./comparison.md).
---
-## What is Folio?
+## Why Folio
+
+- **Gotenberg-compatible.** Same routes (`/forms/chromium/*`,
+ `/forms/libreoffice/convert`, `/forms/pdfengines/*`), same multipart
+ contract. Drop-in for ~85% of workloads.
+- **Memory-safe.** Rust core; no GC pauses, no parser-level CVEs from
+ malformed inputs.
+- **Four ways to run it.** HTTP server, CLI, Rust library, Docker — pick
+ whichever fits your shape. The library is the source of truth; the
+ server and CLI are thin wrappers.
+- **Observability-first.** Prometheus metrics, OpenTelemetry traces, and
+ a built-in Svelte SPA at `/_/` showing live RPS, p95 latency,
+ per-engine health, concurrency, and active batches over SSE.
+- **Slim deployment targets.** Multi-stage Dockerfile produces full,
+ Chromium-only, LibreOffice-only, Cloud Run, and Lambda images.
+
+For the honest comparison against Gotenberg (what's parity, what's behind,
+what's ahead) read [`comparison.md`](./comparison.md).
-**Folio** (from Latin *folium*, meaning "leaf" or "sheet of paper") is a high-performance PDF generation engine built in Rust. It converts HTML, URLs, Markdown, and Office documents to PDF with **true browser-grade fidelity** by leveraging Chrome's rendering engine via the Chrome DevTools Protocol (CDP).
+---
-> Like a printer's folio marks the beginning of a new page, Folio marks a new chapter in document conversion technology.
+## 60-second quickstart
-### Key Highlights
+```bash
+# Run the server (Docker, full image)
+docker run --rm -p 3000:3000 ghcr.io/__deesh_reddy__/folio:latest
-- **True Browser Fidelity**: Renders using real Chrome/Chromium — full CSS3, JavaScript, Web Fonts support
-- **Gotenberg-Compatible**: Drop-in replacement for existing Gotenberg deployments
-- **Memory Safe**: Rust's compile-time guarantees prevent entire classes of bugs
-- **Multiple Interfaces**: HTTP API, CLI, Rust library, and language bindings (Python/Node.js)
-- **Self-Contained**: Library mode requires no external HTTP services
+# Convert a URL to PDF
+curl -X POST http://localhost:3000/forms/chromium/convert/url \
+ -F "url=https://example.com" \
+ -F "landscape=true" \
+ -o out.pdf
----
+# Open the operator console
+open http://localhost:3000/_/
+```
-## Why Folio?
+That's it. Same multipart contract for HTML, Markdown, Office, merge,
+split, watermark, etc.
-### Comparison Table
+---
-| Feature | **Folio** | Gotenberg | WeasyPrint | wkhtmltopdf |
-|---------|------------|-----------|-------------|-------------|
-| **Language** | Rust 🦀 | Go | Python | C++ |
-| **Rendering** | Chrome (CDP) | Chrome | Custom engine | QtWebKit (2012) |
-| **Modern CSS** | ✅ Full | ✅ Full | ⚠️ Limited | ❌ Legacy |
-| **JavaScript** | ✅ Full V8 | ✅ Full | ❌ None | ⚠️ ES3 |
-| **Usage Modes** | 4 (Server/CLI/Lib/Bindings) | Server only | Library only | CLI only |
-| **Memory Safety** | ✅ Compile-time | GC | Runtime | Manual |
-| **Gotenberg API** | ✅ Compatible | ✅ Native | ❌ | ❌ |
-| **Screenshots** | ✅ Done | ✅ | ❌ | ❌ |
-| **Structured Logging** | ✅ Full (tracing) | ✅ (slog) | ❌ | ❌ |
-| **Prometheus Metrics** | ✅ `/prometheus/metrics` | ✅ | ❌ | ❌ |
-| **OpenTelemetry** | ✅ OTLP HTTP | ✅ | ❌ | ❌ |
-| **Process Supervision** | 🚧 In Progress | ✅ | ❌ | ❌ |
+## Install
-### Architecture Pattern
+| Surface | Command |
+|------------------|---------------------------------------------------------------|
+| Docker (full) | `docker pull ghcr.io/__deesh_reddy__/folio:latest` |
+| Docker (slim) | `docker pull ghcr.io/__deesh_reddy__/folio:latest-chromium` |
+| CLI (cargo) | `cargo install --path crates/cli` → `folio --help` |
+| Server (cargo) | `cargo run -p server -- serve --port 3000` |
+| Library | `folio-engine = { path = "crates/engine" }` in `Cargo.toml` |
-```
-┌─────────────────────────────────────────────────────────────┐
-│ USAGE MODES │
-│ Server CLI Rust Lib Python Node.js │
-│ │ │ │ │ │ │
-│ └────────┴─────────┴──────────┴─────────┘ │
-│ │ │
-│ ┌──────────┴──────────┐ │
-│ │ engine │ ← Single source │
-│ │ • ChromiumEngine │ of truth │
-│ │ • LibreOfficeEngine │ │
-│ │ • PdfOperations │ │
-│ └──────────┬────────────┘ │
-│ │ │
-│ ┌──────────┴──────────┐ │
-│ │ Chrome (CDP) │ │
-│ └──────────────────────┘ │
-└─────────────────────────────────────────────────────────────┘
-```
+**Prerequisites for non-Docker installs:** Rust 1.75+, Chrome/Chromium
+(auto-detected, or set `CHROME_PATH`), and optionally LibreOffice for
+Office conversion.
----
+### Embeddable bindings (v1: conversion)
-## Quick Start
+| Surface | Install |
+|---|---|
+| Python | `pip install folio` — see `bindings/python/README.md` |
+| Node.js | `npm install @folio/folio` — see `bindings/node/README.md` |
-### Prerequisites
+Both bindings auto-download a pinned Chrome on first use if no system
+Chrome is found. v1 supports HTML / URL / Markdown / Office → PDF;
+PDF ops and screenshots ship in v2 (spec:
+`docs/superpowers/specs/2026-05-01-bindings-design.md`).
-- **Rust** 1.75+ ([install](https://rustup.rs/))
-- **Chrome/Chromium** (auto-detected) or set `CHROME_PATH`
-- **LibreOffice** (optional, for Office document conversion)
+---
-### Option 1: HTTP Server (Gotenberg-Compatible)
+## HTTP API at a glance
-```bash
-# Build and run
-cargo run -p server -- serve --port 3000
+All routes are `POST` and accept multipart/form-data unless noted.
-# Or with Docker (full image — Chromium + LibreOffice)
-docker build --target folio -t folio:latest .
-docker run -p 3000:3000 folio:latest
+### Chromium (HTML / URL / Markdown → PDF or screenshot)
+```
+/forms/chromium/convert/{html,url,markdown}
+/forms/chromium/screenshot/{html,url,markdown}
+```
-# Convert URL to PDF
-curl -X POST http://localhost:3000/forms/chromium/convert/url \
- -F "url=https://example.com" \
- -F "landscape=true" \
- -o output.pdf
+### LibreOffice (100+ Office formats → PDF)
+```
+/forms/libreoffice/convert
```
-### Option 2: CLI
+### PDF operations
+```
+/forms/pdfengines/{merge,split,flatten,rotate,watermark,convert,encrypt}
+/forms/pdfengines/metadata/{read,write}
+/forms/pdfengines/bookmarks/{read,write}
+```
-```bash
-# Install
-cargo install --path crates/cli
+### Operational
+```
+GET /health → JSON health + per-engine status
+GET /version → plain text
+GET /prometheus/metrics → Prometheus text format
+GET /_/ → operator console (SPA)
+GET /_/sse → Server-Sent Events stream
+```
+
+For the gap analysis vs Gotenberg, see [`comparison.md`](./comparison.md).
-# Convert HTML to PDF
-folio convert --html index.html --output out.pdf
+---
-# Convert URL to PDF
-folio convert --url https://example.com --output out.pdf
+## CLI
-# Batch conversion
-folio batch --input-dir ./docs/ --output-dir ./pdfs/
+```bash
+folio convert --html index.html --output out.pdf
+folio convert --url https://example.com --output out.pdf
+folio convert --markdown README.md --output out.pdf
+folio convert --office report.docx --output out.pdf
+
+folio merge a.pdf b.pdf c.pdf --output combined.pdf
+folio split input.pdf --mode uniform --span 1 --output-dir ./pages/
+folio flatten input.pdf --output flat.pdf
+folio rotate input.pdf --angle 90 --output rotated.pdf
+folio metadata read input.pdf
+folio metadata write input.pdf '{"Title":"Q2 Review"}'
```
-### Option 3: Rust Library
+Shell completions: `folio completion zsh > ~/.zfunc/_folio`.
-```toml
-# Cargo.toml
-[dependencies]
-folio-engine = { path = "crates/engine" }
-```
+---
+
+## Library
```rust
use engine::ChromiumEngine;
@@ -156,459 +164,167 @@ use engine::ChromiumEngine;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let engine = ChromiumEngine::launch().await?;
- let pdf = engine.html_to_pdf("Hello World
", None, &Default::default(), &Default::default()).await?;
- std::fs::write("output.pdf", pdf)?;
+ let pdf = engine
+ .html_to_pdf("Hello
", None, &Default::default(), &Default::default())
+ .await?;
+ std::fs::write("out.pdf", pdf)?;
Ok(())
}
```
-### Option 4: Docker Compose (Development)
-
-```bash
-# Copy example environment file
-cp .env.example .env
-
-# Start Folio with all dependencies
-make run
-
-# Run tests
-make test-integration
-
-# Stop
-make stop
-```
+The engine crate has zero dependency on `axum` or `tower` — it's the same
+code path the server uses, just without an HTTP layer in front.
---
-## Usage Modes
+## Operator console
-### 1. Server Mode (HTTP API)
+`GET /_/` serves a Svelte SPA driven by Server-Sent Events. In one screen:
-Gotenberg-compatible REST API for document conversion:
+- **Ticker:** RPS, p95 latency, error %, in-flight count
+- **Routes table:** per-endpoint p50 / p95 / p99, error %, load %
+- **Engines:** Chromium / LibreOffice up-down + restart count
+- **Concurrency grid:** active vs cap, with warn/crit thresholds
+- **Throughput strip:** 30-min RPS + p95 trend with SLA overlay
+- **Resources:** CPU % and memory MB
+- **Batches:** progress + per-item state for active batches
+- **Logs:** last 20 requests, last 10 errors
-| Endpoint | Method | Input | Output |
-|----------|--------|-------|--------|
-| `/forms/chromium/convert/html` | POST | HTML file | PDF |
-| `/forms/chromium/convert/url` | POST | URL | PDF |
-| `/forms/chromium/convert/markdown` | POST | Markdown | PDF |
-| `/forms/chromium/screenshot/html` | POST | HTML | PNG/JPEG/WebP |
-| `/forms/libreoffice/convert` | POST | Office docs | PDF |
-| `/forms/pdfengines/merge` | POST | PDFs | Merged PDF |
-| `/forms/pdfengines/split` | POST | PDF | Split PDFs |
-| `/health` | GET | - | Health status |
+This is the cleanest lead Folio has over Gotenberg today; it's where the
+last 30 commits have lived. If you've ever bolted Grafana onto Gotenberg
+just to see whether it's healthy — this replaces that step.
-See [API Documentation](./docs/gotenberg-spec.md) for full details.
+---
-### 2. CLI Mode
+## Configuration
-Command-line interface for batch operations and scripting:
+Common flags (every flag is also `FOLIO_*` env-overridable):
```bash
-# Convert various formats
-folio convert --html file.html --output out.pdf
-folio convert --url https://example.com --output out.pdf
-folio convert --markdown README.md --output readme.pdf
-
-# PDF operations
-folio merge --output combined.pdf file1.pdf file2.pdf
-folio split input.pdf --output-dir ./split/
-folio flatten input.pdf --output flat.pdf
-folio metadata read input.pdf
-```
-
-### 3. Library Mode (Rust)
-
-Use Folio as a Rust library in your applications:
-
-```rust
-// HTML to PDF
-let engine = ChromiumEngine::launch().await?;
-let pdf = engine.html_to_pdf(html, None, &opts, &ctx).await?;
-
-// URL to PDF
-let pdf = engine.url_to_pdf("https://example.com", &opts, &ctx).await?;
-
-// Markdown to PDF
-let pdf = engine.markdown_to_pdf(markdown, &opts, &ctx).await?;
-```
-
-### 4. Language Bindings
-
-**Python** ([Planned]):
-```python
-import folio
-
-engine = folio.ChromiumEngine()
-pdf = engine.html_to_pdf("Hello
")
-```
-
-**Node.js** ([Planned]):
-```javascript
-const folio = require('folio');
-const engine = new folio.ChromiumEngine();
-const pdf = await engine.htmlToPdf('Hello
');
+folio-server serve \
+ --host 0.0.0.0 --port 3000 \
+ --concurrency 8 \
+ --max-body-bytes 52428800 \ # 50 MiB
+ --request-timeout 120s \
+ --chrome /usr/bin/google-chrome --no-sandbox \
+ --soffice /usr/bin/soffice \
+ --log-level info --log-format json \
+ --api-basic-auth-username admin --api-basic-auth-password secret \
+ --otel-enabled --otel-endpoint http://localhost:4318/v1/traces
```
----
+Run `folio-server serve --help` for the full flag reference.
-## Features
-
-### ✅ Implemented
-
-- **HTML/URL to PDF**: Full Chrome rendering with print CSS support
-- **Markdown to PDF**: GitHub Flavored Markdown with syntax highlighting
-- **Office Documents**: Convert 100+ formats via LibreOffice (DOC, DOCX, PPT, XLS, ODT, etc.)
-- **PDF Operations**: Merge, split, flatten, rotate, watermark
-- **PDF Metadata**: Read/write PDF metadata
-- **Gotenberg Compatibility**: Drop-in API replacement
-- **Health Checks**: `/health` endpoint with engine status
-- **Concurrent Rendering**: Thread-safe browser instance sharing
-- **Screenshots**: URL/HTML/Markdown to PNG/JPEG/WebP
-- **BDD Testing**: Port Gotenberg's Gherkin scenarios to Rust
-- **Webhook System**: Async job dispatch with retry, full engine integration (spec 15)
-- **Structured Logging**: Context-aware logs with request_id, engine type, duration (text/JSON formats)
-- **Prometheus Metrics**: `/prometheus/metrics` endpoint with conversion, queue, and engine metrics
-
-### 🚧 In Progress / Partially Done
-
-- **Advanced Wait Conditions**: `skipNetworkIdleEvent`, `failOnResourceLoadingFailed`, etc. (spec 36)
-- **Advanced LibreOffice Fields**: 30+ missing export options (spec 37)
-- **Full CLI Flag Parity**: Many Gotenberg flags still missing (spec 39)
-- **Actionable Errors**: Structured error responses, room for enhancement (spec 44)
-- **BDD Test Suite**: Framework exists, scenario coverage incomplete (spec 50)
-- **Batch API**: CLI batch works; server-side bulk endpoint pending (spec 50-batch)
-- **Health Dashboard**: JSON `/health` works; visual HTML dashboard pending (spec 51)
-
-### ❌ Not Started (Spec-Only)
-
-- **Python / Node.js Bindings**: Empty placeholders only (specs 40, 41)
-- **Multi-Backend PDF Engines**: qpdf, pdfcpu, pdftk backends (spec 38)
-- **Special Features**: TLS, auth, cloud-run, remote URL download (spec 40-special)
-- **Smart PDF Optimiser**: Automatic bloat detection & compression (spec 42)
-- **Font Doctor**: Font rendering diagnostics (spec 43)
-- **Live Preview**: HTML→image debug preview (spec 45)
-- **PDF Size Estimator**: Pre-flight size prediction (spec 46)
-- **One-Command Install**: `curl | bash` installer (spec 47)
-- **Interactive Docs**: Built-in `/docs` API explorer (spec 48)
-- **Template Library**: Pre-built document templates (spec 49)
-
-> **Note:** This README is a high-level overview. For a ground-truth audit of what is actually built vs. spec claims, see [`docs/implementation-status.md`](./docs/implementation-status.md). The `20-missing-features-roadmap.md` spec is currently stale and should not be relied upon for current status.
+**TLS is intentionally not handled in-process.** Put nginx, Caddy, or
+envoy in front. Cert rotation, OCSP stapling, and ALPN are not things
+Folio is positioned to do better than they do.
---
-## Documentation
-
-### Core Documentation
-
-| Document | Description |
-|----------|-------------|
-| [Technical Specification](./docs/proposal.md) | Full architecture and design |
-| [Gotenberg API Spec](./docs/gotenberg-spec.md) | API compatibility details |
-| [Gap Analysis](./docs/gap-analysis.md) | Research findings |
-
-### Specs (Implementation Guides)
-
-| Spec | Description | Status |
-|------|-------------|--------|
-| [00-overview](./docs/specs/00-overview.md) | Spec system overview & conventions | 📋 Reference |
-| [10-engine-types](./docs/specs/10-engine-types.md) | Core types, errors, options | ✅ Done |
-| [11-engine-chromium](./docs/specs/11-engine-chromium.md) | Chromium engine (HTML/URL/Markdown→PDF + screenshots) | ✅ Done |
-| [12-engine-libreoffice](./docs/specs/12-engine-libreoffice.md) | LibreOffice engine (Office→PDF) | ✅ Done |
-| [13-engine-pdfops](./docs/specs/13-engine-pdfops.md) | PDF operations (merge, split, flatten, metadata, watermark, rotate) | ✅ Done |
-| [14-engine-pdfa](./docs/specs/14-engine-pdfa.md) | PDF/A & PDF/UA conformance conversion | ✅ Done |
-| [15-webhook](./docs/specs/15-webhook.md) | Async webhook callback system | 🚧 Partially Done |
-| [16-bookmarks](./docs/specs/16-bookmarks.md) | PDF bookmarks/outline read & write | ✅ Done |
-| [17-watermark](./docs/specs/17-watermark.md) | PDF watermark & stamp overlay | ✅ Done *(via spec 13)* |
-| [18-screenshot](./docs/specs/18-screenshot.md) | Chromium screenshot API (PNG/JPEG/WebP) | ✅ Done *(via spec 11)* |
-| [19-encrypt](./docs/specs/19-encrypt.md) | PDF encryption & password protection | ✅ Done |
-| [20-cli](./docs/specs/20-cli.md) | Command-line interface (`folio` binary) | ✅ Done |
-| [20-bdd-testing](./docs/specs/20-bdd-testing.md) | BDD test strategy | 🚧 Partially Done |
-| [20-missing-features-roadmap](./docs/specs/20-missing-features-roadmap.md) | Feature parity roadmap vs Gotenberg | 📋 Reference |
-| [30-server](./docs/specs/30-server.md) | HTTP server (Gotenberg-compatible API) | ✅ Done |
-| [36-chromium-wait-conditions](./docs/specs/36-chromium-wait-conditions.md) | Advanced wait conditions & options | 🚧 Partially Done |
-| [37-libreoffice-advanced](./docs/specs/37-libreoffice-advanced.md) | Advanced LibreOffice form fields | 🚧 Partially Done |
-| [38-pdfengines-backends](./docs/specs/38-pdfengines-backends.md) | Multi-backend support (qpdf, pdfcpu, pdftk) | ❌ Not Done |
-| [39-config-flags](./docs/specs/39-config-flags.md) | Full Gotenberg CLI flag parity | 🚧 Partially Done |
-| [40-bindings-py](./docs/specs/40-bindings-py.md) | Python bindings (`py` crate) | ❌ Not Done *(placeholder)* |
-| [40-special-features](./docs/specs/40-special-features.md) | TLS, auth, cloud-run, remote URL download | ❌ Not Done |
-| [41-bindings-js](./docs/specs/41-bindings-js.md) | Node.js bindings (`js` crate) | ❌ Not Done *(placeholder)* |
-| [41-github-issues-analysis](./docs/specs/41-github-issues-analysis.md) | User pain-point research from GitHub issues | 📋 Research |
-| [42-smart-pdf-optimiser](./docs/specs/42-smart-pdf-optimiser.md) | Automatic PDF size optimisation | ❌ Not Done |
-| [43-font-doctor](./docs/specs/43-font-doctor.md) | Font rendering diagnostics & fixes | ❌ Not Done |
-| [44-crystal-clear-errors](./docs/specs/44-crystal-clear-errors.md) | Actionable error messages (replace generic 500s) | 🚧 Partially Done |
-| [45-live-preview-mode](./docs/specs/45-live-preview-mode.md) | Live HTML→image preview for debugging | ❌ Not Done |
-| [46-pdf-size-estimator](./docs/specs/46-pdf-size-estimator.md) | Pre-flight PDF size prediction | ❌ Not Done |
-| [47-one-command-install](./docs/specs/47-one-command-install.md) | Frictionless install (`curl | bash`) | ❌ Not Done |
-| [48-interactive-docs](./docs/specs/48-interactive-docs.md) | Built-in API explorer at `/docs` | ❌ Not Done |
-| [49-template-library](./docs/specs/49-template-library.md) | Pre-built document templates | ❌ Not Done |
-| [50-batch-api](./docs/specs/50-batch-api.md) | Bulk conversion API (100+ docs in one request) | 🚧 Partially Done *(CLI batch only)* |
-| [50-testing-bdd](./docs/specs/50-testing-bdd.md) | BDD integration test suite (Gherkin→Rust) | 🚧 Partially Done |
-| [51-health-dashboard](./docs/specs/51-health-dashboard.md) | Visual health dashboard beyond JSON `/health` | 🚧 Partially Done |
-
-**Legend:** `✅ Done` = fully implemented & tested. `🚧 Partially Done` = core working, gaps remain. `❌ Not Done` = spec only, no code. `📋 Reference` = meta-doc or research, no code expected.
-
-### API Reference
-
-- **Chromium Routes**: `/forms/chromium/*` (convert HTML/URL/Markdown, screenshots)
-- **LibreOffice Routes**: `/forms/libreoffice/*` (convert Office docs)
-- **PDF Engine Routes**: `/forms/pdfengines/*` (merge, split, flatten, etc.)
+## Docker variants
----
+Single `Dockerfile`, multiple `--target` stages — pick the smallest one
+that does what you need.
-## Project Structure
+| Target | Contains | Use case |
+|------------------------------|----------------------|----------------------|
+| `folio` | Chromium + LO | Default |
+| `folio-chromium` | Chromium | HTML/URL/Markdown only (~30% smaller) |
+| `folio-libreoffice` | LO | Office docs only (~40% smaller) |
+| `folio-cloudrun` | Full + Cloud Run env | Google Cloud Run |
+| `folio-lambda` | Full + Lambda Web Adapter | AWS Lambda |
+| `folio-{cloudrun,lambda}-{chromium,libreoffice}` | Slim + platform | Mix-and-match |
-```
-folio/
-├── Cargo.toml # Workspace definition
-├── README.md # This file
-├── Dockerfile # Single file, 9 named --target variants (see Docker section)
-├── Dockerfile.test # Test environment (poppler, JRE, verapdf)
-├── docker-compose.yml # Development environment
-├── Makefile # Build/test/docker automation
-├── .env.example # Configuration template
-│
-├── crates/
-│ ├── engine/ # Core PDF generation engine
-│ │ ├── src/
-│ │ │ ├── chromium/ # Chrome/Chromium integration
-│ │ │ │ ├── launch.rs # Browser discovery & launch
-│ │ │ │ ├── render.rs # HTML/URL → PDF
-│ │ │ │ └── screenshot.rs # Screenshots (✅)
-│ │ │ ├── libreoffice/ # LibreOffice integration
-│ │ │ └── pdfops/ # PDF manipulation
-│ │ └── Cargo.toml
-│ │
-│ ├── server/ # HTTP server (Gotenberg-compatible)
-│ │ ├── src/
-│ │ │ ├── routes/ # API route handlers
-│ │ │ └── app.rs # Router configuration
-│ │ └── tests/ # Integration tests
-│ │
-│ ├── cli/ # Command-line interface
-│ │ └── src/commands/ # CLI subcommands
-│ │
-│
-├── docs/
-│ ├── proposal.md # Technical specification
-│ ├── gotenberg-spec.md # Gotenberg API analysis
-│ ├── gap-analysis.md # Research findings
-│ ├── assets/ # Images, logos
-│ └── specs/ # Implementation specs (32 files, see table above)
-│
-└── crates/*/tests/ # Crate-local tests (unit + integration)
- └── server/tests/bdd/ # BDD integration tests
+```bash
+docker build --target folio-chromium -t folio:chromium .
+make docker-push-all DOCKER_REGISTRY=ghcr.io/me VERSION=1.0.0
```
---
-## Development
-
-### Building from Source
-
-```bash
-# Clone the repository
-git clone https://github.com/yourusername/folio.git
-cd folio
-
-# Build all crates
-cargo build --release
+## Where things stand
-# Run tests
-cargo test
+A short, honest scorecard. The full version is [`comparison.md`](./comparison.md).
-# Run with specific features
-cargo run -p server -- serve --help
-```
-
-### Docker Image Variants
+**Ready to use:**
+HTML/URL/Markdown→PDF · Office→PDF · screenshots · merge · split · flatten ·
+rotate · watermark · metadata · bookmarks · encrypt · PDF/A & PDF/UA ·
+Basic Auth · Prometheus · OpenTelemetry · operator console · CLI · Rust
+library · multi-target Docker.
-All variants are built from a single `Dockerfile` using named `--target` stages, following Gotenberg's pattern. Each platform-specific variant (Cloud Run, Lambda) is a thin layer on top of the base variant — just environment variables.
+**In progress:**
+Webhook callback delivery (scaffold ready, delivery TODO) ·
+batch API ZIP/merge output (endpoints + worker exist) ·
+advanced Chromium wait/fail conditions (`waitForSelector`, `failOn*`) ·
+long tail of LibreOffice export filters · `embed` and full `stamp` routes.
-| Target | Tag | Description |
-|--------|-----|-------------|
-| `folio` | `latest`, `vX.Y.Z` | Full: Chromium + LibreOffice |
-| `folio-chromium` | `latest-chromium` | Chromium only (~30% smaller) |
-| `folio-libreoffice` | `latest-libreoffice` | LibreOffice only (~40% smaller) |
-| `folio-cloudrun` | `latest-cloudrun` | Full + Google Cloud Run env vars |
-| `folio-cloudrun-chromium` | `latest-chromium-cloudrun` | Chromium + Cloud Run |
-| `folio-cloudrun-libreoffice` | `latest-libreoffice-cloudrun` | LibreOffice + Cloud Run |
-| `folio-lambda` | `latest-lambda` | Full + [Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter) |
-| `folio-lambda-chromium` | `latest-chromium-lambda` | Chromium + Lambda |
-| `folio-lambda-libreoffice` | `latest-libreoffice-lambda` | LibreOffice + Lambda |
+**Deliberate gaps:**
+TLS in-process (use a reverse proxy) · OAuth/JWT/RBAC (use a reverse
+proxy) · workflow/DAG engine on top of batch (out of scope).
-```bash
-# Build a specific variant
-docker build --target folio-chromium -t myrepo/folio:chromium .
+**Empty placeholders (will be removed if not built):**
+Python bindings (`crates/py/`), Node bindings (`crates/js/`).
-# Build + push all 9 variants
-make docker-push-all DOCKER_REGISTRY=myrepo/folio VERSION=1.0.0
+---
-# Run with Docker Compose (default: full image)
-docker compose up folio
+## Documentation
-# Run Chromium-only profile
-docker compose --profile chromium up folio-chromium
-```
+- [`comparison.md`](./comparison.md) — in-depth audit vs Gotenberg
+- [`docs/markdown-plus.md`](./docs/markdown-plus.md) — proposed
+ enhanced Markdown route (front-matter, math, mermaid, themes)
-### Development Commands
-
-| Command | Description |
-|---------|-------------|
-| `make docker-build` | Build full Docker image |
-| `make docker-build-all` | Build all 9 variants |
-| `make docker-push-all` | Build and push all variants |
-| `make run` | Start Folio via Docker Compose |
-| `make test-unit` | Run unit tests |
-| `make test-integration` | Run integration tests (requires Chrome) |
-| `make fmt` | Format code |
-| `make lint` | Lint with Clippy |
-| `make check` | Run format + lint + unit tests |
-| `make clean` | Clean build artifacts |
-
-### Environment Variables
-
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `CHROME_PATH` | Path to Chrome/Chromium executable | Auto-detected |
-| `LIBREOFFICE_PATH` | Path to LibreOffice (soffice) | Auto-detected |
-| `RUST_LOG` | Log level (trace, debug, info, warn, error) | `info` |
-| `FOLIO_PORT` | Server port | `3000` |
-| `FOLIO_CONCURRENCY` | Max concurrent renders | CPU count |
-| `FOLIO_OTEL_ENABLED` | Enable OpenTelemetry trace export | `false` |
-| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP HTTP trace endpoint | `http://localhost:4318/v1/traces` |
+> **Note on specs.** The previous 32-file `docs/specs/` tree has been
+> archived to [`docs/specs-archive-2026-05-01.zip`](./docs/specs-archive-2026-05-01.zip).
+> Fresh, better-organised contributor-facing specs are being written and
+> will reappear under `docs/` shortly.
---
-## Testing
-
-### Test Structure
-
-```
-tests/
-├── unit/ # Unit tests (cargo test --lib)
-├── integration/ # BDD integration tests (🚧)
-│ ├── scenarios/ # Test scenarios (ported from Gotenberg)
-│ ├── common/ # Test helpers
-│ └── testdata/ # Test fixtures
-└── e2e/ # End-to-end tests
-```
-
-### Running Tests
+## Development
```bash
-# Unit tests (no Chrome required)
-cargo test --lib
+git clone https://github.com/__deesh_reddy__/folio.git && cd folio
-# Integration tests (skip gracefully if deps missing)
-cargo test -p server --test bdd
-
-# E2E tests (skip gracefully if deps missing)
-cargo test -p server --test e2e
-
-# All tests (skip gracefully if deps missing)
-cargo test -- --test-threads=1
-
-# All tests with Docker
-make docker-test
+cargo build --release # build everything
+cargo test # unit + integration (skips gracefully if Chrome missing)
+make check # fmt + clippy + unit tests (run before PRs)
+make run # docker-compose up, full image
+make test-integration # BDD scenarios in Docker
```
-### Test Coverage
+| Command | What it does |
+|-------------------------|---------------------------------------|
+| `make docker-build` | Build full image |
+| `make docker-build-all` | Build all 9 image variants |
+| `make test-unit` | `cargo test --lib` |
+| `make test-integration` | BDD + e2e in container |
+| `make fmt` / `make lint`| `cargo fmt` / `cargo clippy` |
-We're porting Gotenberg's comprehensive BDD test suite:
-
-- ✅ Unit tests: 50+ test cases
-- 🚧 Integration tests: BDD framework with 25+ feature files (scenario pass rate unverified)
-- ✅ E2E tests: Server + CLI smoke tests
-
-See [BDD Testing Spec](./docs/specs/50-testing-bdd.md) for details.
-
----
-
-## Roadmap
-
-### Phase 1: Core Features ✅
-- [x] HTML/URL/Markdown → PDF (Chromium) — spec 11
-- [x] Office documents → PDF (LibreOffice) — spec 12
-- [x] PDF operations (merge, split, flatten, rotate, watermark) — spec 13
-- [x] PDF metadata read/write — spec 13
-- [x] Gotenberg-compatible API — spec 30
-- [x] Screenshots (HTML/URL/Markdown → PNG/JPEG/WebP) — spec 11 / 18
-- [x] Structured Logging (tracing with text/JSON formats)
-- [x] Prometheus Metrics (`/prometheus/metrics` endpoint)
-- [x] OpenTelemetry Traces (OTLP HTTP exporter)
-- [x] CLI (`folio` binary) — spec 20
-
-### Phase 2: Advanced Engine Features 🚧
-- [x] PDF/A & PDF/UA conformance conversion — spec 14
-- [x] PDF bookmarks read/write — spec 16
-- [x] PDF encryption & password protection — spec 19
-- [ ] Advanced Chromium wait conditions — spec 36
-- [ ] Advanced LibreOffice form fields — spec 37
-- [ ] Multi-backend PDF engines (qpdf, pdfcpu, pdftk) — spec 38
-
-### Phase 3: Server & Infrastructure 🚧
-- [ ] Webhook system with retry — spec 15
-- [ ] Full CLI flag parity with Gotenberg — spec 39
-- [ ] Batch API (server-side bulk conversion) — spec 50-batch
-- [ ] Actionable error messages — spec 44
-- [ ] Visual health dashboard — spec 51
-
-### Phase 4: Bindings & Ecosystem ❌
-- [ ] Python bindings (`py` crate) — spec 40
-- [ ] Node.js bindings (`js` crate) — spec 41
-- [ ] TLS, auth, cloud-run, remote URL download — spec 40-special
-
-### Phase 5: Unique Folio Features ❌
-- [ ] Smart PDF optimiser — spec 42
-- [ ] Font doctor / diagnostics — spec 43
-- [ ] Live preview mode — spec 45
-- [ ] PDF size estimator — spec 46
-- [ ] One-command install (`curl | bash`) — spec 47
-- [ ] Interactive API docs (`/docs`) — spec 48
-- [ ] Template library — spec 49
-
-See [Full Roadmap](./docs/specs/20-missing-features-roadmap.md) and detailed specs in [docs/specs/](./docs/specs/) for planning.
+**Useful env vars:** `CHROME_PATH`, `LIBREOFFICE_PATH`, `RUST_LOG`,
+`FOLIO_PORT`, `FOLIO_CONCURRENCY`, `OTEL_EXPORTER_OTLP_ENDPOINT`.
---
## Contributing
-Contributions are welcome! Please read our [contributing guidelines](./CONTRIBUTING.md) before submitting a PR.
-
-### Quick Contribution Guide
+PRs welcome. Three things that make a PR easy to land:
-1. Fork the repository
-2. Create a feature branch (`git checkout -b feature/amazing-feature`)
-3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
-4. Push to the branch (`git push origin feature/amazing-feature`)
-5. Open a Pull Request
+1. `make check` passes locally.
+2. Conventional Commits style (`feat:`, `fix:`, `docs:`, `chore:`).
+3. One feature or fix per PR — split mixed work.
-### Development Workflow
-
-- Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages
-- Ensure `make check` passes before submitting PR
-- Add tests for new functionality
-- Update documentation as needed
-- Keep PRs focused on a single feature/fix
+For larger changes, open an issue first so we can agree on the shape
+before code.
---
-## Acknowledgments
-
-- **[Gotenberg](https://github.com/gotenberg/gotenberg)** - The original PDF generation API that inspired this project
-- **[chromiumoxide](https://github.com/mattsse/chromiumoxide)** - Chrome DevTools Protocol client for Rust
-- **[lopdf](https://github.com/Hopding/lopdf)** - Pure Rust PDF manipulation library
-- **[Axum](https://github.com/tokio-rs/axum)** - Ergonomic HTTP server framework
+## Acknowledgements
----
+- [Gotenberg](https://github.com/gotenberg/gotenberg) — the API contract Folio implements
+- [chromiumoxide](https://github.com/mattsse/chromiumoxide) — Chrome DevTools Protocol client
+- [lopdf](https://github.com/J-F-Liu/lopdf) — pure-Rust PDF manipulation
+- [axum](https://github.com/tokio-rs/axum) — HTTP server
## License
-Folio is licensed under the MIT License - see [LICENSE](LICENSE) for details.
-
----
-
-
- Built with ❤️ in Rust 🦀
- Folio: A new page in PDF generation.
-
+MIT. See [LICENSE](./LICENSE).
diff --git a/bench/results/20260501T101529Z/perf.md b/bench/results/20260501T101529Z/perf.md
new file mode 100644
index 0000000..4e4b6da
--- /dev/null
+++ b/bench/results/20260501T101529Z/perf.md
@@ -0,0 +1,43 @@
+# Folio vs Gotenberg — Performance Report
+
+Generated: 2026-05-01T10:39:49Z
+
+## Latency (ms)
+
+| Workload | Server | p50 | p95 | p99 | RPS | Errors |
+|----------|--------|-----|-----|-----|-----|--------|
+| html-small | folio | 880 | 1418 | 1992 | 4.2 | 0.0% |
+| html-small | gotenberg | 298 | 493 | 792 | 12.4 | 0.0% |
+| html-large | folio | 3073 | 13903 | 17935 | 0.9 | 0.0% |
+| html-large | gotenberg | 376 | 663 | 935 | 9.7 | 0.0% |
+| libreoffice-docx | folio | 19711 | 25791 | 25791 | 0.2 | 0.0% |
+| libreoffice-docx | gotenberg | 665 | 1354 | 1560 | 5.4 | 0.0% |
+| pdfengines-merge | folio | 25 | 38 | 52 | 152.6 | 0.0% |
+| pdfengines-merge | gotenberg | 17 | 59 | 99 | 165.2 | 0.0% |
+
+## Peak RSS (MiB)
+
+| Workload | Folio | Gotenberg |
+|----------|-------|-----------|
+| html-small | N/A | 785 |
+| html-large | N/A | 762 |
+| libreoffice-docx | N/A | 837 |
+| pdfengines-merge | N/A | 828 |
+
+## Stability Warnings (CV > 15%)
+
+- folio/html-small: CV=26.7% (unstable)
+- gotenberg/html-small: CV=33.9% (unstable)
+- folio/html-large: CV=84.2% (unstable)
+- gotenberg/html-large: CV=33.1% (unstable)
+- folio/libreoffice-docx: CV=28.8% (unstable)
+- gotenberg/libreoffice-docx: CV=40.6% (unstable)
+- folio/pdfengines-merge: CV=29.2% (unstable)
+- gotenberg/pdfengines-merge: CV=82.9% (unstable)
+
+## Caveats
+
+- Results are hardware-specific and not portable across machines.
+- Both servers ran under `cpus: "2"` / `mem_limit: 2g` Docker cgroups.
+- Chrome PDF rendering is non-deterministic; latency varies across runs.
+- 60-second warm-up discarded before measurements.
diff --git a/bench/results/20260501T123054Z/perf.md b/bench/results/20260501T123054Z/perf.md
new file mode 100644
index 0000000..52d640d
--- /dev/null
+++ b/bench/results/20260501T123054Z/perf.md
@@ -0,0 +1,45 @@
+# Folio vs Gotenberg — Performance Report
+
+Generated: 2026-05-01T13:07:01Z
+
+## Latency (ms)
+
+| Workload | Server | p50 | p95 | p99 | RPS | Errors |
+|----------|--------|-----|-----|-----|-----|--------|
+| html-small | folio | 186 | 210 | 256 | 21.3 | 0.0% |
+| html-small | gotenberg | 283 | 398 | 517 | 14.3 | 0.0% |
+| html-large | folio | 223 | 303 | 385 | 16.9 | 0.0% |
+| html-large | gotenberg | 299 | 494 | 686 | 12.4 | 0.0% |
+| url-local | folio | 205 | 261 | 298 | 18.6 | 0.0% |
+| url-local | gotenberg | 289 | 396 | 591 | 13.7 | 0.0% |
+| libreoffice-docx | folio | 1256 | 1471 | 1710 | 3.1 | 0.0% |
+| libreoffice-docx | gotenberg | 528 | 859 | 1030 | 6.7 | 0.0% |
+| pdfengines-merge | folio | 11 | 22 | 37 | 316.5 | 0.0% |
+| pdfengines-merge | gotenberg | 25 | 47 | 61 | 139.1 | 0.0% |
+
+## Peak RSS (MiB)
+
+| Workload | Folio | Gotenberg |
+|----------|-------|-----------|
+| html-small | 488 | 312 |
+| html-large | 542 | 324 |
+| url-local | 573 | 331 |
+| libreoffice-docx | 697 | 317 |
+| pdfengines-merge | 550 | 298 |
+
+## Stability Warnings (CV > 15%)
+
+- gotenberg/html-small: CV=23.3% (unstable)
+- folio/html-large: CV=15.9% (unstable)
+- gotenberg/html-large: CV=29.0% (unstable)
+- gotenberg/url-local: CV=23.6% (unstable)
+- gotenberg/libreoffice-docx: CV=27.1% (unstable)
+- folio/pdfengines-merge: CV=59.2% (unstable)
+- gotenberg/pdfengines-merge: CV=37.1% (unstable)
+
+## Caveats
+
+- Results are hardware-specific and not portable across machines.
+- Both servers ran under `cpus: "2"` / `mem_limit: 2g` Docker cgroups.
+- Chrome PDF rendering is non-deterministic; latency varies across runs.
+- 60-second warm-up discarded before measurements.
diff --git a/bench/results/20260501T184521Z/perf.md b/bench/results/20260501T184521Z/perf.md
new file mode 100644
index 0000000..791aebd
--- /dev/null
+++ b/bench/results/20260501T184521Z/perf.md
@@ -0,0 +1,44 @@
+# Folio vs Gotenberg — Performance Report
+
+Generated: 2026-05-01T19:55:31Z
+
+## Latency (ms)
+
+| Workload | Server | p50 | p95 | p99 | RPS | Errors |
+|----------|--------|-----|-----|-----|-----|--------|
+| html-small | folio | 195 | 230 | 258 | 20.2 | 0.0% |
+| html-small | gotenberg | 297 | 402 | 526 | 13.2 | 0.0% |
+| html-large | folio | 214 | 265 | 293 | 18.0 | 0.0% |
+| html-large | gotenberg | 303 | 408 | 576 | 12.6 | 0.0% |
+| url-local | folio | 210 | 257 | 291 | 18.4 | 0.0% |
+| url-local | gotenberg | 302 | 404 | 590 | 12.7 | 0.0% |
+| libreoffice-docx | folio | 254 | 282 | 326 | 15.5 | 0.0% |
+| libreoffice-docx | gotenberg | 406 | 629 | 658 | 8.3 | 0.0% |
+| pdfengines-merge | folio | 9 | 13 | 19 | 412.1 | 0.0% |
+| pdfengines-merge | gotenberg | 13 | 25 | 34 | 259.7 | 0.0% |
+
+## Peak RSS (MiB)
+
+| Workload | Folio | Gotenberg |
+|----------|-------|-----------|
+| html-small | 415 | 436 |
+| html-large | 530 | 454 |
+| url-local | 641 | 472 |
+| libreoffice-docx | 1538 | 471 |
+| pdfengines-merge | 2041 | 446 |
+
+## Stability Warnings (CV > 15%)
+
+- gotenberg/html-small: CV=20.1% (unstable)
+- gotenberg/html-large: CV=20.4% (unstable)
+- gotenberg/url-local: CV=20.6% (unstable)
+- gotenberg/libreoffice-docx: CV=22.2% (unstable)
+- folio/pdfengines-merge: CV=30.3% (unstable)
+- gotenberg/pdfengines-merge: CV=32.1% (unstable)
+
+## Caveats
+
+- Results are hardware-specific and not portable across machines.
+- Both servers ran under `cpus: "2"` / `mem_limit: 2g` Docker cgroups.
+- Chrome PDF rendering is non-deterministic; latency varies across runs.
+- 60-second warm-up discarded before measurements.
diff --git a/bench/src/perf.rs b/bench/src/perf.rs
index d4ec8e7..46a4a73 100644
--- a/bench/src/perf.rs
+++ b/bench/src/perf.rs
@@ -28,6 +28,9 @@ pub struct PerfArgs {
pub skip_preflight: bool,
#[arg(long)]
pub output_dir: Option,
+ /// Comma-separated workload names to skip (e.g. --skip url-local).
+ #[arg(long, value_delimiter = ',')]
+ pub skip: Vec,
}
pub async fn run_perf(args: PerfArgs) -> anyhow::Result<()> {
@@ -44,6 +47,10 @@ pub async fn run_perf(args: PerfArgs) -> anyhow::Result<()> {
let mut all_results = Vec::new();
for w in &workloads {
+ if args.skip.iter().any(|s| s == w.name) {
+ println!("\n=== {} — skipped ===", w.name);
+ continue;
+ }
println!("\n=== {} — {} ===", w.name, w.description);
let folio_result = run_workload(
@@ -133,11 +140,13 @@ async fn quality_check(w: &workload::WorkloadDef, url: &str) -> anyhow::Result<(
for path in &w.fixtures {
let bytes = tokio::fs::read(path).await
.map_err(|e| anyhow::anyhow!("failed to read fixture {:?}: {e}", path))?;
- let filename = path.file_name().unwrap().to_string_lossy().to_string();
+ let filename = w.fixture_filename
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| path.file_name().unwrap().to_string_lossy().to_string());
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(filename.clone())
.mime_str("application/octet-stream")?;
- form = form.part(filename, part);
+ form = form.part(w.fixture_field, part);
}
for (k, v) in &w.extra_fields {
form = form.text(k.to_string(), v.to_string());
@@ -164,6 +173,8 @@ async fn drive_once(
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
+ let fixture_field = w.fixture_field;
+ let fixture_filename = w.fixture_filename;
let body_fn = Arc::new(move || {
let url = url.clone();
let fixtures = fixtures.clone();
@@ -172,11 +183,13 @@ async fn drive_once(
let mut form = reqwest::multipart::Form::new();
for path in &fixtures {
let bytes = tokio::fs::read(path).await?;
- let filename = path.file_name().unwrap().to_string_lossy().to_string();
+ let filename = fixture_filename
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| path.file_name().unwrap().to_string_lossy().to_string());
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(filename.clone())
.mime_str("application/octet-stream")?;
- form = form.part(filename, part);
+ form = form.part(fixture_field, part);
}
for (k, v) in &extra_fields {
form = form.text(k.clone(), v.clone());
diff --git a/bench/src/workload.rs b/bench/src/workload.rs
index e13a676..5b1952f 100644
--- a/bench/src/workload.rs
+++ b/bench/src/workload.rs
@@ -6,6 +6,10 @@ pub struct WorkloadDef {
pub folio_route: &'static str,
pub gotenberg_route: &'static str,
pub fixtures: Vec,
+ /// Override the multipart field name for all fixtures (Folio/Gotenberg use "files").
+ pub fixture_field: &'static str,
+ /// Override the multipart filename for all fixtures (e.g. HTML endpoints require "index.html").
+ pub fixture_filename: Option<&'static str>,
pub extra_fields: Vec<(&'static str, &'static str)>,
pub expected_pages: Option,
}
@@ -20,6 +24,8 @@ pub fn all_workloads() -> Vec {
folio_route: "/forms/chromium/convert/html",
gotenberg_route: "/forms/chromium/convert/html",
fixtures: vec![fixtures_dir.join("html_small.html")],
+ fixture_field: "files",
+ fixture_filename: Some("index.html"),
extra_fields: vec![],
expected_pages: None,
},
@@ -29,6 +35,8 @@ pub fn all_workloads() -> Vec {
folio_route: "/forms/chromium/convert/html",
gotenberg_route: "/forms/chromium/convert/html",
fixtures: vec![fixtures_dir.join("html_large.html")],
+ fixture_field: "files",
+ fixture_filename: Some("index.html"),
extra_fields: vec![],
expected_pages: None,
},
@@ -38,6 +46,8 @@ pub fn all_workloads() -> Vec {
folio_route: "/forms/chromium/convert/url",
gotenberg_route: "/forms/chromium/convert/url",
fixtures: vec![],
+ fixture_field: "files",
+ fixture_filename: None,
extra_fields: vec![("url", "http://host.docker.internal:18080/bench.html")],
expected_pages: None,
},
@@ -47,6 +57,8 @@ pub fn all_workloads() -> Vec {
folio_route: "/forms/libreoffice/convert",
gotenberg_route: "/forms/libreoffice/convert",
fixtures: vec![fixtures_dir.join("sample.docx")],
+ fixture_field: "files",
+ fixture_filename: None,
extra_fields: vec![],
expected_pages: None,
},
@@ -62,6 +74,8 @@ pub fn all_workloads() -> Vec {
fixtures_dir.join("page_4.pdf"),
fixtures_dir.join("page_5.pdf"),
],
+ fixture_field: "files",
+ fixture_filename: None,
extra_fields: vec![],
expected_pages: None,
},
diff --git a/bindings/CHROME_VERSION b/bindings/CHROME_VERSION
new file mode 100644
index 0000000..ba102db
--- /dev/null
+++ b/bindings/CHROME_VERSION
@@ -0,0 +1 @@
+131.0.6778.204
\ No newline at end of file
diff --git a/bindings/README.md b/bindings/README.md
new file mode 100644
index 0000000..b8d5e0d
--- /dev/null
+++ b/bindings/README.md
@@ -0,0 +1,15 @@
+# Folio bindings
+
+This directory ships Folio as embeddable libraries.
+
+- `bindings/python/` — maturin project producing the `folio` PyPI package.
+- `bindings/node/` — napi-rs project producing the `@folio/folio` npm package.
+- `bindings/fixtures/` — shared HTML/Office fixtures used by tests.
+- `CHROME_VERSION` — pinned Chrome-for-Testing version. Bumped per release.
+
+The Rust glue lives in `crates/py` and `crates/js`. The Folio engine
+itself is unchanged; bindings reuse `crates/engine` plus the new
+`engine::chrome_fetch` module.
+
+See `docs/superpowers/specs/2026-05-01-bindings-design.md` for the full
+design (v1 + v2).
diff --git a/bindings/fixtures/hello.html b/bindings/fixtures/hello.html
new file mode 100644
index 0000000..66c352f
--- /dev/null
+++ b/bindings/fixtures/hello.html
@@ -0,0 +1 @@
+folio e2e
diff --git a/bindings/node/.gitignore b/bindings/node/.gitignore
new file mode 100644
index 0000000..4505758
--- /dev/null
+++ b/bindings/node/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+*.node
+dist/
+*.log
+_native.js
+_native.d.ts
diff --git a/bindings/node/README.md b/bindings/node/README.md
new file mode 100644
index 0000000..7a10d64
--- /dev/null
+++ b/bindings/node/README.md
@@ -0,0 +1,14 @@
+# @folio/folio
+
+Rust-native PDF conversion, embeddable in Node. See spec at
+`docs/superpowers/specs/2026-05-01-bindings-design.md`.
+
+ npm install @folio/folio
+
+ import { Folio } from '@folio/folio';
+ const f = await Folio.create();
+ try {
+ const pdf = await f.htmlToPdf('hi
');
+ } finally {
+ await f.close();
+ }
diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts
new file mode 100644
index 0000000..c978e07
--- /dev/null
+++ b/bindings/node/index.d.ts
@@ -0,0 +1,31 @@
+/* tslint:disable */
+/* eslint-disable */
+
+/* auto-generated by NAPI-RS */
+
+/** Options passed to [`Folio::create`]. */
+export interface CreateOptions {
+ /** Which engines to enable. Defaults to `["chromium", "office"]`. */
+ engines?: Array
+ /** Explicit path to a Chrome/Chromium executable. */
+ chromePath?: string
+ /** Automatically download Chrome if no system Chrome is found. */
+ autoDownloadChrome?: boolean
+ /** Directory used to cache downloaded Chrome binaries. */
+ chromeCacheDir?: string
+}
+/** Async Folio client that wraps the PDF/document engines. */
+export declare class Folio {
+ /** Create a new Folio instance, launching the requested engines. */
+ static create(opts?: CreateOptions | undefined | null): Promise
+ /** Convert an HTML string to a PDF buffer. */
+ htmlToPdf(html: string, options?: Json | undefined | null): Promise
+ /** Convert a URL to a PDF buffer. */
+ urlToPdf(url: string, options?: Json | undefined | null): Promise
+ /** Convert a Markdown string to a PDF buffer. */
+ markdownToPdf(md: string, options?: Json | undefined | null): Promise
+ /** Convert an office document at `path` to a PDF buffer. */
+ officeToPdf(path: string, options?: Json | undefined | null): Promise
+ /** Shut down the Folio instance and release resources. */
+ close(): Promise
+}
diff --git a/bindings/node/index.js b/bindings/node/index.js
new file mode 100644
index 0000000..ac7a4b5
--- /dev/null
+++ b/bindings/node/index.js
@@ -0,0 +1,60 @@
+'use strict';
+
+const { Folio: NativeFolio } = require('./_native.js');
+
+class FolioError extends Error { constructor(m){ super(m); this.name='FolioError'; } }
+class ChromeNotFoundError extends FolioError { constructor(m){ super(m); this.name='ChromeNotFoundError'; } }
+class ChromeFetchError extends FolioError { constructor(m){ super(m); this.name='ChromeFetchError'; } }
+class ChromiumError extends FolioError { constructor(m){ super(m); this.name='ChromiumError'; } }
+class OfficeError extends FolioError { constructor(m){ super(m); this.name='OfficeError'; } }
+class EngineDisabledError extends FolioError { constructor(m){ super(m); this.name='EngineDisabledError'; } }
+class TimeoutError extends FolioError { constructor(m){ super(m); this.name='TimeoutError'; } }
+class ValidationError extends FolioError { constructor(m){ super(m); this.name='ValidationError'; } }
+
+const tagMap = {
+ ChromeNotFound: ChromeNotFoundError,
+ ChromeFetch: ChromeFetchError,
+ Chromium: ChromiumError,
+ Office: OfficeError,
+ EngineDisabled: EngineDisabledError,
+ Timeout: TimeoutError,
+ Validation: ValidationError,
+};
+
+function decorate(err) {
+ if (!(err instanceof Error)) return err;
+ const m = err.message || '';
+ const match = m.match(/^\[(\w+)\]\s*(.*)$/);
+ if (!match) return err;
+ const Cls = tagMap[match[1]];
+ if (!Cls) return err;
+ const decorated = new Cls(match[2]);
+ decorated.cause = err;
+ return decorated;
+}
+
+function wrapMethod(fn) {
+ return async function(...args) {
+ try { return await fn.apply(this, args); }
+ catch (e) { throw decorate(e); }
+ };
+}
+
+class Folio {
+ constructor(inner) { this._inner = inner; }
+ static async create(opts) {
+ try {
+ const inner = await NativeFolio.create(opts);
+ return new Folio(inner);
+ } catch (e) { throw decorate(e); }
+ }
+}
+for (const m of ['htmlToPdf', 'urlToPdf', 'markdownToPdf', 'officeToPdf', 'close']) {
+ Folio.prototype[m] = wrapMethod(function(...args) { return this._inner[m](...args); });
+}
+
+module.exports = {
+ Folio,
+ FolioError, ChromeNotFoundError, ChromeFetchError, ChromiumError,
+ OfficeError, EngineDisabledError, TimeoutError, ValidationError,
+};
diff --git a/bindings/node/package-lock.json b/bindings/node/package-lock.json
new file mode 100644
index 0000000..3e25ac0
--- /dev/null
+++ b/bindings/node/package-lock.json
@@ -0,0 +1,1890 @@
+{
+ "name": "@folio/folio",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@folio/folio",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@napi-rs/cli": "^2.18.0",
+ "@types/node": "^20.0.0",
+ "vitest": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/cli": {
+ "version": "2.18.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz",
+ "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "napi": "scripts/index.js"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.10",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
+ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.39",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
+ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
+ "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "chai": "^4.3.10"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz",
+ "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "1.6.1",
+ "p-limit": "^5.0.0",
+ "pathe": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz",
+ "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz",
+ "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^2.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz",
+ "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "diff-sequences": "^29.6.3",
+ "estree-walker": "^3.0.3",
+ "loupe": "^2.3.7",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
+ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.3",
+ "deep-eql": "^4.1.3",
+ "get-func-name": "^2.0.2",
+ "loupe": "^2.3.6",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+ "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.2"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+ "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/execa": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+ "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^8.0.1",
+ "human-signals": "^5.0.0",
+ "is-stream": "^3.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^5.1.0",
+ "onetime": "^6.0.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-func-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/local-pkg": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
+ "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.3",
+ "pkg-types": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+ "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.1"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mimic-fn": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+ "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/mlly/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
+ "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-run-path/node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+ "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
+ "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/pkg-types/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.13",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
+ "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-final-newline": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+ "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz",
+ "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinypool": {
+ "version": "0.8.4",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
+ "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
+ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
+ "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz",
+ "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.4",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
+ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "1.6.1",
+ "@vitest/runner": "1.6.1",
+ "@vitest/snapshot": "1.6.1",
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "acorn-walk": "^8.3.2",
+ "chai": "^4.3.10",
+ "debug": "^4.3.4",
+ "execa": "^8.0.1",
+ "local-pkg": "^0.5.0",
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "std-env": "^3.5.0",
+ "strip-literal": "^2.0.0",
+ "tinybench": "^2.5.1",
+ "tinypool": "^0.8.3",
+ "vite": "^5.0.0",
+ "vite-node": "1.6.1",
+ "why-is-node-running": "^2.2.2"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "1.6.1",
+ "@vitest/ui": "1.6.1",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/bindings/node/package.json b/bindings/node/package.json
new file mode 100644
index 0000000..9df96b4
--- /dev/null
+++ b/bindings/node/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@folio/folio",
+ "version": "0.1.0",
+ "description": "Folio: Rust-native PDF conversion, embeddable in Node.",
+ "main": "index.js",
+ "types": "index.d.ts",
+ "license": "MIT",
+ "engines": { "node": ">= 18" },
+ "napi": {
+ "name": "folio-node",
+ "triples": {
+ "defaults": true,
+ "additional": ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
+ }
+ },
+ "scripts": {
+ "build": "napi build --platform --release --cargo-cwd ../../crates/js --cargo-name folio_js --js _native.js --dts _native.d.ts",
+ "build:debug": "napi build --platform --cargo-cwd ../../crates/js --cargo-name folio_js --js _native.js --dts _native.d.ts",
+ "test": "vitest run"
+ },
+ "devDependencies": {
+ "@napi-rs/cli": "^2.18.0",
+ "@types/node": "^20.0.0",
+ "vitest": "^1.6.0"
+ }
+}
diff --git a/bindings/node/tests/e2e.test.mjs b/bindings/node/tests/e2e.test.mjs
new file mode 100644
index 0000000..2bcd466
--- /dev/null
+++ b/bindings/node/tests/e2e.test.mjs
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest';
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+import { Folio } from '../index.js';
+
+const E2E = process.env.FOLIO_E2E === '1';
+const here = dirname(fileURLToPath(import.meta.url));
+const fixture = resolve(here, '../../fixtures/hello.html');
+
+describe.skipIf(!E2E)('e2e', () => {
+ it('htmlToPdf', async () => {
+ const f = await Folio.create({ engines: ['chromium'] });
+ try {
+ const html = await readFile(fixture, 'utf8');
+ const pdf = await f.htmlToPdf(html);
+ expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
+ } finally { await f.close(); }
+ }, 120_000);
+
+ it('urlToPdf', async () => {
+ const f = await Folio.create({ engines: ['chromium'] });
+ try {
+ const pdf = await f.urlToPdf('about:blank');
+ expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
+ } finally { await f.close(); }
+ }, 120_000);
+
+ it('markdownToPdf', async () => {
+ const f = await Folio.create({ engines: ['chromium'] });
+ try {
+ const pdf = await f.markdownToPdf('# hello');
+ expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
+ } finally { await f.close(); }
+ }, 120_000);
+});
diff --git a/bindings/node/tests/smoke.test.mjs b/bindings/node/tests/smoke.test.mjs
new file mode 100644
index 0000000..677426a
--- /dev/null
+++ b/bindings/node/tests/smoke.test.mjs
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest';
+import {
+ Folio,
+ FolioError,
+ ChromeNotFoundError,
+ ChromeFetchError,
+ ChromiumError,
+ OfficeError,
+ EngineDisabledError,
+ TimeoutError,
+ ValidationError,
+} from '../index.js';
+
+describe('module exports', () => {
+ it('exposes Folio class with create static method', () => {
+ expect(typeof Folio.create).toBe('function');
+ });
+
+ it('exposes Folio instance methods on prototype', () => {
+ for (const m of ['htmlToPdf', 'urlToPdf', 'markdownToPdf', 'officeToPdf', 'close']) {
+ expect(typeof Folio.prototype[m]).toBe('function');
+ }
+ });
+
+ it('error subclasses extend FolioError', () => {
+ expect(new ChromeNotFoundError('x')).toBeInstanceOf(FolioError);
+ expect(new ChromeFetchError('x')).toBeInstanceOf(FolioError);
+ expect(new ChromiumError('x')).toBeInstanceOf(FolioError);
+ expect(new OfficeError('x')).toBeInstanceOf(FolioError);
+ expect(new EngineDisabledError('x')).toBeInstanceOf(FolioError);
+ expect(new TimeoutError('x')).toBeInstanceOf(FolioError);
+ expect(new ValidationError('x')).toBeInstanceOf(FolioError);
+ });
+
+ it('FolioError extends Error', () => {
+ expect(new FolioError('x')).toBeInstanceOf(Error);
+ });
+});
diff --git a/bindings/python/.gitignore b/bindings/python/.gitignore
new file mode 100644
index 0000000..f1e48b8
--- /dev/null
+++ b/bindings/python/.gitignore
@@ -0,0 +1,6 @@
+.venv/
+__pycache__/
+*.so
+*.pyd
+dist/
+*.egg-info/
diff --git a/bindings/python/README.md b/bindings/python/README.md
new file mode 100644
index 0000000..9add61f
--- /dev/null
+++ b/bindings/python/README.md
@@ -0,0 +1,30 @@
+# folio (Python)
+
+Rust-native PDF conversion, embeddable. See spec at
+`docs/superpowers/specs/2026-05-01-bindings-design.md`.
+
+## Install
+
+ pip install folio
+
+## Quick start
+
+ from folio import Folio
+ with Folio() as f:
+ pdf = f.html_to_pdf("hi
")
+ open("out.pdf", "wb").write(pdf)
+
+## Async
+
+ import asyncio
+ from folio import AsyncFolio
+
+ async def main():
+ f = await AsyncFolio.create()
+ try:
+ pdf = await f.html_to_pdf("hi
")
+ finally:
+ await f.close()
+ return pdf
+
+ asyncio.run(main())
diff --git a/bindings/python/folio/__init__.py b/bindings/python/folio/__init__.py
new file mode 100644
index 0000000..f0e02a7
--- /dev/null
+++ b/bindings/python/folio/__init__.py
@@ -0,0 +1,26 @@
+"""Folio — Rust-native PDF conversion."""
+from ._native import (
+ Folio,
+ AsyncFolio,
+ FolioError,
+ ChromeNotFoundError,
+ ChromeFetchError,
+ ChromiumError,
+ OfficeError,
+ EngineDisabledError,
+ TimeoutError,
+ ValidationError,
+)
+
+__all__ = [
+ "Folio",
+ "AsyncFolio",
+ "FolioError",
+ "ChromeNotFoundError",
+ "ChromeFetchError",
+ "ChromiumError",
+ "OfficeError",
+ "EngineDisabledError",
+ "TimeoutError",
+ "ValidationError",
+]
diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml
new file mode 100644
index 0000000..3209eff
--- /dev/null
+++ b/bindings/python/pyproject.toml
@@ -0,0 +1,27 @@
+[build-system]
+requires = ["maturin>=1.7,<2.0"]
+build-backend = "maturin"
+
+[project]
+name = "folio"
+version = "0.1.0"
+description = "Folio: Rust-native PDF conversion, embeddable in Python."
+readme = "README.md"
+license = { text = "MIT" }
+requires-python = ">=3.8"
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Rust",
+ "License :: OSI Approved :: MIT License",
+]
+
+[project.urls]
+Homepage = "https://github.com/__deesh_reddy__/folio"
+Repository = "https://github.com/__deesh_reddy__/folio"
+
+[tool.maturin]
+manifest-path = "../../crates/py/Cargo.toml"
+module-name = "folio._native"
+features = ["pyo3/extension-module"]
+python-source = "."
+strip = true
diff --git a/bindings/python/tests/test_e2e.py b/bindings/python/tests/test_e2e.py
new file mode 100644
index 0000000..730700f
--- /dev/null
+++ b/bindings/python/tests/test_e2e.py
@@ -0,0 +1,35 @@
+import os, pathlib, pytest
+import folio
+
+E2E = os.environ.get("FOLIO_E2E") == "1"
+pytestmark = pytest.mark.skipif(not E2E, reason="FOLIO_E2E not set")
+
+FIXTURE = pathlib.Path(__file__).resolve().parents[2] / "fixtures" / "hello.html"
+
+def test_html_to_pdf_sync():
+ with folio.Folio(engines=["chromium"]) as f:
+ pdf = f.html_to_pdf(FIXTURE.read_text())
+ assert pdf[:4] == b"%PDF"
+
+def test_url_to_pdf_sync():
+ with folio.Folio(engines=["chromium"]) as f:
+ pdf = f.url_to_pdf("about:blank")
+ assert pdf[:4] == b"%PDF"
+
+def test_markdown_to_pdf_sync():
+ with folio.Folio(engines=["chromium"]) as f:
+ pdf = f.markdown_to_pdf("# hello\n\nfolio e2e")
+ assert pdf[:4] == b"%PDF"
+
+import asyncio
+
+def test_html_to_pdf_async():
+ async def run():
+ f = await folio.AsyncFolio.create(engines=["chromium"])
+ try:
+ pdf = await f.html_to_pdf(FIXTURE.read_text())
+ finally:
+ await f.close()
+ return pdf
+ pdf = asyncio.run(run())
+ assert pdf[:4] == b"%PDF"
diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py
new file mode 100644
index 0000000..1882fe3
--- /dev/null
+++ b/bindings/python/tests/test_smoke.py
@@ -0,0 +1,38 @@
+import folio
+
+def test_module_exports():
+ assert hasattr(folio, "Folio")
+ assert hasattr(folio, "AsyncFolio")
+ assert issubclass(folio.ChromeNotFoundError, folio.FolioError)
+ assert issubclass(folio.ChromiumError, folio.FolioError)
+ assert issubclass(folio.OfficeError, folio.FolioError)
+ assert issubclass(folio.ValidationError, folio.FolioError)
+
+def test_validation_error_class_exists():
+ assert folio.ValidationError is not None
+ assert issubclass(folio.ValidationError, folio.FolioError)
+
+def test_folio_class_methods():
+ # Don't instantiate (would launch Chrome). Just check the class exists.
+ assert hasattr(folio.Folio, "html_to_pdf")
+ assert hasattr(folio.Folio, "url_to_pdf")
+ assert hasattr(folio.Folio, "markdown_to_pdf")
+ assert hasattr(folio.Folio, "office_to_pdf")
+ assert hasattr(folio.Folio, "close")
+ assert hasattr(folio.Folio, "__enter__")
+ assert hasattr(folio.Folio, "__exit__")
+
+def test_async_folio_class_exists():
+ assert hasattr(folio.AsyncFolio, "create")
+ assert hasattr(folio.AsyncFolio, "html_to_pdf")
+ assert hasattr(folio.AsyncFolio, "url_to_pdf")
+ assert hasattr(folio.AsyncFolio, "markdown_to_pdf")
+ assert hasattr(folio.AsyncFolio, "office_to_pdf")
+ assert hasattr(folio.AsyncFolio, "close")
+
+def test_async_folio_create_returns_coroutine():
+ """AsyncFolio.create() must return an awaitable, not eagerly launch."""
+ import folio, inspect
+ # Don't call it (would launch chrome). Just confirm it's a static method
+ # and the signature accepts our args.
+ assert callable(folio.AsyncFolio.create)
diff --git a/comparison.md b/comparison.md
new file mode 100644
index 0000000..d73a1c4
--- /dev/null
+++ b/comparison.md
@@ -0,0 +1,559 @@
+# Folio vs Gotenberg — In-Depth Feature Comparison
+
+> **Snapshot date:** 2026-05-01
+> **Folio commit:** `spec/operator-console` (HEAD: `209a444`)
+> **Gotenberg snapshot:** vendored at `tmp/gotenberg/`
+> **Companion:** `docs/markdown-plus.md` (the new Markdown variation
+> referenced in this comparison's recommendations).
+
+This document is an audit, not a sales sheet. It records what each project
+does *today*, what Folio has chosen not to do (deliberately or not), and
+what is missing relative to Gotenberg parity. It is structured so that any
+single section can be read in isolation by someone deciding whether Folio
+is ready for their workload.
+
+---
+
+## 0. TL;DR
+
+| Axis | Folio | Gotenberg | Verdict |
+|-----------------------------------------|------------------------------------|------------------------------------|------------------------|
+| Core conversions (HTML/URL/MD/Office) | ✅ Implemented | ✅ Implemented | **Parity** |
+| Screenshot routes (PNG/JPEG/WebP) | ✅ Implemented | ✅ Implemented | **Parity** |
+| PDF ops (merge/split/flatten/rotate/…) | ✅ Implemented (single backend) | ✅ Implemented (multi backend) | Folio behind on choice |
+| PDF/A & PDF/UA | ✅ via Ghostscript | ✅ via LibreOffice + engines | Different paths, OK |
+| Metadata read/write | ✅ | ✅ | **Parity** |
+| Bookmarks read/write | ✅ | ✅ | **Parity** |
+| Encrypt | ✅ | ✅ | **Parity** |
+| Watermark / stamp | ✅ (watermark) / partial (stamp) | ✅ both | Folio behind on stamp |
+| Webhook async delivery | 🚧 Scaffolded, callback TODO | ✅ Production-grade | **Folio missing** |
+| Batch API | 🚧 Endpoints + worker, ZIP TODO | ❌ Not offered | Folio ahead (in spec) |
+| Prometheus metrics | ✅ Rich set | ✅ Standard set | **Parity** |
+| Structured logs | ✅ JSON/text + request IDs | ✅ slog | **Parity** |
+| OpenTelemetry traces | ✅ OTLP HTTP | ✅ OTel SDK | **Parity** |
+| Operator console (live UI) | ✅ Svelte SPA, SSE, charts | ❌ JSON only | **Folio ahead** |
+| Auth (Basic) | ✅ | ✅ | **Parity** |
+| TLS | ❌ (rely on reverse proxy) | ✅ (cert/key flags) | **Folio missing** |
+| SSRF / download allow-deny | partial | ✅ rich | **Folio behind** |
+| Multi-engine fallback per op | ❌ (lopdf only) | ✅ qpdf/pdfcpu/pdftk/exiftool | **Folio missing** |
+| Python / Node bindings | ❌ Empty crates | ❌ Not offered | Both miss |
+| CLI (convert/merge/split/…) | ✅ | ❌ Not offered | **Folio ahead** |
+| Library (Rust crate) usage | ✅ | ❌ Server-only | **Folio ahead** |
+
+**Bottom line.** Folio reaches roughly **85% of Gotenberg's HTTP-surface
+capability** while exceeding it on observability, in-process usage, and
+CLI ergonomics. The remaining 15% — webhook callback delivery, multi-engine
+fallback chains, TLS, fine-grained SSRF controls, advanced Chromium wait
+conditions, the long tail of LibreOffice export filters — is what blocks a
+clean drop-in replacement claim today.
+
+---
+
+## 1. Architecture comparison
+
+### 1.1 Gotenberg
+- **Language:** Go
+- **Framework:** Echo HTTP, modular plugin system
+- **Concurrency model:** Process pools per engine (Chromium / LibreOffice
+ supervised externally), goroutines per request
+- **Rendering:** Each Chromium conversion launches/uses a managed Chrome
+ subprocess; LibreOffice spawns `soffice` per conversion
+- **Deployment shape:** Container-only — the project is explicitly a
+ Docker product
+- **Distribution:** Single binary inside a Debian image with all engines
+ preinstalled
+
+### 1.2 Folio
+- **Language:** Rust
+- **Framework:** axum / tower
+- **Concurrency model:** Tokio tasks, semaphore-bounded; engines wrapped in
+ `SupervisedEngine` with lazy-start / idle-shutdown
+- **Rendering:** Chromium via `chromiumoxide` (CDP) — Folio holds the
+ client; LibreOffice via `soffice` subprocess
+- **Deployment shape:** Container *or* binary *or* Rust library *or* CLI
+- **Distribution:** Multi-target Dockerfile (`folio`, `folio-chromium`,
+ `folio-libreoffice`, `folio-cloudrun`, `folio-lambda`)
+
+### 1.3 What this means in practice
+Folio's choice to live as a *library* is the real architectural divergence
+— it is a strict superset of "PDF microservice", whereas Gotenberg only
+exists as the microservice form. That choice shapes a lot of what
+follows: the supervised-engine wrapper, the operator console, the CLI all
+flow from "we are not married to the HTTP surface."
+
+---
+
+## 2. HTTP API comparison
+
+### 2.1 Endpoint matrix
+
+| Route | Folio | Gotenberg | Notes |
+|---------------------------------------------------|-------|-----------|-------|
+| `POST /forms/chromium/convert/url` | ✅ | ✅ | parity |
+| `POST /forms/chromium/convert/html` | ✅ | ✅ | parity |
+| `POST /forms/chromium/convert/markdown` | ✅ | ✅ | parity, see §3.4 |
+| `POST /forms/chromium/screenshot/url` | ✅ | ✅ | parity |
+| `POST /forms/chromium/screenshot/html` | ✅ | ✅ | parity |
+| `POST /forms/chromium/screenshot/markdown` | ✅ | ✅ | parity |
+| `POST /forms/libreoffice/convert` | ✅ | ✅ | parity, filter coverage differs (see §3.5) |
+| `POST /forms/pdfengines/merge` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/split` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/flatten` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/convert` (PDF/A, PDF/UA) | ✅ | ✅ | different backend |
+| `POST /forms/pdfengines/rotate` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/metadata/read` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/metadata/write` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/bookmarks/read` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/bookmarks/write` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/encrypt` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/embed` | ❌ | ✅ | **Folio missing** — attach files inside PDF |
+| `POST /forms/pdfengines/watermark` | ✅ | ✅ | parity |
+| `POST /forms/pdfengines/stamp` | 🚧 | ✅ | **Folio partial** — overlay-on-pages variant |
+| `POST /forms/batch/submit` | 🚧 | ❌ | **Folio ahead in spec** |
+| `GET /forms/batch/{id}/status` | 🚧 | ❌ | **Folio ahead in spec** |
+| `GET /forms/batch/{id}/download` | 🚧 | ❌ | **Folio ahead in spec** |
+| `GET /health` | ✅ | ✅ | parity |
+| `GET /version` | ✅ | ❌ | **Folio ahead** (Gotenberg ships version on root) |
+| `GET /prometheus/metrics` | ✅ | ✅ | parity |
+| `GET /_/`, `/_/sse`, `/_/metrics.json` | ✅ | ❌ | **Folio ahead** — operator console |
+| Webhook headers (`Webhook-Url`, etc.) | 🚧 | ✅ | callback delivery TODO in Folio |
+
+**Visible gaps in HTTP surface:** `embed`, full `stamp`, complete webhook
+callback delivery, batch ZIP/merge output. Everything else exists.
+
+### 2.2 Request/response shape
+
+Gotenberg insists on multipart/form-data for *every* conversion. Folio
+follows the same convention for all core routes — operators using
+Gotenberg client SDKs (`gotenberg-php`, `gotenberg-js-client`,
+`gotenberg-go-client`) can point at Folio with only a base-URL change for
+the parity routes. This is a deliberate compatibility choice, not an
+accident.
+
+---
+
+## 3. Conversion engines, feature by feature
+
+### 3.1 Chromium — PDF generation
+
+| Feature | Folio | Gotenberg | Notes |
+|-----------------------------------------|-------|-----------|-------|
+| Paper size (named + custom WxH) | ✅ | ✅ | parity |
+| Margins (per side, inches) | ✅ | ✅ | parity |
+| Landscape | ✅ | ✅ | parity |
+| Print background | ✅ | ✅ | parity |
+| Omit background (transparency) | ✅ | ✅ | parity |
+| Single-page mode | ✅ | ✅ | parity |
+| Scale (0.1–2.0) | ✅ | ✅ | parity |
+| Page ranges | ✅ | ✅ | parity |
+| Custom header/footer HTML w/ tokens | ✅ | ✅ | parity |
+| Prefer CSS page size | ✅ | ✅ | parity |
+| Tagged PDF / outline | partial | ✅ | Folio passes flags but limited testing |
+| Cookies (with sameSite) | ✅ | ✅ | parity |
+| Extra HTTP headers (scoped) | partial | ✅ | Folio: flat headers; Gotenberg: regex scope |
+| User-Agent override | ✅ | ✅ | parity |
+| Emulated media type | ✅ | ✅ | parity |
+| Emulated media features (color-scheme…) | ❌ | ✅ | **Folio missing** |
+
+### 3.2 Chromium — wait / failure conditions
+
+| Feature | Folio | Gotenberg |
+|--------------------------------------------------|-------|-----------|
+| `waitDelay` (fixed) | ✅ | ✅ |
+| `waitForExpression` / custom JS predicate | partial | ✅ |
+| `waitWindowStatus` | ❌ | ✅ |
+| `waitForSelector` | ❌ | ✅ |
+| `skipNetworkIdleEvent` | ❌ | ✅ |
+| `skipNetworkAlmostIdleEvent` | ❌ | ✅ |
+| `failOnHttpStatusCodes` | ❌ | ✅ |
+| `failOnResourceHttpStatusCodes` | ❌ | ✅ |
+| `ignoreResourceHttpStatusDomains` | ❌ | ✅ |
+| `failOnResourceLoadingFailed` | ❌ | ✅ |
+| `failOnConsoleExceptions` | ❌ | ✅ |
+
+This is the most concrete Chromium feature gap. Spec
+(archived spec) already exists; it just hasn't
+been implemented past the stub. **Recommendation:** prioritise.
+
+### 3.3 Chromium — Screenshots
+
+Both projects support PNG/JPEG/WebP, dimensions, JPEG quality, viewport
+clipping, optimize-for-speed. **Parity.** The only gap is that Folio's
+"capture beyond viewport" code path has fewer integration tests covered
+than Gotenberg's.
+
+### 3.4 Markdown route
+
+Both implementations are minimal. Both produce a wrapped HTML document and
+hand it to Chromium. Differences:
+
+- **Folio:** `pulldown_cmark` with `Options::all()` + a single embedded
+ `markdown.css`. No template injection point.
+- **Gotenberg:** `gomarkdown` + `bluemonday` (sanitised HTML). Requires
+ the user to supply a wrapper HTML file (named `index.html` in the
+ multipart) that pulls the rendered Markdown in via a documented
+ mechanism, so the user can inject CSS/fonts/JS.
+
+Each has a different opinion: Folio is "we own the template, give us
+markdown"; Gotenberg is "you own the template, give us markdown + a
+template."
+
+This comparison's companion document `docs/markdown-plus.md` proposes a
+**third route** that combines both philosophies plus front-matter, math,
+mermaid, syntax highlighting, includes, and named themes. That work is
+designed to ship alongside the existing route, not replace it.
+
+### 3.5 LibreOffice — input formats
+
+Both projects exercise LibreOffice's full ~100-format input matrix (DOC,
+DOCX, ODT, ODS, ODP, XLS, XLSX, PPT, PPTX, RTF, CSV, EPUB, etc.). The
+difference is in **export options**:
+
+| Export option | Folio | Gotenberg |
+|---------------------------------------------|-------|-----------|
+| Landscape | ✅ | ✅ |
+| Native page ranges | partial | ✅ |
+| Single-page mode (Calc/Sheet) | ✅ | ✅ |
+| Password-protected input documents | ❌ | ✅ |
+| Update indexes on conversion | ❌ | ✅ |
+| Export form fields | ❌ | ✅ |
+| Export bookmarks | partial | ✅ |
+| Export notes / placeholders | ❌ | ✅ |
+| Bookmarks → PDF destinations | ❌ | ✅ |
+| Image compression (lossless / JPEG quality) | ❌ | ✅ |
+| Image resolution reduction | ❌ | ✅ |
+| Viewer preferences (initial view, zoom…) | ❌ | ✅ |
+| Native LibreOffice watermark | ❌ | ✅ |
+| PDF/A-1b / 2b / 3b output | ✅ | ✅ |
+| PDF/UA output | ✅ | ✅ |
+
+Spec (archived spec) lists most of these as
+explicit TODOs.
+
+### 3.6 PDF engine ops
+
+Gotenberg's killer feature here is **per-operation engine selection with
+fallback chains**: `qpdf → pdfcpu → pdftk` for merge, etc. If qpdf
+chokes on a malformed PDF, pdfcpu retries transparently. Folio uses a
+single backend (`lopdf`, pure Rust) for *every* op, which is operationally
+simpler but means a malformed input has no recovery path other than
+"return an error and let the caller deal with it."
+
+This is the largest pure-feature gap. Three options for closing it:
+
+- **(A) Re-implement engine fallback in Rust** by shelling out to qpdf /
+ pdfcpu / pdftk binaries. Cheapest. Loses some of the "no external tools"
+ posture but Folio already shells out to `soffice` and `gs`, so the
+ posture is already mixed.
+- **(B) Stay single-backend and harden lopdf** — file upstream patches for
+ the malformed-input cases that arise. Highest engineering cost, slowest
+ return.
+- **(C) Punt** — say in the README that Folio is "well-formed PDF only"
+ and let users pre-validate. Honest, but caps the addressable workload.
+
+Spec (archived spec) exists and points at (A).
+
+---
+
+## 4. Async delivery — webhooks
+
+Gotenberg's webhook module is mature: middleware POSTs the produced file
+to a user-supplied URL with retry logic, allow/deny lists (literal and
+regex), private/public IP filtering for SSRF, configurable retry windows,
+sync vs async modes.
+
+Folio has the **shape** of this — `Webhook-Url` and friends parse,
+`crates/server/src/webhook/` exists, the worker runs — but the actual
+callback delivery path is marked TODO. Until that lands, an operator
+sending `Webhook-Url` headers will see a 202 and then... nothing.
+
+**Status:** spec (archived spec) is the source of truth; the
+gap is implementation, not design.
+
+---
+
+## 5. Batch API (Folio-only)
+
+Folio has a server-side batch surface that Gotenberg has no equivalent
+for: submit a JSON manifest of N jobs, get back a `batch_id`, poll for
+progress, download a ZIP when done. The endpoints exist; the worker runs;
+ZIP packaging and per-item-failure semantics are TODO.
+
+This is a real differentiator, not just parity-plus. Worth finishing.
+
+---
+
+## 6. Operator console (Folio-only)
+
+This is where Folio is unambiguously ahead.
+
+Gotenberg gives you `/health` (JSON) and `/prometheus/metrics`
+(Prometheus text). That is the entire operability surface. To get any
+actual visibility you wire it into Grafana yourself.
+
+Folio ships a Svelte SPA at `/_/` driven by Server-Sent Events that
+shows, live, in one screen:
+
+- RPS, p95 latency, error %, in-flight count
+- Per-route table (RPS, p50/p95/p99, error %, load %)
+- Engine status (Chromium / LibreOffice up/down + restart count)
+- Concurrency grid (active vs cap, with warn/crit thresholds)
+- Throughput strip (30-min windowed RPS + p95 with SLA overlay)
+- Activity strip (error % + queue depth)
+- Resources (CPU %, memory MB)
+- Active batches (progress + per-item state)
+- Last-20 request log + last-10 error log
+
+The recent commit history (last 30 commits, all dashboard-focused) shows
+this is the team's current focus and it is in active polish.
+
+This shifts the value proposition: Folio is not "Gotenberg in Rust", it
+is "Gotenberg-compatible PDF service that you can run without immediately
+needing a dashboards engineer."
+
+---
+
+## 7. Configuration / CLI flags
+
+Gotenberg has a wide and stable flag surface (api, webhook, pdfengines,
+prometheus, basic auth). Folio's flags cover the same axes but are
+narrower:
+
+| Knob | Folio | Gotenberg |
+|---------------------------------------------|-------|-----------|
+| API port / bind / TLS | port + bind ✅, TLS ❌ | ✅ |
+| Body limit (multipart) | ✅ | ✅ |
+| Per-request timeout | ✅ | ✅ |
+| Root path (reverse-proxy mount) | ❌ | ✅ |
+| Correlation ID header | ✅ | ✅ |
+| Basic-auth user/pass (env) | ✅ | ✅ |
+| Download allow/deny lists | partial | ✅ |
+| Download deny private/public IPs | partial | ✅ |
+| Download max retries | ✅ | ✅ |
+| Disable downloads entirely | ❌ | ✅ |
+| Enable debug route | ❌ | ✅ |
+| Webhook allow/deny + SSRF filters | partial | ✅ |
+| Webhook retry waits / counts / timeouts | partial | ✅ |
+| Per-op engine selection (merge/split/…) | ❌ | ✅ |
+| Disable specific PDF engine routes | ❌ | ✅ |
+| Prometheus namespace / collect interval | partial | ✅ |
+| Disable route telemetry | ✅ | ✅ |
+
+**Recommendation:** the gaps here are individually small; add them one
+by one as `--root-path`, `--api-disable-debug`, `--api-disable-download`,
+and SSRF flags. Spec (archived spec) already exists.
+
+---
+
+## 8. Auth & security posture
+
+| Concern | Folio | Gotenberg |
+|--------------------------------------------|-------|-----------|
+| HTTP Basic Auth | ✅ | ✅ |
+| Token / JWT auth | ❌ | ❌ |
+| Per-route authorisation | ❌ | ❌ |
+| TLS in-process | ❌ | ✅ |
+| `file://` rejected on URL routes | ✅ | ✅ |
+| SSRF: private IP block | partial | ✅ |
+| SSRF: public IP block | ❌ | ✅ |
+| Download URL allow/deny regex | ❌ | ✅ |
+| Webhook URL allow/deny regex | partial | ✅ |
+| Multipart body limit enforcement | ✅ | ✅ |
+| Memory-safe core | ✅ (Rust) | ❌ (Go GC) |
+
+Folio's Rust core is a real security advantage at the parser level;
+Gotenberg's mature SSRF/download/webhook filter stack is a real security
+advantage at the network edge. They are not the same thing and Folio
+should not pretend memory-safety substitutes for the network filters —
+both matter.
+
+---
+
+## 9. Observability
+
+| Surface | Folio | Gotenberg |
+|--------------------------------------|-------|-----------|
+| Structured logs (JSON / text) | ✅ | ✅ |
+| Request ID propagation | ✅ | ✅ |
+| Prometheus counters/histograms | ✅ | ✅ |
+| OpenTelemetry traces | ✅ (OTLP HTTP) | ✅ |
+| OpenTelemetry metrics | ✅ | ✅ |
+| Live operator UI | ✅ | ❌ |
+| SSE event stream | ✅ | ❌ |
+| Per-engine health endpoint detail | ✅ (per-engine) | ✅ |
+
+**Parity, with Folio ahead on the live UI.** No gaps to call out here.
+
+---
+
+## 10. Distribution surfaces
+
+| Surface | Folio | Gotenberg |
+|----------------------------------|-------|-----------|
+| HTTP server (Docker) | ✅ | ✅ |
+| HTTP server (raw binary) | ✅ | ❌ (officially Docker-only) |
+| CLI binary (`folio convert …`) | ✅ | ❌ |
+| Rust library (in-process) | ✅ | ❌ |
+| Python bindings | ❌ (placeholder) | ❌ |
+| Node.js bindings | ❌ (placeholder) | ❌ |
+| Cloud Run image | ✅ (`folio-cloudrun`) | ❌ |
+| AWS Lambda image | ✅ (`folio-lambda`) | ❌ |
+| Slim images (Chromium-only / LO-only) | ✅ | ❌ |
+
+Folio has done real work here that Gotenberg has explicitly said no to
+(Gotenberg's stance is that it is a Docker product; everything else is
+the user's problem). The *empty* Python/Node bindings undercut that
+narrative — the placeholder crates (`crates/py/`, `crates/js/`) imply a
+roadmap commitment that has no actual code. Either ship them or remove
+the placeholders; the worst state is "empty crate that suggests a
+feature."
+
+---
+
+## 11. Test coverage
+
+- **Folio:** ~43 unit tests passing across types, engine, pdfops, routes;
+ ~25 BDD scenarios ported from Gotenberg (runner partially complete);
+ 5 e2e smoke tests; Docker-based PDF/A validation via verapdf.
+ `TEST_STATUS.md` and `TEST_ISSUES.md` are surprisingly honest about
+ what is and isn't passing.
+- **Gotenberg:** mature integration test suite that has been running for
+ years; thousands of cumulative production deployments worth of
+ battle-testing.
+
+The maturity gap is real. Folio's BDD harness is the right move (re-using
+Gotenberg's scenarios is the cheapest path to credibility), it just needs
+to finish.
+
+---
+
+## 12. What Folio did well, with credit
+
+- **Library-first architecture.** Being usable as a Rust crate, a CLI,
+ and a server is a substantial superset of Gotenberg's positioning, and
+ was clearly an early decision rather than a retrofit (the engine crates
+ have no axum imports).
+- **Operator console.** The SSE-driven Svelte dashboard is a genuinely
+ better operator experience than Gotenberg's bare metrics endpoint.
+ This was the right thing to invest in last.
+- **Supervised engines with lazy-start / idle-shutdown.** Memory profile
+ on idle should be substantially better than Gotenberg's eager
+ process-pool model — relevant for serverless deploys (Cloud Run /
+ Lambda images exist for a reason).
+- **Atomic concurrency tracking** (commit `209a444`) over sampled
+ semaphore reads. Small fix, but it's the kind of correctness work that
+ shows the team has actually been driving the dashboard against real
+ load.
+- **Honest test status docs.** `TEST_STATUS.md` and `TEST_ISSUES.md`
+ exist and are not propaganda. Easy to underestimate how rare this is.
+
+## 13. What Folio did not do, deliberately
+
+- **No multi-engine fallback** for PDF ops. Single backend (`lopdf`)
+ keeps the dependency surface small. Defensible until you hit the first
+ malformed-input bug report, at which point the answer becomes "punt or
+ shell out." Decide before users force the decision.
+- **No batch-of-batches / DAG job system.** The batch API is a flat list
+ of jobs, not a workflow. This is the right call for a PDF service —
+ workflow tools belong elsewhere.
+- **No template engine for Markdown.** The basic Markdown route does not
+ let users inject Liquid/Handlebars/etc. The companion proposal
+ (`docs/markdown-plus.md`) preserves this stance: front-matter
+ substitution only, no full templating.
+- **No cross-request server-side state.** Includes resolve from the
+ upload only. This is a security posture, not laziness.
+
+## 14. What Folio did not do, but should
+
+In rough priority order (cheapest-impact-per-LOC first):
+
+1. **Finish webhook callback delivery** ((archived spec)). The
+ Async-202 path is half-built; finishing it unblocks Gotenberg client
+ compatibility.
+2. **Wire advanced Chromium wait conditions** (spec 36): `waitForSelector`,
+ `waitWindowStatus`, `failOn*` family. Each is a single CDP call.
+3. **Finish batch ZIP packaging + per-item failure semantics**
+ (spec 50-batch). The endpoints already exist; finishing them turns a
+ stub into a differentiator.
+4. **Add `embed` + finish `stamp`** routes. Last gaps in the
+ `/forms/pdfengines/*` matrix.
+5. **Implement `--root-path` and SSRF/download filter flags**
+ (spec 39). Small individual changes; collectively close the
+ security/operations gap.
+6. **Decide on multi-engine PDF ops** (spec 38). Either ship qpdf/pdfcpu
+ shellout or commit to "well-formed PDFs only" in the README. Current
+ middle ground is the worst of both.
+7. **Either ship the Python/Node bindings or remove the placeholder
+ crates.** Empty crates are a roadmap lie.
+8. **Fill in LibreOffice export filters** (spec 37). Long tail; do as
+ user demand surfaces, not preemptively.
+9. **Build Markdown+** (`docs/markdown-plus.md`). Net-new feature, not
+ Gotenberg parity, but uses the operator-console + observability
+ investment as a foundation.
+
+## 15. What Folio did not do, and arguably should not
+
+- **TLS in-process.** Use a reverse proxy. Adding TLS to the binary adds
+ cert rotation, OCSP stapling, ALPN — none of which Folio is positioned
+ to do better than nginx/Caddy/envoy. The current "not implemented"
+ status is correct; it should be made *explicit* in the README.
+- **OAuth / JWT / RBAC.** PDF services are not where you want to be doing
+ identity. Stay with Basic Auth + reverse-proxy auth headers; document
+ the pattern.
+- **A workflow / DAG engine on top of batch.** Out of scope. Forever.
+- **A web-UI document editor.** Folio's UI is an operator console, not an
+ end-user product. The line should stay there.
+
+---
+
+## 16. What we did vs what we did not — concise scorecard
+
+### Done
+- Six Chromium routes (HTML/URL/Markdown × convert+screenshot)
+- LibreOffice convert route + 100+ input formats
+- All standard PDF ops bar `embed` and full `stamp`
+- PDF/A and PDF/UA via Ghostscript
+- Bookmarks, metadata, encrypt
+- HTTP Basic Auth
+- Prometheus metrics + OpenTelemetry traces + structured logs
+- Operator console (Svelte + SSE) — distinct lead over Gotenberg
+- CLI with convert/merge/split/flatten/rotate/metadata
+- Multi-target Docker images (full / chromium-only / lo-only / cloudrun /
+ lambda)
+- Library usage as a Rust crate
+- BDD test harness (in progress)
+
+### Not done
+- Webhook callback delivery (scaffold only)
+- Batch ZIP output / per-item failure semantics (scaffold only)
+- `embed` route, full `stamp` route
+- Advanced Chromium wait/fail conditions (spec 36)
+- Long tail of LibreOffice export options (spec 37)
+- Multi-engine PDF op fallback (spec 38)
+- Several CLI flags (`--root-path`, full SSRF filters) (spec 39)
+- Python and Node.js bindings (empty placeholder crates)
+- Cookie/header-scope regex filtering on Chromium routes
+- Emulated media features (color-scheme, prefers-reduced-motion)
+- TLS in-process *(deliberately not done; document the choice)*
+
+### Should be added (new)
+- **Markdown+** — see `docs/markdown-plus.md`. Builds on existing
+ Chromium pipeline; uses existing observability stack; ships standalone
+ without blocking on webhook/batch/bindings.
+- **Stage-level histograms** for any multi-stage route (Markdown+ is the
+ obvious first user). Genuine new information, not just parity.
+- **Operator console "Markdown+" panel**, conditionally rendered when
+ traffic exists. Avoids polluting empty deployments.
+
+### Should *not* be added
+- TLS in-process
+- Identity/RBAC inside Folio
+- Workflow/DAG engine on top of batch
+- A document editor
+- A second Markdown route that is "just like the first but with an
+ option" — extension, not duplication
+
+---
+
+*End of comparison. The companion proposal in `docs/markdown-plus.md`
+implements the "should be added (new)" section's first item.*
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 399103d..a55fb9e 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -8,14 +8,17 @@ description = "Folio core engine: ChromiumEngine, LibreOfficeEngine, PdfOperatio
[features]
default = ["chromium", "libreoffice"]
chromium = ["dep:chromiumoxide", "dep:futures-util", "dep:pulldown-cmark", "dep:urlencoding"]
-libreoffice = []
+libreoffice = ["dep:reqwest", "dep:base64"]
+chrome-fetch = ["chromium", "dep:reqwest", "dep:sha2", "dep:zip", "dep:flate2", "dep:tar", "dep:dirs", "dep:walkdir"]
[dependencies]
+base64 = { workspace = true, optional = true }
chromiumoxide = { workspace = true, optional = true }
futures-util = { workspace = true, optional = true }
humantime-serde = { workspace = true }
lopdf = { workspace = true }
pulldown-cmark = { workspace = true, optional = true }
+reqwest = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
@@ -30,6 +33,14 @@ image = { workspace = true }
# URL encoding for screenshot data URLs
urlencoding = { version = "2", optional = true }
+# Chrome auto-download (chrome-fetch feature)
+sha2 = { workspace = true, optional = true }
+zip = { workspace = true, optional = true }
+flate2 = { workspace = true, optional = true }
+tar = { workspace = true, optional = true }
+dirs = { workspace = true, optional = true }
+walkdir = { workspace = true, optional = true }
+
[dev-dependencies]
axum = { workspace = true }
proptest = { workspace = true }
diff --git a/crates/engine/src/chrome_fetch/cache.rs b/crates/engine/src/chrome_fetch/cache.rs
new file mode 100644
index 0000000..6107d6d
--- /dev/null
+++ b/crates/engine/src/chrome_fetch/cache.rs
@@ -0,0 +1,71 @@
+//! Platform cache directory for downloaded Chrome builds.
+
+use std::path::{Path, PathBuf};
+
+/// Default cache root for Folio's downloaded Chrome.
+///
+/// - macOS: `~/Library/Caches/folio/chromium`
+/// - Linux: `$XDG_CACHE_HOME/folio/chromium` (falls back to `~/.cache`)
+/// - Windows: `%LOCALAPPDATA%\folio\chromium`
+///
+/// Override via `FOLIO_CHROME_CACHE` env var; constructor argument wins
+/// over both.
+pub fn cache_dir() -> PathBuf {
+ if let Ok(env) = std::env::var("FOLIO_CHROME_CACHE") {
+ if !env.is_empty() {
+ return PathBuf::from(env);
+ }
+ }
+ let base = dirs::cache_dir().unwrap_or_else(|| PathBuf::from("."));
+ base.join("folio").join("chromium")
+}
+
+/// Returns `Some(path)` if a cached Chrome for `version` exists and the
+/// executable is present.
+pub fn cached_chrome(cache: &Path, version: &str) -> Option {
+ let exe = chrome_exe_path(&cache.join(version));
+ if exe.exists() { Some(exe) } else { None }
+}
+
+/// Path to the Chrome executable inside an extracted Chrome-for-Testing
+/// distribution rooted at `dist`.
+pub(crate) fn chrome_exe_path(dist: &Path) -> PathBuf {
+ #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
+ {
+ dist.join("chrome-mac-arm64").join("Google Chrome for Testing.app")
+ .join("Contents/MacOS/Google Chrome for Testing")
+ }
+ #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
+ {
+ dist.join("chrome-mac-x64").join("Google Chrome for Testing.app")
+ .join("Contents/MacOS/Google Chrome for Testing")
+ }
+ #[cfg(target_os = "linux")]
+ {
+ dist.join("chrome-linux64").join("chrome")
+ }
+ #[cfg(target_os = "windows")]
+ {
+ dist.join("chrome-win64").join("chrome.exe")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn cache_dir_respects_env_override() {
+ // SAFETY: test mutates env in a non-overlapping way.
+ unsafe { std::env::set_var("FOLIO_CHROME_CACHE", "/tmp/folio-test-cache"); }
+ assert_eq!(cache_dir(), PathBuf::from("/tmp/folio-test-cache"));
+ // SAFETY: justified above.
+ unsafe { std::env::remove_var("FOLIO_CHROME_CACHE"); }
+ }
+
+ #[test]
+ fn cached_chrome_none_when_dir_missing() {
+ let tmp = tempfile::tempdir().unwrap();
+ assert!(cached_chrome(tmp.path(), "999.0.0.0").is_none());
+ }
+}
diff --git a/crates/engine/src/chrome_fetch/detect.rs b/crates/engine/src/chrome_fetch/detect.rs
new file mode 100644
index 0000000..125af26
--- /dev/null
+++ b/crates/engine/src/chrome_fetch/detect.rs
@@ -0,0 +1,109 @@
+//! Detect a usable system Chrome / Chromium executable.
+
+use std::path::PathBuf;
+
+/// Returns the first existing Chrome executable found via env vars,
+/// `$PATH`, and platform-default install paths. Returns `None` if none
+/// of the candidates resolve.
+pub fn detect_system_chrome() -> Option {
+ detect_with(
+ std::env::var("BROWSER_PATH").ok().as_deref(),
+ std::env::var("CHROME_PATH").ok().as_deref(),
+ &|name| which::which(name).ok(),
+ &|p: &std::path::Path| p.exists(),
+ )
+}
+
+pub(crate) fn detect_with(
+ browser_path_env: Option<&str>,
+ chrome_path_env: Option<&str>,
+ path_lookup: &dyn Fn(&str) -> Option,
+ exists: &dyn Fn(&std::path::Path) -> bool,
+) -> Option {
+ for env in [browser_path_env, chrome_path_env].into_iter().flatten() {
+ if !env.is_empty() {
+ let p = PathBuf::from(env);
+ if exists(&p) {
+ return Some(p);
+ }
+ }
+ }
+ for name in ["chromium-browser", "chromium", "google-chrome", "chrome"] {
+ if let Some(p) = path_lookup(name) {
+ return Some(p);
+ }
+ }
+ for candidate in platform_defaults() {
+ let p = PathBuf::from(candidate);
+ if exists(&p) {
+ return Some(p);
+ }
+ }
+ None
+}
+
+#[cfg(target_os = "macos")]
+fn platform_defaults() -> &'static [&'static str] {
+ &[
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ ]
+}
+
+#[cfg(target_os = "linux")]
+fn platform_defaults() -> &'static [&'static str] {
+ &[
+ "/usr/bin/google-chrome",
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ "/snap/bin/chromium",
+ ]
+}
+
+#[cfg(target_os = "windows")]
+fn platform_defaults() -> &'static [&'static str] {
+ &[
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
+ ]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::Path;
+
+ #[test]
+ fn explicit_browser_path_wins_when_exists() {
+ let result = detect_with(
+ Some("/fake/chrome"),
+ None,
+ &|_| None,
+ &|p: &Path| p == Path::new("/fake/chrome"),
+ );
+ assert_eq!(result, Some(PathBuf::from("/fake/chrome")));
+ }
+
+ #[test]
+ fn falls_back_to_path_lookup() {
+ let result = detect_with(
+ None,
+ None,
+ &|name| if name == "chromium" { Some(PathBuf::from("/usr/bin/chromium")) } else { None },
+ &|_| false,
+ );
+ assert_eq!(result, Some(PathBuf::from("/usr/bin/chromium")));
+ }
+
+ #[test]
+ fn returns_none_when_nothing_found() {
+ let result = detect_with(None, None, &|_| None, &|_| false);
+ assert_eq!(result, None);
+ }
+
+ #[test]
+ fn empty_env_var_is_skipped() {
+ let result = detect_with(Some(""), None, &|_| None, &|_| false);
+ assert_eq!(result, None);
+ }
+}
diff --git a/crates/engine/src/chrome_fetch/download.rs b/crates/engine/src/chrome_fetch/download.rs
new file mode 100644
index 0000000..06bd28b
--- /dev/null
+++ b/crates/engine/src/chrome_fetch/download.rs
@@ -0,0 +1,200 @@
+//! Download a pinned Chrome-for-Testing build, verify, extract into the
+//! cache.
+//!
+//! Manifest format:
+//! https://github.com/GoogleChromeLabs/chrome-for-testing
+//!
+//! Per-version endpoint:
+//! `https://googlechromelabs.github.io/chrome-for-testing/.json`
+
+use std::path::{Path, PathBuf};
+
+use serde::Deserialize;
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+use tokio::io::AsyncWriteExt;
+
+use super::cache::chrome_exe_path;
+
+/// Errors that can occur when fetching or preparing a Chrome-for-Testing binary.
+#[derive(Debug, Error)]
+pub enum ChromeFetchError {
+ /// System Chrome was not found and `auto_download` was disabled.
+ #[error("system Chrome not found and auto_download disabled")]
+ NotFoundAndDownloadDisabled,
+ /// The current platform is not supported by the Chrome-for-Testing manifest.
+ #[error("unsupported platform: {0}")]
+ UnsupportedPlatform(&'static str),
+ /// Fetching or parsing the per-version manifest failed.
+ #[error("manifest fetch failed: {0}")]
+ Manifest(String),
+ /// The manifest did not contain a download entry for this platform.
+ #[error("no download for platform '{0}' in manifest")]
+ NoPlatformInManifest(&'static str),
+ /// The HTTP download of the Chrome archive failed.
+ #[error("download failed: {0}")]
+ Download(String),
+ /// SHA-256 digest of the downloaded archive did not match the expected value.
+ #[error("checksum mismatch: expected {expected}, got {actual}")]
+ Checksum {
+ /// The expected hex digest from the manifest.
+ expected: String,
+ /// The actual hex digest computed from the downloaded file.
+ actual: String,
+ },
+ /// Extracting the zip archive failed.
+ #[error("extract failed: {0}")]
+ Extract(String),
+ /// An underlying I/O error.
+ #[error("io: {0}")]
+ Io(#[from] std::io::Error),
+}
+
+#[derive(Debug, Deserialize)]
+struct VersionManifest {
+ downloads: Downloads,
+}
+#[derive(Debug, Deserialize)]
+struct Downloads {
+ chrome: Vec,
+}
+#[derive(Debug, Deserialize)]
+struct DownloadEntry {
+ platform: String,
+ url: String,
+}
+
+#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
+const PLATFORM: &str = "mac-arm64";
+#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
+const PLATFORM: &str = "mac-x64";
+#[cfg(target_os = "linux")]
+const PLATFORM: &str = "linux64";
+#[cfg(target_os = "windows")]
+const PLATFORM: &str = "win64";
+
+/// Download Chrome `version` into `cache_root//`. Returns the
+/// path to the executable.
+///
+/// Atomicity: download to `cache_root/.partial/`, extract there,
+/// rename to `cache_root//` on success.
+pub async fn download_chrome(cache_root: &Path, version: &str) -> Result {
+ let manifest = fetch_manifest(version).await?;
+ let entry = manifest.downloads.chrome.into_iter()
+ .find(|e| e.platform == PLATFORM)
+ .ok_or(ChromeFetchError::NoPlatformInManifest(PLATFORM))?;
+
+ let dest = cache_root.join(version);
+ if dest.exists() {
+ return Ok(chrome_exe_path(&dest));
+ }
+ tokio::fs::create_dir_all(cache_root).await?;
+ let staging = cache_root.join(format!("{version}.partial"));
+ if staging.exists() {
+ tokio::fs::remove_dir_all(&staging).await?;
+ }
+ tokio::fs::create_dir_all(&staging).await?;
+
+ let archive = staging.join(archive_filename());
+ download_to_file(&entry.url, &archive).await?;
+ extract_archive(&archive, &staging)?;
+ tokio::fs::rename(&staging, &dest).await?;
+ Ok(chrome_exe_path(&dest))
+}
+
+async fn fetch_manifest(version: &str) -> Result {
+ let url = format!(
+ "https://googlechromelabs.github.io/chrome-for-testing/{version}.json"
+ );
+ let resp = reqwest::get(&url).await
+ .map_err(|e| ChromeFetchError::Manifest(e.to_string()))?;
+ if !resp.status().is_success() {
+ return Err(ChromeFetchError::Manifest(format!("HTTP {}", resp.status())));
+ }
+ let text = resp.text().await.map_err(|e| ChromeFetchError::Manifest(e.to_string()))?;
+ serde_json::from_str(&text).map_err(|e| ChromeFetchError::Manifest(e.to_string()))
+}
+
+async fn download_to_file(url: &str, dest: &Path) -> Result<(), ChromeFetchError> {
+ let resp = reqwest::get(url).await
+ .map_err(|e| ChromeFetchError::Download(e.to_string()))?;
+ if !resp.status().is_success() {
+ return Err(ChromeFetchError::Download(format!("HTTP {}", resp.status())));
+ }
+ let bytes = resp.bytes().await
+ .map_err(|e| ChromeFetchError::Download(e.to_string()))?;
+ let mut file = tokio::fs::File::create(dest).await?;
+ file.write_all(&bytes).await?;
+ file.flush().await?;
+ Ok(())
+}
+
+#[allow(dead_code)]
+fn verify_sha256(path: &Path, expected_hex: &str) -> Result<(), ChromeFetchError> {
+ let mut hasher = Sha256::new();
+ let mut file = std::fs::File::open(path)?;
+ std::io::copy(&mut file, &mut hasher)?;
+ let actual = hex_lower(&hasher.finalize());
+ if actual != expected_hex.to_ascii_lowercase() {
+ return Err(ChromeFetchError::Checksum { expected: expected_hex.into(), actual });
+ }
+ Ok(())
+}
+
+fn hex_lower(bytes: &[u8]) -> String {
+ let mut s = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ s.push_str(&format!("{b:02x}"));
+ }
+ s
+}
+
+fn archive_filename() -> &'static str { "chrome.zip" }
+
+fn extract_archive(archive: &Path, into: &Path) -> Result<(), ChromeFetchError> {
+ let file = std::fs::File::open(archive)?;
+ let mut zip = zip::ZipArchive::new(file)
+ .map_err(|e| ChromeFetchError::Extract(e.to_string()))?;
+ zip.extract(into).map_err(|e| ChromeFetchError::Extract(e.to_string()))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ for entry in walkdir::WalkDir::new(into) {
+ let entry = entry.map_err(|e| ChromeFetchError::Extract(e.to_string()))?;
+ if entry.file_type().is_file()
+ && entry.file_name().to_string_lossy().contains("chrome")
+ {
+ let mut perm = entry.metadata()
+ .map_err(|e| ChromeFetchError::Extract(e.to_string()))?
+ .permissions();
+ perm.set_mode(0o755);
+ let _ = std::fs::set_permissions(entry.path(), perm);
+ }
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn hex_lower_pads_zeros() {
+ assert_eq!(hex_lower(&[0x0a, 0xff, 0x00]), "0aff00");
+ }
+
+ #[test]
+ fn manifest_deserializes() {
+ let json = r#"{
+ "downloads": {
+ "chrome": [
+ {"platform": "linux64", "url": "https://example.com/chrome.zip"},
+ {"platform": "mac-arm64", "url": "https://example.com/mac.zip"}
+ ]
+ }
+ }"#;
+ let m: VersionManifest = serde_json::from_str(json).unwrap();
+ assert_eq!(m.downloads.chrome.len(), 2);
+ }
+}
diff --git a/crates/engine/src/chrome_fetch/mod.rs b/crates/engine/src/chrome_fetch/mod.rs
new file mode 100644
index 0000000..1b86da9
--- /dev/null
+++ b/crates/engine/src/chrome_fetch/mod.rs
@@ -0,0 +1,60 @@
+//! Detect and download Chrome / Chromium for embedded use.
+//!
+//! Bindings (`crates/py`, `crates/js`) call [`ensure_chrome`] which
+//! returns a path to a usable Chrome executable, downloading a pinned
+//! Chrome-for-Testing build into a platform cache directory if no system
+//! Chrome is available.
+//!
+//! See `docs/superpowers/specs/2026-05-01-bindings-design.md` §
+//! "Chrome auto-download" for the contract.
+
+#![cfg(feature = "chrome-fetch")]
+
+mod detect;
+mod download;
+mod cache;
+
+pub use detect::detect_system_chrome;
+pub use download::{download_chrome, ChromeFetchError};
+pub use cache::{cache_dir, cached_chrome};
+
+use std::path::PathBuf;
+
+/// Pinned Chrome-for-Testing version. Bumped per Folio release.
+/// Single source of truth: `bindings/CHROME_VERSION` mirrors this string.
+pub const CHROME_VERSION: &str = include_str!("../../../../bindings/CHROME_VERSION");
+
+/// Options controlling [`ensure_chrome`].
+#[derive(Debug, Clone)]
+pub struct EnsureOptions {
+ /// Explicit path to a Chrome executable; skips all detection if set.
+ pub explicit: Option,
+ /// Override the platform cache directory used for downloaded binaries.
+ pub cache_dir: Option,
+ /// When `true`, download Chrome automatically if no system Chrome is found.
+ pub auto_download: bool,
+}
+
+impl Default for EnsureOptions {
+ fn default() -> Self {
+ Self { explicit: None, cache_dir: None, auto_download: true }
+ }
+}
+
+/// Returns a path to a usable Chrome.
+pub async fn ensure_chrome(opts: &EnsureOptions) -> Result {
+ if let Some(p) = &opts.explicit {
+ return Ok(p.clone());
+ }
+ if let Some(p) = detect_system_chrome() {
+ return Ok(p);
+ }
+ let cache = opts.cache_dir.clone().unwrap_or_else(cache_dir);
+ if let Some(p) = cached_chrome(&cache, CHROME_VERSION.trim()) {
+ return Ok(p);
+ }
+ if !opts.auto_download {
+ return Err(ChromeFetchError::NotFoundAndDownloadDisabled);
+ }
+ download_chrome(&cache, CHROME_VERSION.trim()).await
+}
diff --git a/crates/engine/src/chromium/launch.rs b/crates/engine/src/chromium/launch.rs
index c952797..8337707 100644
--- a/crates/engine/src/chromium/launch.rs
+++ b/crates/engine/src/chromium/launch.rs
@@ -118,7 +118,18 @@ pub(crate) async fn launch_with(config: BrowserConfig) -> EngineResult EngineResult EngineResult EngineResult {
let mut builder = CxBrowserConfig::builder()
.chrome_executable(executable)
- .request_timeout(config.timeout);
+ .request_timeout(config.timeout)
+ .user_data_dir(user_data_dir);
if !config.headless {
builder = builder.with_head();
diff --git a/crates/engine/src/chromium/mod.rs b/crates/engine/src/chromium/mod.rs
index bae94a8..e70dfbf 100644
--- a/crates/engine/src/chromium/mod.rs
+++ b/crates/engine/src/chromium/mod.rs
@@ -74,6 +74,12 @@ pub(crate) struct Inner {
/// OS process ID of the Chrome child process; used for best-effort
/// synchronous kill in [`Inner::Drop`] when shutdown was skipped.
pub(crate) chrome_pid: AtomicU32,
+ /// Per-engine user-data-dir. Owned so the directory (and its
+ /// SingletonLock) is removed when the engine is dropped — concurrent
+ /// or rapid sequential launches no longer collide on the chromiumoxide
+ /// default `/tmp/chromiumoxide-runner`.
+ #[allow(dead_code)]
+ pub(crate) user_data_dir: Option,
}
/// Per-render context describing user-agent, headers, cookies, and
@@ -101,6 +107,17 @@ pub struct RequestContext {
pub fail_on_console_exceptions: bool,
/// If true, fail the render if any resource fails to load (network error).
pub fail_on_resource_loading_failed: bool,
+ /// If true, skip the engine's `networkIdle` race during navigation —
+ /// proceed once `load` fires. Mirrors Gotenberg's
+ /// `skipNetworkIdleEvent` / `skipNetworkAlmostIdleEvent` flags
+ /// (Chrome does not distinguish the two via CDP, so a single bool
+ /// covers both).
+ pub skip_network_idle: bool,
+ /// Resource URLs whose host contains any of these substrings are
+ /// exempt from [`Self::fail_on_resource_status`] checks. Match is
+ /// case-insensitive substring on the URL host. Empty (the default)
+ /// means no domains are ignored.
+ pub ignore_resource_status_domains: Vec,
}
/// A single cookie installed on the page before a render.
@@ -403,6 +420,7 @@ impl ChromiumEngine {
handler_task: JoinHandle<()>,
config: BrowserConfig,
chrome_pid: Option,
+ user_data_dir: Option,
) -> Self {
Self {
inner: Arc::new(Inner {
@@ -411,6 +429,7 @@ impl ChromiumEngine {
handler_task: std::sync::Mutex::new(Some(handler_task)),
config,
chrome_pid: AtomicU32::new(chrome_pid.unwrap_or(0)),
+ user_data_dir,
}),
}
}
@@ -453,6 +472,7 @@ mod assertions {
assert!(ctx.fail_on_resource_status.is_empty());
assert!(!ctx.fail_on_console_exceptions);
assert!(!ctx.fail_on_resource_loading_failed);
+ assert!(!ctx.skip_network_idle);
}
#[test]
diff --git a/crates/engine/src/chromium/render.rs b/crates/engine/src/chromium/render.rs
index 3f82a26..d2c50c5 100644
--- a/crates/engine/src/chromium/render.rs
+++ b/crates/engine/src/chromium/render.rs
@@ -143,7 +143,14 @@ async fn render_url_on(
let resource_status = if request.fail_on_resource_status.is_empty() {
None
} else {
- Some(spawn_resource_status_capture(page, &request.fail_on_resource_status).await?)
+ Some(
+ spawn_resource_status_capture(
+ page,
+ &request.fail_on_resource_status,
+ &request.ignore_resource_status_domains,
+ )
+ .await?,
+ )
};
let console_exceptions = if request.fail_on_console_exceptions {
@@ -160,7 +167,12 @@ async fn render_url_on(
// Navigate with lifecycle event waits.
debug!("render_url_on: navigating to {}" , url);
- navigate_with_lifecycle(page, url, engine.inner().config.network_idle_timeout)
+ let network_idle_timeout = if request.skip_network_idle {
+ None
+ } else {
+ engine.inner().config.network_idle_timeout
+ };
+ navigate_with_lifecycle(page, url, network_idle_timeout)
.await
.map_err(|e| navigation_error(url, e))?;
debug!("render_url_on: navigation complete");
@@ -544,12 +556,14 @@ async fn spawn_console_exception_capture(
async fn spawn_resource_status_capture(
page: &Page,
fail_statuses: &[u16],
+ ignore_domains: &[String],
) -> EngineResult<(Arc>>, JoinHandle<()>)> {
let mut events = page
.event_listener::()
.await
.map_err(|e| EngineError::Cdp(e.to_string()))?;
let fail_set: HashSet = fail_statuses.iter().copied().collect();
+ let ignore_lower: Vec = ignore_domains.iter().map(|d| d.to_lowercase()).collect();
let errors: Arc>> = Arc::new(Mutex::new(Vec::new()));
let writer = errors.clone();
let task = tokio::spawn(async move {
@@ -557,7 +571,9 @@ async fn spawn_resource_status_capture(
// Only check resources, not main document.
if !matches!(ev.r#type, ResourceType::Document) {
let status = ev.response.status as u16;
- if fail_set.contains(&status) {
+ if fail_set.contains(&status)
+ && !url_host_matches_any(&ev.response.url, &ignore_lower)
+ {
let msg = format!("{}: {}", ev.response.url, status);
if let Ok(mut g) = writer.lock() {
g.push(msg);
@@ -637,3 +653,78 @@ async fn wait_lifecycle_event(
debug!("wait_lifecycle_event: stream ended without {}", event_name);
Ok(())
}
+
+/// Return true if the URL's host contains any of the given lowercase
+/// substrings. URLs that fail to parse never match — they are not
+/// silently ignored from the fail-on-resource-status check.
+fn url_host_matches_any(url: &str, ignore_lower: &[String]) -> bool {
+ if ignore_lower.is_empty() {
+ return false;
+ }
+ // Cheap host extraction without pulling in the `url` crate: take the
+ // segment between `://` and the next `/`, `?`, or `#`. Strip
+ // `userinfo@` and `:port`.
+ let after_scheme = match url.find("://") {
+ Some(i) => &url[i + 3..],
+ None => return false,
+ };
+ let host_with_userinfo = after_scheme
+ .split(|c: char| c == '/' || c == '?' || c == '#')
+ .next()
+ .unwrap_or("");
+ let host_with_port = match host_with_userinfo.rfind('@') {
+ Some(i) => &host_with_userinfo[i + 1..],
+ None => host_with_userinfo,
+ };
+ let host = match host_with_port.rfind(':') {
+ Some(i) => &host_with_port[..i],
+ None => host_with_port,
+ };
+ let host_lower = host.to_lowercase();
+ ignore_lower.iter().any(|d| host_lower.contains(d))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::url_host_matches_any;
+
+ #[test]
+ fn host_match_substring_case_insensitive() {
+ let ignore = vec!["googleapis.com".to_string()];
+ assert!(url_host_matches_any(
+ "https://fonts.googleapis.com/css?family=Inter",
+ &ignore
+ ));
+ assert!(url_host_matches_any(
+ "HTTPS://Fonts.GoogleAPIS.com/x",
+ &ignore
+ ));
+ }
+
+ #[test]
+ fn host_no_match_when_outside_host() {
+ // The substring lives in the path, not the host — must NOT match.
+ let ignore = vec!["googleapis.com".to_string()];
+ assert!(!url_host_matches_any(
+ "https://example.com/path/googleapis.com/x",
+ &ignore
+ ));
+ }
+
+ #[test]
+ fn host_with_port_strips_correctly() {
+ let ignore = vec!["example.com".to_string()];
+ assert!(url_host_matches_any("http://example.com:8080/x", &ignore));
+ }
+
+ #[test]
+ fn empty_ignore_list_never_matches() {
+ assert!(!url_host_matches_any("https://example.com/", &[]));
+ }
+
+ #[test]
+ fn malformed_url_never_matches() {
+ let ignore = vec!["example.com".to_string()];
+ assert!(!url_host_matches_any("not-a-url", &ignore));
+ }
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 04364db..33cd8b1 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -16,6 +16,9 @@ pub mod chromium;
#[cfg(feature = "libreoffice")]
pub mod libreoffice;
+#[cfg(feature = "chrome-fetch")]
+pub mod chrome_fetch;
+
pub use bookmarks::{Bookmark, read_bookmarks, write_bookmarks, flatten_bookmarks};
pub use encrypt::{EncryptionAlgorithm, Permissions, encrypt_pdf, decrypt_pdf, is_encrypted, qpdf_available as encrypt_qpdf_available};
pub use pdfa::{PdfAProfile, convert_to_pdfa, ghostscript_available, qpdf_available};
diff --git a/crates/engine/src/libreoffice/convert.rs b/crates/engine/src/libreoffice/convert.rs
index f950fcd..dafa888 100644
--- a/crates/engine/src/libreoffice/convert.rs
+++ b/crates/engine/src/libreoffice/convert.rs
@@ -1,164 +1,385 @@
-//! Single-file `soffice --convert-to` invocation with isolated `UserInstallation`.
+//! XML-RPC conversion via a running unoserver process.
+//!
+//! unoserver 2.x exposes a `convert()` method over XML-RPC at its HTTP port.
+//! This module builds the minimal request, sends it, and decodes the response.
-use std::ffi::OsString;
-use std::path::{Path, PathBuf};
+use std::path::Path;
use std::time::Duration;
+use base64::Engine as _;
+use base64::engine::general_purpose::STANDARD as B64;
+
use crate::types::{EngineError, EngineResult};
use super::OfficeOptions;
use super::filter::for_extension;
-/// Run `soffice` once for `input`, returning the produced PDF bytes.
+/// Send `input` to unoserver for PDF conversion and return the PDF bytes.
///
-/// Caller is responsible for input-existence checks, validation, and
-/// concurrency limits — those live on [`super::LibreOfficeEngine`].
+/// `client` must be the shared `reqwest::Client` from `LibreOfficeEngine::Inner`.
+/// `port` is the localhost port unoserver is listening on.
pub(super) async fn run_convert(
- exe: &Path,
+ client: &reqwest::Client,
+ port: u16,
timeout: Duration,
input: &Path,
opts: &OfficeOptions,
) -> EngineResult> {
- // Per-call tempdir → UserInstallation directory + outdir. Drop cleans up.
- let tmp = tempfile::tempdir()?;
- let user_dir = tmp.path().join("uipfx");
- std::fs::create_dir_all(&user_dir)?;
- let outdir = tmp.path().join("out");
- std::fs::create_dir_all(&outdir)?;
-
- let convert_to = build_convert_to(input, opts);
- let user_url = path_to_file_url(&user_dir);
-
- let mut cmd = tokio::process::Command::new(exe);
- cmd.arg("--headless")
- .arg("--norestore")
- .arg("--nologo")
- .arg("--nodefault")
- .arg("--nofirststartwizard")
- .arg("--convert-to")
- .arg(&convert_to)
- .arg("--outdir")
- .arg(&outdir)
- .arg(format!("-env:UserInstallation={user_url}"))
- .arg(input)
- .kill_on_drop(true)
- .stdin(std::process::Stdio::null());
-
- let output = match tokio::time::timeout(timeout, cmd.output()).await {
- Err(_) => return Err(EngineError::Timeout(timeout)),
- Ok(Err(e)) => {
- return Err(EngineError::Internal(format!("soffice spawn failed: {e}")));
- }
- Ok(Ok(o)) => o,
- };
+ let file_bytes = tokio::fs::read(input).await.map_err(EngineError::Io)?;
+ let indata_b64 = B64.encode(&file_bytes);
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- let stdout = String::from_utf8_lossy(&output.stdout);
- let code = output
- .status
- .code()
- .map(|c| c.to_string())
- .unwrap_or_else(|| "signal".into());
- return Err(EngineError::Internal(format!(
- "soffice exit {code}: {}",
- pick_message(&stderr, &stdout)
- )));
- }
+ let ext = input
+ .extension()
+ .and_then(|s| s.to_str())
+ .unwrap_or_default();
+ let lo_filter = for_extension(ext);
+ // for_extension returns the soffice CLI format ("pdf:writer_pdf_Export").
+ // unoserver's convert() takes only the bare LO filter name ("writer_pdf_Export").
+ let filtername = lo_filter.split_once(':').map(|(_, name)| name);
+
+ let body = build_xmlrpc_request(&indata_b64, filtername, &opts.filter_options());
- // soffice writes .pdf into outdir.
- let stem = input
- .file_stem()
- .ok_or_else(|| EngineError::Internal("input path has no file stem".into()))?;
- let mut pdf_path: PathBuf = outdir.join(stem);
- pdf_path.set_extension("pdf");
+ let url = format!("http://127.0.0.1:{port}/");
- let bytes = std::fs::read(&pdf_path).map_err(|e| match e.kind() {
- std::io::ErrorKind::NotFound => {
- EngineError::Internal(format!("soffice produced no PDF at {}", pdf_path.display()))
+ let result = tokio::time::timeout(timeout, async {
+ let resp = client
+ .post(&url)
+ .header("Content-Type", "text/xml")
+ .body(body)
+ .send()
+ .await
+ .map_err(|e| EngineError::Internal(format!("unoserver request: {e}")))?;
+
+ let status = resp.status();
+ let text = resp
+ .text()
+ .await
+ .map_err(|e| EngineError::Internal(format!("unoserver read body: {e}")))?;
+
+ // unoserver returns 200 even for XML-RPC faults, so parse the body.
+ if !status.is_success() {
+ return Err(EngineError::Internal(format!(
+ "unoserver HTTP {status}: {text}"
+ )));
}
- _ => EngineError::Io(e),
- })?;
- drop(tmp); // Explicit cleanup point; Drop would do it anyway.
- Ok(bytes)
+ parse_xmlrpc_response(&text)
+ .map_err(|e| EngineError::Internal(format!("unoserver fault: {e}")))
+ })
+ .await
+ .map_err(|_| EngineError::Timeout(timeout))??;
+
+ Ok(result)
}
-fn build_convert_to(input: &Path, opts: &OfficeOptions) -> OsString {
- let ext = input
- .extension()
- .and_then(|s| s.to_str())
- .unwrap_or_default();
- let base = for_extension(ext);
- match opts.filter_blob() {
- Some(blob) => format!("{base}:{blob}").into(),
- None => OsString::from(base),
- }
+// ── XML-RPC helpers ──────────────────────────────────────────────────────────
+
+fn build_xmlrpc_request(
+ indata_b64: &str,
+ filtername: Option<&str>,
+ filteroptions: &[String],
+) -> String {
+ let filter_param = match filtername {
+ Some(f) => format!(
+ "{}",
+ xml_escape(f)
+ ),
+ None => "".to_string(),
+ };
+ // filter_options must be an array (even empty); unoserver iterates over
+ // it and parses each element as `Name=Value`.
+ let items: String = filteroptions
+ .iter()
+ .map(|s| format!("{}", xml_escape(s)))
+ .collect();
+ let filteroptions_param =
+ format!("{items}");
+ // convert_to must be "pdf" when outpath is nil, otherwise unoserver
+ // cannot determine the output format and raises a TypeError.
+ format!(
+ "\n\
+ \n\
+ convert\n\
+ \n\
+ \n\
+ {indata_b64}\n\
+ \n\
+ pdf\n\
+ {filter_param}\n\
+ {filteroptions_param}\n\
+ 0\n\
+ \n\
+ "
+ )
}
-/// Encode a filesystem path as a `file://` URL suitable for the
-/// `-env:UserInstallation=...` argument. Best-effort lossy on non-UTF-8
-/// paths (LibreOffice itself does not accept non-UTF-8 here).
-fn path_to_file_url(p: &Path) -> String {
- let s = p.to_string_lossy();
- if cfg!(windows) {
- let s = s.replace('\\', "/");
- format!("file:///{s}")
- } else {
- format!("file://{s}")
+fn parse_xmlrpc_response(body: &str) -> Result, String> {
+ if body.contains("") {
+ let msg = extract_between(body, "", "").unwrap_or("unknown fault");
+ return Err(msg.to_string());
}
+ let b64 = extract_between(body, "", "")
+ .ok_or_else(|| format!("no element in response: {body}"))?;
+ // Python's base64 module wraps encoded output at 76 chars with newlines.
+ // Strip all whitespace before decoding so embedded newlines don't break the decoder.
+ let b64_clean: String = b64.chars().filter(|c| !c.is_ascii_whitespace()).collect();
+ B64.decode(b64_clean.as_bytes())
+ .map_err(|e| format!("base64 decode failed: {e}"))
+}
+
+fn extract_between<'a>(haystack: &'a str, open: &str, close: &str) -> Option<&'a str> {
+ let start = haystack.find(open)? + open.len();
+ let end = haystack[start..].find(close)? + start;
+ Some(&haystack[start..end])
}
-fn pick_message<'a>(stderr: &'a str, stdout: &'a str) -> &'a str {
- let s = stderr.trim();
- if s.is_empty() { stdout.trim() } else { s }
+fn xml_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
}
+// ── Tests ────────────────────────────────────────────────────────────────────
+
#[cfg(test)]
mod tests {
use super::*;
- use crate::types::PageRanges;
+ use axum::body::Body;
+ use axum::http::StatusCode;
+ use axum::response::Response;
+ use axum::routing::post;
+ use axum::Router;
+ use tempfile::Builder;
- #[test]
- fn build_convert_to_no_blob_for_default() {
- let p = PathBuf::from("/tmp/sample.docx");
- let out = build_convert_to(&p, &OfficeOptions::default());
- assert_eq!(out, OsString::from("pdf:writer_pdf_Export"));
+ fn xmlrpc_ok_response(pdf: &[u8]) -> String {
+ format!(
+ "\n\
+ \n\
+ \n\
+ {}\n\
+ \n\
+ ",
+ B64.encode(pdf)
+ )
+ }
+
+ fn xmlrpc_fault_response(msg: &str) -> String {
+ format!(
+ "\n\
+ \n\
+ \n\
+ faultCode1\n\
+ faultString{msg}\n\
+ \n\
+ "
+ )
+ }
+
+ async fn start_mock_unoserver(
+ handler: impl Fn(String) -> Response + Send + Sync + Clone + 'static,
+ ) -> u16 {
+ let app = Router::new().route(
+ "/",
+ post(move |body: String| {
+ let h = handler.clone();
+ async move { h(body) }
+ }),
+ );
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let port = listener.local_addr().unwrap().port();
+ tokio::spawn(async move {
+ axum::serve(listener, app).await.unwrap();
+ });
+ port
+ }
+
+ fn fake_docx() -> tempfile::NamedTempFile {
+ let f = Builder::new().suffix(".docx").tempfile().unwrap();
+ std::fs::write(f.path(), b"PK fake docx content").unwrap();
+ f
+ }
+
+ #[tokio::test]
+ async fn run_convert_returns_pdf_bytes_on_success() {
+ let port = start_mock_unoserver(|_body| {
+ Response::builder()
+ .status(StatusCode::OK)
+ .header("Content-Type", "text/xml")
+ .body(Body::from(xmlrpc_ok_response(b"%PDF-1.4 fake")))
+ .unwrap()
+ })
+ .await;
+
+ let tmp = fake_docx();
+ let client = reqwest::Client::new();
+ let result = run_convert(
+ &client,
+ port,
+ Duration::from_secs(5),
+ tmp.path(),
+ &OfficeOptions::default(),
+ )
+ .await;
+ assert!(result.is_ok(), "{result:?}");
+ assert_eq!(result.unwrap(), b"%PDF-1.4 fake");
+ }
+
+ #[tokio::test]
+ async fn run_convert_maps_xmlrpc_fault_to_engine_error() {
+ let port = start_mock_unoserver(|_body| {
+ Response::builder()
+ .status(StatusCode::OK)
+ .header("Content-Type", "text/xml")
+ .body(Body::from(xmlrpc_fault_response("unsupported format")))
+ .unwrap()
+ })
+ .await;
+
+ let tmp = fake_docx();
+ let client = reqwest::Client::new();
+ let result = run_convert(
+ &client,
+ port,
+ Duration::from_secs(5),
+ tmp.path(),
+ &OfficeOptions::default(),
+ )
+ .await;
+ assert!(matches!(result, Err(EngineError::Internal(_))), "{result:?}");
+ let msg = result.unwrap_err().to_string();
+ assert!(msg.contains("unsupported format"), "{msg}");
+ }
+
+ #[tokio::test]
+ async fn run_convert_returns_error_when_nothing_listening() {
+ let client = reqwest::Client::new();
+ let tmp = fake_docx();
+ // Port 19877 — nothing is listening here.
+ let result =
+ run_convert(&client, 19877, Duration::from_millis(200), tmp.path(), &OfficeOptions::default()).await;
+ assert!(result.is_err(), "expected error when nothing listening");
+ }
+
+ #[tokio::test]
+ async fn run_convert_sends_correct_filtername_for_docx() {
+ use std::sync::{Arc, Mutex};
+ let captured: Arc>> = Arc::new(Mutex::new(None));
+ let cap2 = Arc::clone(&captured);
+
+ let port = start_mock_unoserver(move |body| {
+ // The XML has two params: first is convert_to="pdf",
+ // second (after "pdf") is the filtername. Extract the second one.
+ let after_pdf = body
+ .find("pdf")
+ .and_then(|pos| body.get(pos + "pdf".len()..));
+ if let Some(rest) = after_pdf {
+ if let Some(v) = extract_between(rest, "", "") {
+ *cap2.lock().unwrap() = Some(v.to_string());
+ }
+ }
+ Response::builder()
+ .status(StatusCode::OK)
+ .header("Content-Type", "text/xml")
+ .body(Body::from(xmlrpc_ok_response(b"%PDF-1.4")))
+ .unwrap()
+ })
+ .await;
+
+ let tmp = Builder::new().suffix(".docx").tempfile().unwrap();
+ std::fs::write(tmp.path(), b"PK fake docx").unwrap();
+ let client = reqwest::Client::new();
+ let result = run_convert(
+ &client,
+ port,
+ Duration::from_secs(5),
+ tmp.path(),
+ &OfficeOptions::default(),
+ )
+ .await;
+ assert!(result.is_ok(), "{result:?}");
+
+ let name = captured.lock().unwrap().clone();
+ assert_eq!(
+ name.as_deref(),
+ Some("writer_pdf_Export"),
+ "expected 'writer_pdf_Export', got: {name:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_convert_omits_filtername_for_unknown_extension() {
+ use std::sync::{Arc, Mutex};
+ let saw_filter: Arc> = Arc::new(Mutex::new(false));
+ let saw2 = Arc::clone(&saw_filter);
+
+ let port = start_mock_unoserver(move |body| {
+ // The request always has pdf for convert_to.
+ // If there's an additional after "pdf", a filtername was sent.
+ let after_pdf = body
+ .find("pdf")
+ .and_then(|pos| body.get(pos + "pdf".len()..));
+ if after_pdf.map_or(false, |rest| rest.contains("")) {
+ *saw2.lock().unwrap() = true;
+ }
+ Response::builder()
+ .status(StatusCode::OK)
+ .header("Content-Type", "text/xml")
+ .body(Body::from(xmlrpc_ok_response(b"%PDF-1.4")))
+ .unwrap()
+ })
+ .await;
+
+ // ".zzz" is an unknown extension — for_extension returns "pdf" (no colon)
+ let tmp = Builder::new().suffix(".zzz").tempfile().unwrap();
+ std::fs::write(tmp.path(), b"unknown content").unwrap();
+
+ let client = reqwest::Client::new();
+ let result = run_convert(
+ &client,
+ port,
+ Duration::from_secs(5),
+ tmp.path(),
+ &OfficeOptions::default(),
+ )
+ .await;
+ assert!(result.is_ok(), "{result:?}");
+ assert!(
+ !*saw_filter.lock().unwrap(),
+ "filtername should not be sent for unknown extensions"
+ );
}
#[test]
- fn build_convert_to_appends_blob_when_options_set() {
- let p = PathBuf::from("/tmp/sample.docx");
- let opts = OfficeOptions {
- page_ranges: Some(PageRanges::parse("1-3").expect("parse")),
- ..Default::default()
- };
- let out = build_convert_to(&p, &opts);
- let s = out.into_string().expect("utf8");
- assert!(s.starts_with("pdf:writer_pdf_Export:"), "got {s}");
- assert!(s.contains("\"PageRange\""), "got {s}");
- assert!(s.contains("\"1-3\""), "got {s}");
+ fn build_xmlrpc_request_contains_filtername() {
+ let req = build_xmlrpc_request("DATA==", Some("writer_pdf_Export"), &[]);
+ assert!(req.contains("writer_pdf_Export"), "{req}");
+ assert!(req.contains("DATA=="), "{req}");
}
#[test]
- fn build_convert_to_unknown_ext_uses_pdf_fallback() {
- let p = PathBuf::from("/tmp/sample.weird");
- let out = build_convert_to(&p, &OfficeOptions::default());
- assert_eq!(out, OsString::from("pdf"));
+ fn build_xmlrpc_request_without_filtername_uses_nil() {
+ let req = build_xmlrpc_request("DATA==", None, &[]);
+ // Should contain exactly one element: the convert_to="pdf".
+ // No extra for filtername.
+ let count = req.matches("").count();
+ assert_eq!(count, 1, "expected only convert_to string, got {count}: {req}");
+ assert!(req.contains("pdf"), "{req}");
}
#[test]
- fn path_to_file_url_unix_style() {
- if cfg!(not(windows)) {
- let url = path_to_file_url(Path::new("/tmp/foo bar"));
- assert_eq!(url, "file:///tmp/foo bar");
- }
+ fn parse_xmlrpc_response_decodes_pdf() {
+ let body = xmlrpc_ok_response(b"%PDF-1.4 test");
+ let result = parse_xmlrpc_response(&body).unwrap();
+ assert_eq!(result, b"%PDF-1.4 test");
}
#[test]
- fn pick_message_prefers_stderr_when_present() {
- assert_eq!(pick_message("err", "out"), "err");
- assert_eq!(pick_message(" ", "out"), "out");
- assert_eq!(pick_message("", ""), "");
+ fn parse_xmlrpc_response_returns_err_on_fault() {
+ let body = xmlrpc_fault_response("conversion failed");
+ let result = parse_xmlrpc_response(&body);
+ assert!(result.is_err());
+ assert!(result.unwrap_err().contains("conversion failed"));
}
}
diff --git a/crates/engine/src/libreoffice/discover.rs b/crates/engine/src/libreoffice/discover.rs
deleted file mode 100644
index 9fdddf5..0000000
--- a/crates/engine/src/libreoffice/discover.rs
+++ /dev/null
@@ -1,198 +0,0 @@
-//! `soffice` executable discovery and version probing.
-//!
-//! The discovery search order is documented in spec 12 § *Executable
-//! discovery*. The probe step runs `soffice --headless --version` under a
-//! caller-supplied timeout to confirm the binary actually starts.
-
-use std::path::{Path, PathBuf};
-use std::time::Duration;
-
-use crate::types::{EngineError, EngineResult};
-
-/// Public entry point: locate `soffice` using env var, `$PATH`, and platform defaults.
-pub(super) fn find_soffice() -> EngineResult {
- find_in(&candidate_paths())
-}
-
-/// Search the supplied list of candidate paths in order, returning the first
-/// path that exists and is executable.
-///
-/// Factored out of [`find_soffice`] so unit tests can exercise the lookup
-/// without mutating the process environment (which is `unsafe` on edition 2024).
-pub(super) fn find_in(searched: &[PathBuf]) -> EngineResult {
- for path in searched {
- if path.exists() && is_executable(path) {
- return Ok(path.clone());
- }
- }
- Err(EngineError::Internal(format!(
- "LibreOffice not found: searched {searched:?}"
- )))
-}
-
-/// Build the platform-specific list of candidate locations.
-pub(super) fn candidate_paths() -> Vec {
- let mut out: Vec = Vec::new();
-
- // 1. $LIBREOFFICE_PATH (env var)
- if let Some(p) = std::env::var_os("LIBREOFFICE_PATH") {
- out.push(PathBuf::from(p));
- }
-
- // 2. `which soffice`, `which libreoffice`
- if let Some(p) = which_in_path("soffice") {
- out.push(p);
- }
- if let Some(p) = which_in_path("libreoffice") {
- out.push(p);
- }
-
- // 3-5. Platform defaults.
- #[cfg(target_os = "macos")]
- {
- out.push(PathBuf::from(
- "/Applications/LibreOffice.app/Contents/MacOS/soffice",
- ));
- // Homebrew cask `--appdir=~/Applications` install location.
- if let Some(home) = std::env::var_os("HOME") {
- let mut p = PathBuf::from(home);
- p.push("Applications/LibreOffice.app/Contents/MacOS/soffice");
- out.push(p);
- }
- }
- #[cfg(target_os = "linux")]
- {
- out.push(PathBuf::from("/usr/bin/soffice"));
- out.push(PathBuf::from("/usr/bin/libreoffice"));
- out.push(PathBuf::from("/usr/lib/libreoffice/program/soffice"));
- out.push(PathBuf::from("/snap/bin/libreoffice"));
- out.push(PathBuf::from(
- "/var/lib/flatpak/exports/bin/org.libreoffice.LibreOffice",
- ));
- }
- #[cfg(target_os = "windows")]
- {
- out.push(PathBuf::from(
- r"C:\Program Files\LibreOffice\program\soffice.exe",
- ));
- out.push(PathBuf::from(
- r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
- ));
- }
-
- out
-}
-
-fn which_in_path(name: &str) -> Option {
- let path_var = std::env::var_os("PATH")?;
- for dir in std::env::split_paths(&path_var) {
- let candidate = dir.join(name);
- if candidate.is_file() && is_executable(&candidate) {
- return Some(candidate);
- }
- #[cfg(target_os = "windows")]
- {
- let exe = dir.join(format!("{name}.exe"));
- if exe.is_file() {
- return Some(exe);
- }
- }
- }
- None
-}
-
-#[cfg(unix)]
-fn is_executable(p: &Path) -> bool {
- use std::os::unix::fs::PermissionsExt;
- p.metadata()
- .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
- .unwrap_or(false)
-}
-
-#[cfg(not(unix))]
-fn is_executable(p: &Path) -> bool {
- p.is_file()
-}
-
-/// Run `soffice --headless --version` under `timeout`. Empty stdout or a
-/// non-zero exit status is reported as `EngineError::Internal`.
-///
-/// Each call gets its own `UserInstallation` tempdir so concurrent probes
-/// (e.g. parallel test runs) never race for the default profile lock.
-pub(super) async fn probe(exe: &Path, timeout: Duration) -> EngineResult<()> {
- let tmp = tempfile::tempdir()?;
- let user_dir = tmp.path().join("probe");
- std::fs::create_dir_all(&user_dir)?;
- let user_url = if cfg!(windows) {
- format!("file:///{}", user_dir.to_string_lossy().replace('\\', "/"))
- } else {
- format!("file://{}", user_dir.to_string_lossy())
- };
-
- let mut cmd = tokio::process::Command::new(exe);
- cmd.arg("--headless")
- .arg("--norestore")
- .arg("--nologo")
- .arg("--nodefault")
- .arg("--nofirststartwizard")
- .arg("--version")
- .arg(format!("-env:UserInstallation={user_url}"))
- .kill_on_drop(true)
- .stdin(std::process::Stdio::null());
-
- let out = match tokio::time::timeout(timeout, cmd.output()).await {
- Err(_) => return Err(EngineError::Timeout(timeout)),
- Ok(Err(e)) => {
- return Err(EngineError::Internal(format!(
- "LibreOffice probe failed: {e}"
- )));
- }
- Ok(Ok(o)) => o,
- };
-
- if !out.status.success() {
- let stderr = String::from_utf8_lossy(&out.stderr);
- return Err(EngineError::Internal(format!(
- "LibreOffice probe failed (exit {:?}): {}",
- out.status.code(),
- stderr.trim()
- )));
- }
- if out.stdout.is_empty() {
- return Err(EngineError::Internal(
- "LibreOffice probe failed: empty stdout".into(),
- ));
- }
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn discover_returns_searched_list_when_missing() {
- let paths = vec![
- PathBuf::from("/nonexistent/__folio_no_soffice_a"),
- PathBuf::from("/nonexistent/__folio_no_soffice_b"),
- ];
- let err = find_in(&paths).expect_err("should fail for missing paths");
- let msg = format!("{err}");
- assert!(
- msg.contains("__folio_no_soffice_a"),
- "missing first path in: {msg}"
- );
- assert!(
- msg.contains("__folio_no_soffice_b"),
- "missing second path in: {msg}"
- );
- assert!(matches!(err, EngineError::Internal(_)));
- }
-
- #[test]
- fn candidate_paths_is_non_empty() {
- // At minimum each platform contributes at least one default location.
- let paths = candidate_paths();
- assert!(!paths.is_empty());
- }
-}
diff --git a/crates/engine/src/libreoffice/mod.rs b/crates/engine/src/libreoffice/mod.rs
index 63f4133..74d22d8 100644
--- a/crates/engine/src/libreoffice/mod.rs
+++ b/crates/engine/src/libreoffice/mod.rs
@@ -1,21 +1,20 @@
-//! `LibreOfficeEngine` — convert office documents to PDF via the `soffice`
-//! subprocess.
+//! `LibreOfficeEngine` — convert office documents to PDF via unoserver.
//!
-//! Implementation of `docs/specs/12-engine-libreoffice.md`. Each call spawns a
-//! short-lived `soffice --headless` child with its own isolated
-//! `UserInstallation` profile, making concurrent invocations safe.
+//! Implementation of `docs/specs/12-engine-libreoffice.md`. A persistent
+//! `unoserver` process is managed as a child, eliminating per-request
+//! soffice startup cost (~200–400ms).
pub mod filter;
mod convert;
-mod discover;
+mod unoserver;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
-use tokio::sync::Semaphore;
+use tokio::sync::{Mutex, Semaphore};
use tracing::{debug, info, instrument};
@@ -25,11 +24,11 @@ use crate::types::{EngineError, EngineResult, PageRanges};
// Engine
// ---------------------------------------------------------------------------
-/// Wrapper around the `soffice` binary. Cheap to clone (`Arc` inside).
+/// Wrapper around a persistent `unoserver` process. Cheap to clone (`Arc` inside).
///
/// # Example
///
-/// ```ignore
+/// ```no_run
/// use std::path::Path;
/// use engine::{LibreOfficeEngine, OfficeOptions};
///
@@ -48,7 +47,11 @@ pub struct LibreOfficeEngine {
}
struct Inner {
- exe: PathBuf,
+ unoserver: Mutex,
+ port: u16,
+ unoserver_ready_timeout: Duration,
+ executable: Option,
+ client: reqwest::Client,
timeout: Duration,
semaphore: Semaphore,
}
@@ -56,13 +59,12 @@ struct Inner {
/// Engine-wide configuration. Pass to [`LibreOfficeEngine::launch`].
#[derive(Debug, Clone)]
pub struct LibreOfficeConfig {
- /// Path to `soffice` (or `libreoffice`). `None` = autodiscover via
- /// `$LIBREOFFICE_PATH`, `$PATH`, and platform defaults.
+ /// Path to `soffice` passed to unoserver via `--executable`. `None` = unoserver
+ /// auto-discovers soffice.
pub executable: Option,
/// Per-conversion timeout. Default 120s.
pub timeout: Duration,
- /// Maximum concurrent subprocess invocations. Default
- /// [`std::thread::available_parallelism`].
+ /// Maximum concurrent conversions. Default [`std::thread::available_parallelism`].
pub max_concurrency: usize,
/// Use lazy initialization (start on first request).
/// Default: false (start eagerly at server startup).
@@ -70,6 +72,10 @@ pub struct LibreOfficeConfig {
/// Idle shutdown timeout - engine shuts down after this duration of no requests.
/// None means no idle shutdown. Default: None.
pub idle_shutdown_timeout: Option,
+ /// Port unoserver listens on. Default: 2003.
+ pub unoserver_port: u16,
+ /// Maximum time to wait for unoserver to be ready at startup. Default: 60s.
+ pub unoserver_ready_timeout: Duration,
}
impl Default for LibreOfficeConfig {
@@ -82,20 +88,13 @@ impl Default for LibreOfficeConfig {
.unwrap_or(4),
lazy_start: false,
idle_shutdown_timeout: None,
+ unoserver_port: 2003,
+ unoserver_ready_timeout: Duration::from_secs(60),
}
}
}
impl LibreOfficeEngine {
- /// Return a tracing span for this engine instance, tagged with
- /// `engine="libreoffice"`.
- pub fn logger(&self) -> tracing::Span {
- tracing::info_span!(
- "engine",
- engine = "libreoffice",
- )
- }
-
/// Discover `soffice` on `$PATH` and platform defaults using
/// [`LibreOfficeConfig::default`].
pub async fn discover() -> EngineResult {
@@ -104,36 +103,79 @@ impl LibreOfficeEngine {
/// Construct an engine with explicit configuration.
///
- /// If `config.executable` is `Some`, the path is required to exist;
- /// otherwise auto-discovery is performed. The chosen executable is then
- /// probed (`--headless --version`) before the engine is returned.
- #[instrument(skip(config), fields(executable = ?config.executable))]
+ /// Spawns a persistent `unoserver` process. The engine is returned once
+ /// unoserver is ready to accept connections.
pub async fn launch(config: LibreOfficeConfig) -> EngineResult {
- info!("Launching LibreOffice engine");
- let exe = match config.executable.as_ref() {
- Some(p) => {
- if !p.exists() {
- return Err(EngineError::Internal(format!(
- "LibreOffice not found: {}",
- p.display()
- )));
- }
- p.clone()
- }
- None => discover::find_soffice()?,
- };
+ info!(port = config.unoserver_port, "Launching LibreOffice engine");
- discover::probe(&exe, config.timeout).await?;
+ let unoserver = unoserver::UnoserverProcess::spawn(
+ config.unoserver_port,
+ config.unoserver_ready_timeout,
+ config.executable.as_deref(),
+ )
+ .await?;
+ // If config requested port 0, the spawn helper picked a free one.
+ let actual_port = unoserver.port();
let max = config.max_concurrency.max(1);
- info!(executable = %exe.display(), timeout = ?config.timeout, max_concurrency = max, "LibreOffice engine launched");
- Ok(Self {
- inner: Arc::new(Inner {
- exe,
- timeout: config.timeout,
- semaphore: Semaphore::new(max),
- }),
- })
+ let client = reqwest::Client::builder()
+ .tcp_keepalive(Some(Duration::from_secs(60)))
+ .pool_max_idle_per_host(1)
+ .build()
+ .map_err(|e| EngineError::Internal(format!("failed to build HTTP client: {e}")))?;
+
+ let inner = Arc::new(Inner {
+ unoserver: Mutex::new(unoserver),
+ port: actual_port,
+ unoserver_ready_timeout: config.unoserver_ready_timeout,
+ executable: config.executable,
+ client,
+ timeout: config.timeout,
+ semaphore: Semaphore::new(max),
+ });
+
+ // Background task: detect unoserver crashes and restart.
+ let inner_weak = Arc::downgrade(&inner);
+ tokio::spawn(async move {
+ 'monitor: loop {
+ tokio::time::sleep(Duration::from_secs(5)).await;
+ let Some(inner) = inner_weak.upgrade() else { break };
+
+ // try_wait() is non-blocking — it does not yield the lock for long.
+ let exited = inner.unoserver.lock().await.try_wait().ok().flatten();
+
+ if exited.is_some() {
+ tracing::warn!("unoserver exited unexpectedly, attempting restart");
+ for attempt in 0..3u32 {
+ tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
+ let Some(inner) = inner_weak.upgrade() else { break 'monitor };
+ match unoserver::UnoserverProcess::spawn(
+ inner.port,
+ inner.unoserver_ready_timeout,
+ inner.executable.as_deref(),
+ )
+ .await
+ {
+ Ok(new_proc) => {
+ *inner.unoserver.lock().await = new_proc;
+ tracing::info!("unoserver restarted");
+ break;
+ }
+ Err(e) if attempt < 2 => {
+ tracing::warn!(attempt = attempt + 1, error = %e, "unoserver restart failed");
+ }
+ Err(_) => {
+ tracing::error!("unoserver failed to restart after 3 attempts, conversions will fail");
+ break 'monitor;
+ }
+ }
+ }
+ }
+ }
+ });
+
+ info!(port = actual_port, timeout = ?config.timeout, max_concurrency = max, "LibreOffice engine launched");
+ Ok(Self { inner })
}
/// Convert one input file to PDF bytes.
@@ -144,7 +186,6 @@ impl LibreOfficeEngine {
/// `UserInstallation` directory.
#[instrument(skip_all, fields(input = %input.display()))]
pub async fn convert(&self, input: &Path, opts: &OfficeOptions) -> EngineResult> {
- let _span = self.logger();
opts.validate()?;
if !input.exists() {
return Err(EngineError::Io(std::io::Error::new(
@@ -160,7 +201,7 @@ impl LibreOfficeEngine {
.map_err(|e| EngineError::Internal(format!("semaphore closed: {e}")))?;
debug!("Starting LibreOffice conversion");
let start = std::time::Instant::now();
- let result = convert::run_convert(&self.inner.exe, self.inner.timeout, input, opts).await;
+ let result = convert::run_convert(&self.inner.client, self.inner.port, self.inner.timeout, input, opts).await;
let duration = start.elapsed();
match &result {
Ok(_) => info!(
@@ -226,19 +267,23 @@ impl LibreOfficeEngine {
Ok(out)
}
- /// Returns `true` iff `soffice --version` succeeds within a 30-second
- /// timeout (regardless of the engine's `config.timeout`).
+ /// Returns `true` iff unoserver responds to an HTTP GET within 5 seconds.
pub async fn healthy(&self) -> bool {
- discover::probe(&self.inner.exe, Duration::from_secs(30))
- .await
- .is_ok()
+ let url = format!("http://127.0.0.1:{}/", self.inner.port);
+ tokio::time::timeout(
+ Duration::from_secs(5),
+ self.inner.client.get(&url).send(),
+ )
+ .await
+ .map(|r| r.is_ok())
+ .unwrap_or(false)
}
}
impl std::fmt::Debug for LibreOfficeEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LibreOfficeEngine")
- .field("exe", &self.inner.exe)
+ .field("port", &self.inner.port)
.field("timeout", &self.inner.timeout)
.finish()
}
@@ -273,6 +318,7 @@ pub struct OfficeOptions {
/// Export bookmarks as Named Destinations.
pub export_bookmarks_to_pdf_destination: bool,
/// Update document indexes before conversion.
+ /// Note: not currently passed to unoserver (unoserver has no direct API for this).
pub update_indexes: bool,
// --- Form Fields & Placeholders ---
@@ -437,14 +483,15 @@ impl OfficeOptions {
Ok(())
}
- /// Build the LibreOffice filter-options blob (the `:{...}` suffix on
- /// `--convert-to`). Returns `None` if no fields are set, in which case
- /// the bare exporter (e.g. `pdf:writer_pdf_Export`) is used unmodified.
- pub(crate) fn filter_blob(&self) -> Option {
- let mut map = serde_json::Map::new();
+ /// Build the unoserver `filter_options` array. Each entry is a
+ /// `Name=Value` string; unoserver parses these with `split('=', 1)`
+ /// and infers the value type (`true`/`false` → bool, digits → int,
+ /// everything else → string).
+ pub(crate) fn filter_options(&self) -> Vec {
+ let mut out: Vec = Vec::new();
if let Some(pr) = &self.page_ranges {
- map.insert("PageRange".into(), entry_str(&pr.to_string()));
+ out.push(format!("PageRange={}", pr));
}
if let Some(prof) = self.pdf_a {
let v: i64 = match prof {
@@ -452,167 +499,145 @@ impl OfficeOptions {
PdfAProfile::A2B => 2,
PdfAProfile::A3B => 3,
};
- map.insert("SelectPdfVersion".into(), entry_long(v));
+ out.push(format!("SelectPdfVersion={v}"));
}
if self.pdf_ua {
- map.insert("PDFUACompliance".into(), entry_bool(true));
+ out.push("PDFUACompliance=true".into());
}
if let Some(q) = self.quality {
- map.insert("Quality".into(), entry_long(i64::from(q)));
+ out.push(format!("Quality={q}"));
}
if let Some(r) = self.max_image_resolution {
- map.insert("MaxImageResolution".into(), entry_long(i64::from(r)));
+ out.push(format!("MaxImageResolution={r}"));
}
if self.landscape {
- map.insert("IsLandscape".into(), entry_bool(true));
+ out.push("IsLandscape=true".into());
}
- // Bookmarks
if self.export_bookmarks {
- map.insert("ExportBookmarks".into(), entry_bool(true));
+ out.push("ExportBookmarks=true".into());
}
if self.export_bookmarks_to_pdf_destination {
- map.insert("ExportBookmarksToPDFDestination".into(), entry_bool(true));
+ out.push("ExportBookmarksToPDFDestination=true".into());
}
- // Form Fields
if self.export_form_fields {
- map.insert("ExportFormFields".into(), entry_bool(true));
+ out.push("ExportFormFields=true".into());
}
if self.allow_duplicate_field_names {
- map.insert("AllowDuplicateFieldNames".into(), entry_bool(true));
+ out.push("AllowDuplicateFieldNames=true".into());
}
if self.export_placeholders {
- map.insert("ExportPlaceholders".into(), entry_bool(true));
+ out.push("ExportPlaceholders=true".into());
}
- // Notes
if self.export_notes {
- map.insert("ExportNotes".into(), entry_bool(true));
+ out.push("ExportNotes=true".into());
}
if self.export_notes_pages {
- map.insert("ExportNotesPages".into(), entry_bool(true));
+ out.push("ExportNotesPages=true".into());
}
if self.export_only_notes_pages {
- map.insert("ExportOnlyNotesPages".into(), entry_bool(true));
+ out.push("ExportOnlyNotesPages=true".into());
}
if self.export_notes_in_margin {
- map.insert("ExportNotesInMargin".into(), entry_bool(true));
+ out.push("ExportNotesInMargin=true".into());
}
- // Advanced
if self.convert_ooo_target_to_pdf_target {
- map.insert("ConvertOOoTargetToPDFTarget".into(), entry_bool(true));
+ out.push("ConvertOOoTargetToPDFTarget=true".into());
}
if self.export_links_relative_fsys {
- map.insert("ExportLinksRelativeFsys".into(), entry_bool(true));
+ out.push("ExportLinksRelativeFsys=true".into());
}
if self.export_hidden_slides {
- map.insert("ExportHiddenSlides".into(), entry_bool(true));
+ out.push("ExportHiddenSlides=true".into());
}
if self.skip_empty_pages {
- map.insert("IsSkipEmptyPages".into(), entry_bool(true));
+ out.push("IsSkipEmptyPages=true".into());
}
if self.add_original_document_as_stream {
- map.insert("IsAddStream".into(), entry_bool(true));
+ out.push("IsAddStream=true".into());
}
if self.single_page_sheets {
- map.insert("SinglePageSheets".into(), entry_bool(true));
+ out.push("SinglePageSheets=true".into());
}
if self.lossless_image_compression {
- map.insert("UseLosslessCompression".into(), entry_bool(true));
+ out.push("UseLosslessCompression=true".into());
}
if self.reduce_image_resolution {
- map.insert("ReduceImageResolution".into(), entry_bool(true));
+ out.push("ReduceImageResolution=true".into());
}
- // Native Watermarks
if let Some(ref text) = self.native_watermark_text {
- map.insert("Watermark".into(), entry_str(text));
+ out.push(format!("Watermark={text}"));
}
if let Some(color) = self.native_watermark_color {
- map.insert("WatermarkColor".into(), entry_long(i64::from(color)));
+ out.push(format!("WatermarkColor={color}"));
}
if let Some(h) = self.native_watermark_font_height {
- map.insert("WatermarkFontHeight".into(), entry_long(i64::from(h)));
+ out.push(format!("WatermarkFontHeight={h}"));
}
if let Some(angle) = self.native_watermark_rotate_angle {
- map.insert("WatermarkRotateAngle".into(), entry_long(i64::from(angle)));
+ out.push(format!("WatermarkRotateAngle={angle}"));
}
if let Some(ref name) = self.native_watermark_font_name {
- map.insert("WatermarkFontName".into(), entry_str(name));
+ out.push(format!("WatermarkFontName={name}"));
}
if let Some(ref text) = self.native_tiled_watermark_text {
- map.insert("TiledWatermark".into(), entry_str(text));
+ out.push(format!("TiledWatermark={text}"));
}
- // Viewer Preferences
if let Some(v) = self.initial_view {
- map.insert("InitialView".into(), entry_long(i64::from(v)));
+ out.push(format!("InitialView={v}"));
}
if let Some(v) = self.initial_page {
- map.insert("InitialPage".into(), entry_long(i64::from(v)));
+ out.push(format!("InitialPage={v}"));
}
if let Some(v) = self.magnification {
- map.insert("Magnification".into(), entry_long(i64::from(v)));
+ out.push(format!("Magnification={v}"));
}
if let Some(v) = self.zoom {
- map.insert("Zoom".into(), entry_long(i64::from(v)));
+ out.push(format!("Zoom={v}"));
}
if let Some(v) = self.page_layout {
- map.insert("PageLayout".into(), entry_long(i64::from(v)));
+ out.push(format!("PageLayout={v}"));
}
if self.first_page_on_left {
- map.insert("FirstPageOnLeft".into(), entry_bool(true));
+ out.push("FirstPageOnLeft=true".into());
}
if self.resize_window_to_initial_page {
- map.insert("ResizeWindowToInitialPage".into(), entry_bool(true));
+ out.push("ResizeWindowToInitialPage=true".into());
}
if self.center_window {
- map.insert("CenterWindow".into(), entry_bool(true));
+ out.push("CenterWindow=true".into());
}
if self.open_in_full_screen_mode {
- map.insert("OpenInFullScreenMode".into(), entry_bool(true));
+ out.push("OpenInFullScreenMode=true".into());
}
if self.display_pdf_document_title {
- map.insert("DisplayPDFDocumentTitle".into(), entry_bool(true));
+ out.push("DisplayPDFDocumentTitle=true".into());
}
if self.hide_viewer_menubar {
- map.insert("HideViewerMenubar".into(), entry_bool(true));
+ out.push("HideViewerMenubar=true".into());
}
if self.hide_viewer_toolbar {
- map.insert("HideViewerToolbar".into(), entry_bool(true));
+ out.push("HideViewerToolbar=true".into());
}
if self.hide_viewer_window_controls {
- map.insert("HideViewerWindowControls".into(), entry_bool(true));
+ out.push("HideViewerWindowControls=true".into());
}
if self.use_transition_effects {
- map.insert("UseTransitionEffects".into(), entry_bool(true));
+ out.push("UseTransitionEffects=true".into());
}
if let Some(v) = self.open_bookmark_levels {
- map.insert("OpenBookmarkLevels".into(), entry_long(i64::from(v)));
+ out.push(format!("OpenBookmarkLevels={v}"));
}
- if map.is_empty() {
- None
- } else {
- Some(serde_json::Value::Object(map).to_string())
- }
+ out
}
}
-fn entry_str(v: &str) -> serde_json::Value {
- serde_json::json!({ "type": "string", "value": v })
-}
-
-fn entry_long(v: i64) -> serde_json::Value {
- serde_json::json!({ "type": "long", "value": v })
-}
-
-fn entry_bool(v: bool) -> serde_json::Value {
- serde_json::json!({ "type": "boolean", "value": v })
-}
-
/// PDF/A export profile.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
@@ -648,72 +673,64 @@ mod tests {
assert!(c.executable.is_none());
assert_eq!(c.timeout, Duration::from_secs(120));
assert!(c.max_concurrency >= 1);
+ assert_eq!(c.unoserver_port, 2003);
+ assert_eq!(c.unoserver_ready_timeout, Duration::from_secs(60));
}
#[test]
- fn office_options_default_emits_no_filter_blob() {
- assert!(OfficeOptions::default().filter_blob().is_none());
+ fn office_options_default_emits_no_filter_options() {
+ assert!(OfficeOptions::default().filter_options().is_empty());
}
#[test]
- fn office_options_with_page_ranges_emits_pagerange_key() {
+ fn office_options_with_page_ranges_emits_pagerange_entry() {
let opts = OfficeOptions {
page_ranges: Some(PageRanges::parse("1-3,5").expect("parse")),
..Default::default()
};
- let blob = opts.filter_blob().expect("blob");
- let v: serde_json::Value = serde_json::from_str(&blob).expect("json");
- assert_eq!(v["PageRange"]["type"], "string");
- assert_eq!(v["PageRange"]["value"], "1-3,5");
+ let opts = opts.filter_options();
+ assert!(opts.contains(&"PageRange=1-3,5".to_string()), "{opts:?}");
}
#[test]
- fn office_options_with_pdf_a_maps_select_pdf_version_long() {
+ fn office_options_with_pdf_a_maps_select_pdf_version() {
let cases = [
- (PdfAProfile::A1B, 1),
- (PdfAProfile::A2B, 2),
- (PdfAProfile::A3B, 3),
+ (PdfAProfile::A1B, "SelectPdfVersion=1"),
+ (PdfAProfile::A2B, "SelectPdfVersion=2"),
+ (PdfAProfile::A3B, "SelectPdfVersion=3"),
];
for (prof, expected) in cases {
let opts = OfficeOptions {
pdf_a: Some(prof),
..Default::default()
};
- let blob = opts.filter_blob().expect("blob");
- let v: serde_json::Value = serde_json::from_str(&blob).expect("json");
- assert_eq!(v["SelectPdfVersion"]["type"], "long");
- assert_eq!(v["SelectPdfVersion"]["value"], expected);
+ let opts = opts.filter_options();
+ assert!(opts.contains(&expected.to_string()), "{opts:?}");
}
}
#[test]
- fn office_options_landscape_and_pdfua_blob_keys() {
+ fn office_options_landscape_and_pdfua_entries() {
let opts = OfficeOptions {
landscape: true,
pdf_ua: true,
..Default::default()
};
- let blob = opts.filter_blob().expect("blob");
- let v: serde_json::Value = serde_json::from_str(&blob).expect("json");
- assert_eq!(v["IsLandscape"]["type"], "boolean");
- assert_eq!(v["IsLandscape"]["value"], true);
- assert_eq!(v["PDFUACompliance"]["type"], "boolean");
- assert_eq!(v["PDFUACompliance"]["value"], true);
+ let opts = opts.filter_options();
+ assert!(opts.contains(&"IsLandscape=true".to_string()), "{opts:?}");
+ assert!(opts.contains(&"PDFUACompliance=true".to_string()), "{opts:?}");
}
#[test]
- fn office_options_quality_and_resolution_blob_long() {
+ fn office_options_quality_and_resolution_entries() {
let opts = OfficeOptions {
quality: Some(75),
max_image_resolution: Some(150),
..Default::default()
};
- let blob = opts.filter_blob().expect("blob");
- let v: serde_json::Value = serde_json::from_str(&blob).expect("json");
- assert_eq!(v["Quality"]["type"], "long");
- assert_eq!(v["Quality"]["value"], 75);
- assert_eq!(v["MaxImageResolution"]["type"], "long");
- assert_eq!(v["MaxImageResolution"]["value"], 150);
+ let opts = opts.filter_options();
+ assert!(opts.contains(&"Quality=75".to_string()), "{opts:?}");
+ assert!(opts.contains(&"MaxImageResolution=150".to_string()), "{opts:?}");
}
#[test]
@@ -784,7 +801,7 @@ mod tests {
}
#[test]
- fn office_options_filter_blob_all_new_fields() {
+ fn office_options_filter_options_all_new_fields() {
let opts = OfficeOptions {
landscape: true,
export_bookmarks: true,
@@ -827,50 +844,53 @@ mod tests {
open_bookmark_levels: Some(-1),
..Default::default()
};
- let blob = opts.filter_blob().expect("blob");
- let v: serde_json::Value = serde_json::from_str(&blob).expect("json");
- assert_eq!(v["ExportBookmarks"]["type"], "boolean");
- assert_eq!(v["ExportBookmarks"]["value"], true);
- assert_eq!(v["ExportBookmarksToPDFDestination"]["value"], true);
- assert_eq!(v["ExportFormFields"]["value"], true);
- assert_eq!(v["AllowDuplicateFieldNames"]["value"], true);
- assert_eq!(v["ExportPlaceholders"]["value"], true);
- assert_eq!(v["ExportNotes"]["value"], true);
- assert_eq!(v["ExportNotesPages"]["value"], true);
- assert_eq!(v["ExportOnlyNotesPages"]["value"], true);
- assert_eq!(v["ExportNotesInMargin"]["value"], true);
- assert_eq!(v["ConvertOOoTargetToPDFTarget"]["value"], true);
- assert_eq!(v["ExportLinksRelativeFsys"]["value"], true);
- assert_eq!(v["ExportHiddenSlides"]["value"], true);
- assert_eq!(v["IsSkipEmptyPages"]["value"], true);
- assert_eq!(v["IsAddStream"]["value"], true);
- assert_eq!(v["SinglePageSheets"]["value"], true);
- assert_eq!(v["UseLosslessCompression"]["value"], true);
- assert_eq!(v["ReduceImageResolution"]["value"], true);
- assert_eq!(v["Watermark"]["type"], "string");
- assert_eq!(v["Watermark"]["value"], "SECRET");
- assert_eq!(v["WatermarkColor"]["type"], "long");
- assert_eq!(v["WatermarkColor"]["value"], 16711680);
- assert_eq!(v["WatermarkFontHeight"]["value"], 20);
- assert_eq!(v["WatermarkRotateAngle"]["value"], 30);
- assert_eq!(v["WatermarkFontName"]["value"], "Times");
- assert_eq!(v["TiledWatermark"]["value"], "DRAFT");
- assert_eq!(v["InitialView"]["type"], "long");
- assert_eq!(v["InitialView"]["value"], 1);
- assert_eq!(v["InitialPage"]["value"], 5);
- assert_eq!(v["Magnification"]["value"], 2);
- assert_eq!(v["Zoom"]["value"], 150);
- assert_eq!(v["PageLayout"]["value"], 3);
- assert_eq!(v["FirstPageOnLeft"]["value"], true);
- assert_eq!(v["ResizeWindowToInitialPage"]["value"], true);
- assert_eq!(v["CenterWindow"]["value"], true);
- assert_eq!(v["OpenInFullScreenMode"]["value"], true);
- assert_eq!(v["DisplayPDFDocumentTitle"]["value"], true);
- assert_eq!(v["HideViewerMenubar"]["value"], true);
- assert_eq!(v["HideViewerToolbar"]["value"], true);
- assert_eq!(v["HideViewerWindowControls"]["value"], true);
- assert_eq!(v["UseTransitionEffects"]["value"], true);
- assert_eq!(v["OpenBookmarkLevels"]["value"], -1);
+ let entries = opts.filter_options();
+ let expected = [
+ "ExportBookmarks=true",
+ "ExportBookmarksToPDFDestination=true",
+ "ExportFormFields=true",
+ "AllowDuplicateFieldNames=true",
+ "ExportPlaceholders=true",
+ "ExportNotes=true",
+ "ExportNotesPages=true",
+ "ExportOnlyNotesPages=true",
+ "ExportNotesInMargin=true",
+ "ConvertOOoTargetToPDFTarget=true",
+ "ExportLinksRelativeFsys=true",
+ "ExportHiddenSlides=true",
+ "IsSkipEmptyPages=true",
+ "IsAddStream=true",
+ "SinglePageSheets=true",
+ "UseLosslessCompression=true",
+ "ReduceImageResolution=true",
+ "Watermark=SECRET",
+ "WatermarkColor=16711680",
+ "WatermarkFontHeight=20",
+ "WatermarkRotateAngle=30",
+ "WatermarkFontName=Times",
+ "TiledWatermark=DRAFT",
+ "InitialView=1",
+ "InitialPage=5",
+ "Magnification=2",
+ "Zoom=150",
+ "PageLayout=3",
+ "FirstPageOnLeft=true",
+ "ResizeWindowToInitialPage=true",
+ "CenterWindow=true",
+ "OpenInFullScreenMode=true",
+ "DisplayPDFDocumentTitle=true",
+ "HideViewerMenubar=true",
+ "HideViewerToolbar=true",
+ "HideViewerWindowControls=true",
+ "UseTransitionEffects=true",
+ "OpenBookmarkLevels=-1",
+ ];
+ for e in expected {
+ assert!(
+ entries.iter().any(|s| s == e),
+ "missing {e}; got {entries:?}"
+ );
+ }
}
#[test]
@@ -905,15 +925,4 @@ mod tests {
assert!(ok.validate().is_ok());
}
- #[tokio::test]
- async fn launch_with_missing_executable_path_errors() {
- let cfg = LibreOfficeConfig {
- executable: Some(PathBuf::from("/nonexistent/__folio_no_soffice")),
- ..LibreOfficeConfig::default()
- };
- let err = LibreOfficeEngine::launch(cfg)
- .await
- .expect_err("should fail");
- assert!(matches!(err, EngineError::Internal(_)));
- }
}
diff --git a/crates/engine/src/libreoffice/unoserver.rs b/crates/engine/src/libreoffice/unoserver.rs
new file mode 100644
index 0000000..731558e
--- /dev/null
+++ b/crates/engine/src/libreoffice/unoserver.rs
@@ -0,0 +1,119 @@
+use std::path::Path;
+use std::time::Duration;
+
+use tokio::process::Child;
+use tracing::info;
+
+use crate::types::{EngineError, EngineResult};
+
+pub(super) struct UnoserverProcess {
+ child: Child,
+ port: u16,
+}
+
+impl std::fmt::Debug for UnoserverProcess {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("UnoserverProcess")
+ .field("port", &self.port)
+ .finish_non_exhaustive()
+ }
+}
+
+impl UnoserverProcess {
+ pub(super) async fn spawn(
+ port: u16,
+ ready_timeout: Duration,
+ executable: Option<&Path>,
+ ) -> EngineResult {
+ // Port 0 means "ask the OS for a free ephemeral port". We bind a
+ // listener, capture the port, drop the listener, then hand the port
+ // to unoserver. Avoids clashes when several engines start in
+ // sequence (e.g. integration tests) where the previous process
+ // hasn't fully released the socket yet.
+ let port = if port == 0 {
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| {
+ EngineError::Internal(format!("failed to pick free port: {e}"))
+ })?;
+ listener
+ .local_addr()
+ .map_err(|e| EngineError::Internal(format!("local_addr: {e}")))?
+ .port()
+ } else {
+ port
+ };
+ info!(port, "Starting unoserver");
+
+ let mut cmd = tokio::process::Command::new("unoserver");
+ cmd.args([
+ "--interface",
+ "127.0.0.1",
+ "--port",
+ &port.to_string(),
+ ]);
+ if let Some(exe) = executable {
+ cmd.arg("--executable");
+ cmd.arg(exe);
+ }
+ cmd.kill_on_drop(true)
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null());
+
+ let child = cmd
+ .spawn()
+ .map_err(|e| EngineError::Internal(format!("failed to spawn unoserver: {e}")))?;
+
+ // Poll TCP until the port accepts connections or timeout elapses.
+ let addr = format!("127.0.0.1:{port}");
+ let deadline = tokio::time::Instant::now() + ready_timeout;
+ loop {
+ if tokio::time::Instant::now() >= deadline {
+ return Err(EngineError::Timeout(ready_timeout));
+ }
+ match tokio::net::TcpStream::connect(&addr).await {
+ Ok(_) => {
+ info!(port, "unoserver ready");
+ break;
+ }
+ Err(_) => {
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ }
+ }
+ }
+
+ Ok(Self { child, port })
+ }
+
+ pub(super) fn port(&self) -> u16 {
+ self.port
+ }
+
+ pub(super) fn try_wait(&mut self) -> std::io::Result