Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb66d7175e | ||
|
|
5bfc4c4a92 | ||
|
|
71f5ee79e0 | ||
|
|
893c8fd44d | ||
|
|
02e2cdf5e9 | ||
|
|
06a9bab6b3 | ||
|
|
ca6d667146 | ||
|
|
a4b1c714e8 | ||
|
|
0f9b5fa1c6 |
@@ -180,18 +180,31 @@ jobs:
|
|||||||
test -n "$ort_path"
|
test -n "$ort_path"
|
||||||
echo "ORT_DYLIB_PATH=$ort_path" >> "$GITHUB_ENV"
|
echo "ORT_DYLIB_PATH=$ort_path" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Configure isolated model cache
|
||||||
|
shell: bash
|
||||||
|
run: echo "PDF_INSPECTOR_MODEL_CACHE=$RUNNER_TEMP/pdf-inspector-models" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build OCR CLI
|
- name: Build OCR CLI
|
||||||
run: cargo build --features ocr --bin pdf2md
|
run: cargo build --features ocr --bin pdf2md
|
||||||
|
|
||||||
- name: Test PDFium runtime
|
- name: Test PDFium runtime
|
||||||
run: cargo test --features ocr --test local_render_tests
|
run: cargo test --features ocr --test local_render_tests
|
||||||
|
|
||||||
|
- name: Provision OCR model cache
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
target/debug/pdf2md \
|
||||||
|
tests/fixtures/scan_with_native_header_text.pdf \
|
||||||
|
--ocr force \
|
||||||
|
--json > /dev/null
|
||||||
|
|
||||||
- name: Run OCR CLI
|
- name: Run OCR CLI
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
target/debug/pdf2md \
|
target/debug/pdf2md \
|
||||||
tests/fixtures/scan_with_native_header_text.pdf \
|
tests/fixtures/scan_with_native_header_text.pdf \
|
||||||
--ocr auto \
|
--ocr auto \
|
||||||
|
--ocr-offline \
|
||||||
--json > "$RUNNER_TEMP/ocr-result.json"
|
--json > "$RUNNER_TEMP/ocr-result.json"
|
||||||
|
|
||||||
- name: Validate OCR JSON contract
|
- name: Validate OCR JSON contract
|
||||||
@@ -213,6 +226,12 @@ jobs:
|
|||||||
assert "layout_ms" not in result["pages"][0]["timings"]
|
assert "layout_ms" not in result["pages"][0]["timings"]
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
- name: Run OCR launch smoke set
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export PDF_INSPECTOR_OCR_TEST_MODELS="$PDF_INSPECTOR_MODEL_CACHE/pp-ocrv6-small/oar-ocr-v0.7.0"
|
||||||
|
cargo test --features ocr --test ocr_tests -- --nocapture
|
||||||
|
|
||||||
- name: Build Node binding
|
- name: Build Node binding
|
||||||
working-directory: napi
|
working-directory: napi
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -86,12 +86,14 @@ jobs:
|
|||||||
name: Build ${{ matrix.target }}
|
name: Build ${{ matrix.target }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
|
docker-options: -e CFLAGS_aarch64_unknown_linux_gnu=-D__ARM_ARCH=8
|
||||||
# macos-13 was retired by GitHub; macos-15-intel is the remaining
|
# macos-13 was retired by GitHub; macos-15-intel is the remaining
|
||||||
# Intel runner label (available through 2027).
|
# Intel runner label (available through 2027).
|
||||||
- os: macos-15-intel
|
- os: macos-15-intel
|
||||||
@@ -113,6 +115,9 @@ jobs:
|
|||||||
target: ${{ matrix.target }}
|
target: ${{ matrix.target }}
|
||||||
args: --release --out dist
|
args: --release --out dist
|
||||||
manylinux: auto
|
manylinux: auto
|
||||||
|
# The manylinux AArch64 GCC omits this macro while preprocessing
|
||||||
|
# ring's assembly. AArch64 is ARMv8 by definition.
|
||||||
|
docker-options: ${{ matrix.docker-options }}
|
||||||
|
|
||||||
- name: Upload wheel
|
- name: Upload wheel
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ jobs:
|
|||||||
name: Build ${{ matrix.target }}
|
name: Build ${{ matrix.target }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
@@ -70,6 +71,7 @@ jobs:
|
|||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
build-flags: --use-napi-cross
|
build-flags: --use-napi-cross
|
||||||
|
cflags: -D__ARM_ARCH=8
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
target: x86_64-unknown-linux-musl
|
target: x86_64-unknown-linux-musl
|
||||||
build-flags: -x
|
build-flags: -x
|
||||||
@@ -126,6 +128,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build native addon
|
- name: Build native addon
|
||||||
working-directory: napi
|
working-directory: napi
|
||||||
|
env:
|
||||||
|
# napi-cross's old AArch64 GCC omits this predefined macro while
|
||||||
|
# preprocessing ring's assembly. AArch64 is ARMv8 by definition.
|
||||||
|
CFLAGS_aarch64_unknown_linux_gnu: ${{ matrix.cflags }}
|
||||||
run: bunx napi build --platform --release --target ${{ matrix.target }} ${{ matrix.build-flags }}
|
run: bunx napi build --platform --release --target ${{ matrix.target }} ${{ matrix.build-flags }}
|
||||||
|
|
||||||
- name: Upload native binary
|
- name: Upload native binary
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pdf-inspector"
|
name = "pdf-inspector"
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
autobins = false
|
autobins = false
|
||||||
authors = ["Firecrawl Team"]
|
authors = ["Firecrawl Team"]
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ Use the [paired benchmark harness](docs/benchmarking.md) to compare two local bu
|
|||||||
### Python
|
### Python
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install maturin
|
pip install pdf-inspector
|
||||||
maturin develop --release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -182,8 +181,9 @@ and confidence, warnings, and pages recommended for the hosted document
|
|||||||
pipeline. Native Python and Node packages expose the same pipeline without a
|
pipeline. Native Python and Node packages expose the same pipeline without a
|
||||||
source-build feature. All native entry points still require separately
|
source-build feature. All native entry points still require separately
|
||||||
installed PDFium and ONNX Runtime libraries only when OCR is routed. See the
|
installed PDFium and ONNX Runtime libraries only when OCR is routed. See the
|
||||||
[Rust API guide](docs/rust-api.md#complete-ocr-api) for model cache and offline
|
[OCR runtime setup guide](docs/ocr-runtime.md) for pinned downloads, platform
|
||||||
configuration.
|
support, model-cache behavior, and hosted-fallback integration. See the
|
||||||
|
[Rust API guide](docs/rust-api.md#complete-ocr-api) for lower-level controls.
|
||||||
|
|
||||||
From a source checkout, use `cargo run --bin pdf2md -- document.pdf` or `cargo run --bin detect-pdf -- document.pdf` instead.
|
From a source checkout, use `cargo run --bin pdf2md -- document.pdf` or `cargo run --bin detect-pdf -- document.pdf` instead.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# OCR runtime setup
|
||||||
|
|
||||||
|
Selective OCR is available from the Rust library and CLI, Python, and Node.js.
|
||||||
|
Clean native-text documents do not load an OCR dependency or download a model.
|
||||||
|
When `auto` routes at least one page, the process needs PDFium, ONNX Runtime,
|
||||||
|
and the pinned PP-OCRv6 Small model set.
|
||||||
|
|
||||||
|
## Validated versions
|
||||||
|
|
||||||
|
The reproducible runtime path uses these builds:
|
||||||
|
|
||||||
|
- [Firecrawl PDFium `native-v7988`](https://github.com/firecrawl/pdfium-rs/releases/tag/native-v7988),
|
||||||
|
containing PDFium `153.0.7988.0`
|
||||||
|
- [ONNX Runtime `1.27.0`](https://github.com/microsoft/onnxruntime/releases/tag/v1.27.0)
|
||||||
|
- PP-OCRv6 Small artifact revision `oar-ocr-v0.7.0`
|
||||||
|
|
||||||
|
Use these versions for the reproducible path. Other compatible shared-library
|
||||||
|
builds may work, but are not part of the release smoke test.
|
||||||
|
|
||||||
|
## Install the shared libraries
|
||||||
|
|
||||||
|
Download and extract the matching archives:
|
||||||
|
|
||||||
|
| Platform | PDFium asset | ONNX Runtime asset |
|
||||||
|
|---|---|---|
|
||||||
|
| Linux x64 | `firecrawl-pdfium-linux-x64.tgz` | `onnxruntime-linux-x64-1.27.0.tgz` |
|
||||||
|
| Linux ARM64 | `firecrawl-pdfium-linux-arm64.tgz` | `onnxruntime-linux-aarch64-1.27.0.tgz` |
|
||||||
|
| macOS Apple Silicon | `firecrawl-pdfium-mac-arm64.tgz` | `onnxruntime-osx-arm64-1.27.0.tgz` |
|
||||||
|
| Windows x64 | `firecrawl-pdfium-win-x64.tgz` | `onnxruntime-win-x64-1.27.0.zip` |
|
||||||
|
|
||||||
|
The PDFium release publishes `SHA256SUMS`, build provenance, license files,
|
||||||
|
and an SPDX document for every platform archive. GitHub publishes a SHA-256
|
||||||
|
digest with each ONNX Runtime asset.
|
||||||
|
|
||||||
|
Point pdf-inspector at the extracted shared libraries when they are not on the
|
||||||
|
platform library search path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PDFIUM_LIB_PATH=/absolute/path/to/libpdfium.so
|
||||||
|
export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so
|
||||||
|
pdf2md scan.pdf --ocr auto --json
|
||||||
|
```
|
||||||
|
|
||||||
|
On macOS the filenames end in `.dylib`. On Windows, use PowerShell and point
|
||||||
|
the variables at `pdfium.dll` and `onnxruntime.dll`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PDFIUM_LIB_PATH = "C:\absolute\path\to\pdfium.dll"
|
||||||
|
$env:ORT_DYLIB_PATH = "C:\absolute\path\to\onnxruntime.dll"
|
||||||
|
pdf2md scan.pdf --ocr auto --json
|
||||||
|
```
|
||||||
|
|
||||||
|
The native extraction packages also support platforms without these exact
|
||||||
|
runtime assets. In particular, the Python package has an Intel macOS wheel,
|
||||||
|
but ONNX Runtime 1.27.0 does not publish an Intel macOS archive; local OCR on
|
||||||
|
that target requires a compatible custom ONNX Runtime build.
|
||||||
|
|
||||||
|
The full OCR path is exercised end to end on Linux x64 in CI. macOS and
|
||||||
|
Windows compile and run the feature's platform-independent tests, while their
|
||||||
|
external-runtime paths should be treated as preview until equivalent smoke
|
||||||
|
jobs are added.
|
||||||
|
|
||||||
|
## Model cache and offline mode
|
||||||
|
|
||||||
|
The first routed page downloads and SHA-256-verifies three pinned artifacts:
|
||||||
|
the detection model, recognition model, and character dictionary. Together
|
||||||
|
they are about 31 MB. They are stored below the platform cache directory.
|
||||||
|
Set `PDF_INSPECTOR_MODEL_CACHE` to choose a managed cache root.
|
||||||
|
|
||||||
|
For hermetic deployments, populate the model directory ahead of time and use
|
||||||
|
the language-specific offline option:
|
||||||
|
|
||||||
|
- CLI: `--ocr-offline --ocr-model-dir /models/pp-ocrv6-small`
|
||||||
|
- Rust: `ModelDownloadPolicy::Offline` with `OcrOptions::model_directory`
|
||||||
|
- Python: `offline=True, model_directory="/models/pp-ocrv6-small"`
|
||||||
|
- Node.js: `offline: true, modelDirectory: "/models/pp-ocrv6-small"`
|
||||||
|
|
||||||
|
The model artifacts come from
|
||||||
|
[`GreatV/oar-ocr`](https://github.com/GreatV/oar-ocr/releases/tag/v0.7.0),
|
||||||
|
whose OCR implementation and upstream PaddleOCR project use Apache-2.0
|
||||||
|
licensing. Models are downloaded at runtime and are not embedded in any
|
||||||
|
pdf-inspector package.
|
||||||
|
|
||||||
|
## Hosted fallback boundary
|
||||||
|
|
||||||
|
`pages_recommending_hosted` is available after the local pipeline completes.
|
||||||
|
It marks pages whose completed OCR result is empty, low-confidence, or still
|
||||||
|
appears incomplete.
|
||||||
|
|
||||||
|
Setup and execution failures happen before that result exists. A missing or
|
||||||
|
incompatible PDFium/ONNX Runtime library, failed model acquisition, or OCR
|
||||||
|
execution error is returned as an error. A downstream integration that has a
|
||||||
|
hosted parser should catch that error and route the document to the hosted
|
||||||
|
path. This keeps deployment problems distinct from page-quality judgments.
|
||||||
|
|
||||||
|
In `auto`, documents with no routed pages return successfully without touching
|
||||||
|
PDFium, ONNX Runtime, the model cache, or the network.
|
||||||
+3
-1
@@ -44,7 +44,9 @@ OCR calls that route work require compatible PDFium and ONNX Runtime shared
|
|||||||
libraries. Set `PDFIUM_LIB_PATH` and `ORT_DYLIB_PATH` when they are not on the
|
libraries. Set `PDFIUM_LIB_PATH` and `ORT_DYLIB_PATH` when they are not on the
|
||||||
platform library search path. The pinned OCR model set is downloaded and
|
platform library search path. The pinned OCR model set is downloaded and
|
||||||
checksum-verified on the first routed page; use `offline=True` with a warm
|
checksum-verified on the first routed page; use `offline=True` with a warm
|
||||||
cache or `model_directory` to prohibit network access.
|
cache or `model_directory` to prohibit network access. See the
|
||||||
|
[OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md)
|
||||||
|
for pinned downloads, supported platforms, and hosted-fallback behavior.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -383,6 +383,10 @@ shape; `Force` renders every selected page. OCR uses the existing deterministic
|
|||||||
table, column, reading-order, and Markdown assembly path; no learned layout
|
table, column, reading-order, and Markdown assembly path; no learned layout
|
||||||
model is included.
|
model is included.
|
||||||
|
|
||||||
|
The [OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md)
|
||||||
|
lists the pinned PDFium and ONNX Runtime builds, environment variables, model
|
||||||
|
cache behavior, and the error boundary downstream hosted fallbacks should use.
|
||||||
|
|
||||||
For ambiguous mixed pages, `Auto` privately retains clean native fragments
|
For ambiguous mixed pages, `Auto` privately retains clean native fragments
|
||||||
instead of discarding them when OCR is selected. After recognition it compares
|
instead of discarding them when OCR is selected. After recognition it compares
|
||||||
script-agnostic text quality, OCR confidence, character overlap, and material
|
script-agnostic text quality, OCR confidence, character overlap, and material
|
||||||
|
|||||||
Generated
+2
-2
@@ -2089,7 +2089,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdf-inspector"
|
name = "pdf-inspector"
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -2114,7 +2114,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdf-inspector-napi"
|
name = "pdf-inspector-napi"
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"napi",
|
"napi",
|
||||||
"napi-build",
|
"napi-build",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pdf-inspector-napi"
|
name = "pdf-inspector-napi"
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
+3
-1
@@ -41,7 +41,9 @@ OCR calls that route work require compatible PDFium and ONNX Runtime shared
|
|||||||
libraries. Set `PDFIUM_LIB_PATH` and `ORT_DYLIB_PATH` when they are not on the
|
libraries. Set `PDFIUM_LIB_PATH` and `ORT_DYLIB_PATH` when they are not on the
|
||||||
platform library search path. The pinned OCR model set is downloaded and
|
platform library search path. The pinned OCR model set is downloaded and
|
||||||
checksum-verified on the first routed page; use `offline: true` with a warm
|
checksum-verified on the first routed page; use `offline: true` with a warm
|
||||||
cache or `modelDirectory` to prohibit network access.
|
cache or `modelDirectory` to prohibit network access. See the
|
||||||
|
[OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md)
|
||||||
|
for pinned downloads, supported platforms, and hosted-fallback behavior.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -8,12 +8,12 @@
|
|||||||
"@napi-rs/cli": "^3.4.1",
|
"@napi-rs/cli": "^3.4.1",
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.2",
|
"@firecrawl/pdf-inspector-darwin-arm64": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.2",
|
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.2",
|
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.2",
|
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.2",
|
"@firecrawl/pdf-inspector-linux-x64-musl": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.2",
|
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.15.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+7
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@firecrawl/pdf-inspector",
|
"name": "@firecrawl/pdf-inspector",
|
||||||
"version": "1.14.2",
|
"version": "1.15.0",
|
||||||
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
|
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
@@ -52,11 +52,11 @@
|
|||||||
"@napi-rs/cli": "^3.4.1"
|
"@napi-rs/cli": "^3.4.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.2",
|
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.2",
|
"@firecrawl/pdf-inspector-linux-x64-musl": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.2",
|
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.2",
|
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.2",
|
"@firecrawl/pdf-inspector-darwin-arm64": "1.15.0",
|
||||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.2"
|
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.15.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ build-backend = "maturin"
|
|||||||
name = "pdf-inspector"
|
name = "pdf-inspector"
|
||||||
# Keep package versions in sync with `python3 scripts/version.py <version>`.
|
# Keep package versions in sync with `python3 scripts/version.py <version>`.
|
||||||
# CI publishes automatically when the synchronized change lands on main.
|
# CI publishes automatically when the synchronized change lands on main.
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
|
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
|
||||||
readme = "docs/python.md"
|
readme = "docs/python.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
+1
-1
@@ -975,7 +975,7 @@ result = pdf_inspector.<span class="fn">process_pdf</span>(<span class="str">"do
|
|||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||||
const WASM_MODULE_URL = "https://cdn.jsdelivr.net/npm/@firecrawl/pdf-inspector-wasm@1.14.2/pdf_inspector_wasm.js";
|
const WASM_MODULE_URL = "https://cdn.jsdelivr.net/npm/@firecrawl/pdf-inspector-wasm@1.15.0/pdf_inspector_wasm.js";
|
||||||
const input = document.querySelector("#pdf-input");
|
const input = document.querySelector("#pdf-input");
|
||||||
const dropZone = document.querySelector("#drop-zone");
|
const dropZone = document.querySelector("#drop-zone");
|
||||||
const filePanel = document.querySelector("#demo-file");
|
const filePanel = document.querySelector("#demo-file");
|
||||||
|
|||||||
@@ -561,7 +561,11 @@ pub(crate) fn extract_page_text_items(
|
|||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height: rendered_size,
|
height: rendered_size,
|
||||||
font: current_font.clone(),
|
font: crate::extractor::fonts::item_font_name(
|
||||||
|
¤t_font,
|
||||||
|
base_font,
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
font_size: rendered_size,
|
font_size: rendered_size,
|
||||||
page: page_num,
|
page: page_num,
|
||||||
is_bold: is_bold_font(base_font) || desc_bold,
|
is_bold: is_bold_font(base_font) || desc_bold,
|
||||||
@@ -745,7 +749,11 @@ pub(crate) fn extract_page_text_items(
|
|||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height: rendered_size,
|
height: rendered_size,
|
||||||
font: current_font.clone(),
|
font: crate::extractor::fonts::item_font_name(
|
||||||
|
¤t_font,
|
||||||
|
base_font,
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
font_size: rendered_size,
|
font_size: rendered_size,
|
||||||
page: page_num,
|
page: page_num,
|
||||||
is_bold: is_bold_font(base_font) || desc_bold,
|
is_bold: is_bold_font(base_font) || desc_bold,
|
||||||
@@ -852,7 +860,11 @@ pub(crate) fn extract_page_text_items(
|
|||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height: rendered_size,
|
height: rendered_size,
|
||||||
font: current_font.clone(),
|
font: crate::extractor::fonts::item_font_name(
|
||||||
|
¤t_font,
|
||||||
|
base_font,
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
font_size: rendered_size,
|
font_size: rendered_size,
|
||||||
page: page_num,
|
page: page_num,
|
||||||
is_bold: is_bold_font(base_font) || desc_bold,
|
is_bold: is_bold_font(base_font) || desc_bold,
|
||||||
@@ -1005,7 +1017,11 @@ pub(crate) fn extract_page_text_items(
|
|||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height: rendered_size,
|
height: rendered_size,
|
||||||
font: current_font.clone(),
|
font: crate::extractor::fonts::item_font_name(
|
||||||
|
¤t_font,
|
||||||
|
base_font,
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
font_size: rendered_size,
|
font_size: rendered_size,
|
||||||
page: page_num,
|
page: page_num,
|
||||||
is_bold: is_bold_font(base_font) || desc_bold,
|
is_bold: is_bold_font(base_font) || desc_bold,
|
||||||
|
|||||||
@@ -225,6 +225,26 @@ pub(crate) fn build_type3_scales(
|
|||||||
scales
|
scales
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name a `TextItem` carries for its font: the `/BaseFont` family name
|
||||||
|
/// ("ABCDEF+CMMI10"), which identifies the actual face, rather than the
|
||||||
|
/// arbitrary per-page resource tag ("F2").
|
||||||
|
///
|
||||||
|
/// Exception: resource names using Distiller's CID convention (`C2_0`,
|
||||||
|
/// `C0_1`) are kept as-is — `text_utils::is_cid_font` keys on that prefix
|
||||||
|
/// for micro-gap joining, and the family name carries no CID marker to
|
||||||
|
/// replace it. This is a known, deliberate wart: `TextItem::font` is the
|
||||||
|
/// face name except for this one producer convention. The clean fix is an
|
||||||
|
/// explicit CID flag on `TextItem`, which touches its ~29 construction
|
||||||
|
/// sites; do that migration when `TextItem` next changes shape, and delete
|
||||||
|
/// this carve-out with it.
|
||||||
|
pub(crate) fn item_font_name<'a>(resource_name: &'a str, base_font: &'a str) -> &'a str {
|
||||||
|
if crate::text_utils::is_cid_font(resource_name) {
|
||||||
|
resource_name
|
||||||
|
} else {
|
||||||
|
base_font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse font widths from a font dictionary, dispatching by Subtype
|
/// Parse font widths from a font dictionary, dispatching by Subtype
|
||||||
pub(crate) fn parse_font_widths(
|
pub(crate) fn parse_font_widths(
|
||||||
doc: &Document,
|
doc: &Document,
|
||||||
@@ -1664,6 +1684,17 @@ fn score_text(text: &str) -> i32 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_font_name_prefers_family_over_resource_tag() {
|
||||||
|
use super::item_font_name;
|
||||||
|
assert_eq!(item_font_name("F2", "ABCDEF+CMMI10"), "ABCDEF+CMMI10");
|
||||||
|
assert_eq!(item_font_name("T22", "Times-Roman"), "Times-Roman");
|
||||||
|
// Distiller CID-convention resources keep the resource name:
|
||||||
|
// is_cid_font keys on the C2_/C0_ prefix for micro-gap joining.
|
||||||
|
assert_eq!(item_font_name("C2_0", "ABCDEE+SimSun"), "C2_0");
|
||||||
|
assert_eq!(item_font_name("C0_1", "ABCDEE+MSMincho"), "C0_1");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn type3_scale_resolves_indirect_matrix_and_bbox_numbers() {
|
fn type3_scale_resolves_indirect_matrix_and_bbox_numbers() {
|
||||||
use lopdf::{dictionary, Document, Object};
|
use lopdf::{dictionary, Document, Object};
|
||||||
|
|||||||
@@ -620,7 +620,11 @@ fn extract_form_xobject_text_inner(
|
|||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height: rendered_size,
|
height: rendered_size,
|
||||||
font: current_font.clone(),
|
font: crate::extractor::fonts::item_font_name(
|
||||||
|
¤t_font,
|
||||||
|
base_font,
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
font_size: rendered_size,
|
font_size: rendered_size,
|
||||||
page: page_num,
|
page: page_num,
|
||||||
is_bold: is_bold_font(base_font) || desc_bold,
|
is_bold: is_bold_font(base_font) || desc_bold,
|
||||||
@@ -775,7 +779,11 @@ fn extract_form_xobject_text_inner(
|
|||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height: rendered_size,
|
height: rendered_size,
|
||||||
font: current_font.clone(),
|
font: crate::extractor::fonts::item_font_name(
|
||||||
|
¤t_font,
|
||||||
|
base_font,
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
font_size: rendered_size,
|
font_size: rendered_size,
|
||||||
page: page_num,
|
page: page_num,
|
||||||
is_bold: is_bold_font(base_font) || desc_bold,
|
is_bold: is_bold_font(base_font) || desc_bold,
|
||||||
|
|||||||
@@ -203,9 +203,42 @@ pub(crate) fn is_code_like(text: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when a line's text is essentially all monospace (≥90% by character
|
||||||
|
/// count). Code lines are wholly monospace; anything less is prose carrying
|
||||||
|
/// mono-styled fragments — a URL sidebar, or a sentence quoting an inline
|
||||||
|
/// code literal — and fencing it would split paragraphs mid-sentence.
|
||||||
|
/// Any-item matching was safe only while items carried opaque font resource
|
||||||
|
/// names that never matched the monospace patterns; items now carry real
|
||||||
|
/// family names.
|
||||||
|
pub(crate) fn line_is_monospace(line: &crate::types::TextLine) -> bool {
|
||||||
|
let mut monospace_chars = 0usize;
|
||||||
|
let mut total_chars = 0usize;
|
||||||
|
for item in &line.items {
|
||||||
|
let text = item.text.trim();
|
||||||
|
let chars = text.chars().count();
|
||||||
|
total_chars += chars;
|
||||||
|
// Hyperlinks and underlined text set in a mono face are link
|
||||||
|
// styling, not code — a URL sidebar must not fence lyric lines.
|
||||||
|
let looks_like_link = item.is_underline
|
||||||
|
|| matches!(item.item_type, crate::types::ItemType::Link(_))
|
||||||
|
|| text.contains("://")
|
||||||
|
|| text.starts_with("www.");
|
||||||
|
if is_monospace_font(&item.font) && !looks_like_link {
|
||||||
|
monospace_chars += chars;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total_chars > 0 && monospace_chars * 10 >= total_chars * 9
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if font name indicates monospace
|
/// Check if font name indicates monospace
|
||||||
pub(crate) fn is_monospace_font(font_name: &str) -> bool {
|
pub(crate) fn is_monospace_font(font_name: &str) -> bool {
|
||||||
let lower = font_name.to_lowercase();
|
let lower = font_name.to_lowercase();
|
||||||
|
// "Monotype" is a foundry prefix on proportional faces (Monotype
|
||||||
|
// Corsiva, Monotype Garamond) — it must not satisfy the generic "mono"
|
||||||
|
// token below.
|
||||||
|
if lower.contains("monotype") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let patterns = [
|
let patterns = [
|
||||||
"courier",
|
"courier",
|
||||||
"consolas",
|
"consolas",
|
||||||
@@ -230,6 +263,17 @@ pub(crate) fn is_monospace_font(font_name: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monotype_foundry_faces_are_not_monospace() {
|
||||||
|
// "Monotype" is a foundry prefix on proportional faces; the generic
|
||||||
|
// "mono" token must not classify them as code fonts.
|
||||||
|
assert!(!is_monospace_font("MonotypeCorsiva"));
|
||||||
|
assert!(!is_monospace_font("ABCDEF+Monotype-Garamond"));
|
||||||
|
assert!(is_monospace_font("RobotoMono-Regular"));
|
||||||
|
assert!(is_monospace_font("PTMono"));
|
||||||
|
assert!(is_monospace_font("Courier"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_list_item_plain_bullet() {
|
fn format_list_item_plain_bullet() {
|
||||||
assert_eq!(format_list_item("● Item"), "- Item");
|
assert_eq!(format_list_item("● Item"), "- Item");
|
||||||
|
|||||||
+48
-24
@@ -11,9 +11,7 @@ use super::analysis::{
|
|||||||
detect_header_level, font_size_rarity, has_dot_leaders, is_heading_fragment, is_toc_entry_line,
|
detect_header_level, font_size_rarity, has_dot_leaders, is_heading_fragment, is_toc_entry_line,
|
||||||
is_toc_marker_heading,
|
is_toc_marker_heading,
|
||||||
};
|
};
|
||||||
use super::classify::{
|
use super::classify::{format_list_item, is_caption_line, is_list_item, starts_with_bullet_marker};
|
||||||
format_list_item, is_caption_line, is_list_item, is_monospace_font, starts_with_bullet_marker,
|
|
||||||
};
|
|
||||||
use super::heading::classify_heading_sequences;
|
use super::heading::classify_heading_sequences;
|
||||||
use super::postprocess::clean_markdown;
|
use super::postprocess::clean_markdown;
|
||||||
use super::preprocess::{merge_drop_caps, merge_heading_lines};
|
use super::preprocess::{merge_drop_caps, merge_heading_lines};
|
||||||
@@ -771,7 +769,27 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
|||||||
let mut in_list = false;
|
let mut in_list = false;
|
||||||
let mut in_paragraph = false;
|
let mut in_paragraph = false;
|
||||||
let mut last_list_x: Option<f32> = None;
|
let mut last_list_x: Option<f32> = None;
|
||||||
|
// Code lines accumulate here and the fence is emitted only when the
|
||||||
|
// block flushes with content — an empty ``` ``` pair can never appear.
|
||||||
|
fn flush_code_block(output: &mut String, pending_code: &mut String) {
|
||||||
|
let trimmed = pending_code.trim();
|
||||||
|
// A fragment too short to be code — a lone ® or stray glyph set in
|
||||||
|
// a mono face — reads better as plain text than as a fenced block.
|
||||||
|
if trimmed.chars().count() < 3 {
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
output.push_str(trimmed);
|
||||||
|
output.push_str("\n\n");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.push_str("```\n");
|
||||||
|
output.push_str(pending_code);
|
||||||
|
output.push_str("```\n");
|
||||||
|
}
|
||||||
|
pending_code.clear();
|
||||||
|
}
|
||||||
|
|
||||||
let mut in_code_block = false;
|
let mut in_code_block = false;
|
||||||
|
let mut pending_code = String::new();
|
||||||
let mut prev_had_dot_leaders = false;
|
let mut prev_had_dot_leaders = false;
|
||||||
let mut paragraph_in_wrapped_bold_run = false;
|
let mut paragraph_in_wrapped_bold_run = false;
|
||||||
let mut toc_suppress_page: Option<u32> = None;
|
let mut toc_suppress_page: Option<u32> = None;
|
||||||
@@ -805,7 +823,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
|||||||
// Flush current page's remaining tables and images
|
// Flush current page's remaining tables and images
|
||||||
if current_page > 0 {
|
if current_page > 0 {
|
||||||
if in_code_block {
|
if in_code_block {
|
||||||
output.push_str("```\n");
|
flush_code_block(&mut output, &mut pending_code);
|
||||||
in_code_block = false;
|
in_code_block = false;
|
||||||
}
|
}
|
||||||
flush_page_tables_and_images(
|
flush_page_tables_and_images(
|
||||||
@@ -867,6 +885,14 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
|||||||
PositionedBlockKind::Image => inserted_images.contains(&(current_page, idx)),
|
PositionedBlockKind::Image => inserted_images.contains(&(current_page, idx)),
|
||||||
};
|
};
|
||||||
if positioned_block_precedes_line(block, line) && !already_inserted {
|
if positioned_block_precedes_line(block, line) && !already_inserted {
|
||||||
|
// Code lines buffer until their block closes; flush them
|
||||||
|
// first so this block cannot jump ahead of code that
|
||||||
|
// precedes it in reading order. A code line after the
|
||||||
|
// block reopens a new fence naturally.
|
||||||
|
if in_code_block {
|
||||||
|
flush_code_block(&mut output, &mut pending_code);
|
||||||
|
in_code_block = false;
|
||||||
|
}
|
||||||
if in_paragraph {
|
if in_paragraph {
|
||||||
output.push_str("\n\n");
|
output.push_str("\n\n");
|
||||||
in_paragraph = false;
|
in_paragraph = false;
|
||||||
@@ -937,15 +963,22 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
|||||||
// These should be on their own line followed by a paragraph break
|
// These should be on their own line followed by a paragraph break
|
||||||
let struct_role = struct_roles.and_then(|roles| resolve_line_struct_role(line, roles));
|
let struct_role = struct_roles.and_then(|roles| resolve_line_struct_role(line, roles));
|
||||||
|
|
||||||
// Determine if this line is code (struct-tree or font-based) for block accumulation
|
// Determine if this line is code (struct-tree or font-based) for
|
||||||
|
// block accumulation. Font-based detection only opens a block at a
|
||||||
|
// paragraph boundary: a mono-set line that continues an open prose
|
||||||
|
// paragraph is the producer smearing an inline code literal's style
|
||||||
|
// across a wrapped line (HTML-to-PDF exports do this), and fencing
|
||||||
|
// it would cut the sentence in three.
|
||||||
let is_code_line = struct_role
|
let is_code_line = struct_role
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|r| matches!(r, StructRole::Code))
|
.is_some_and(|r| matches!(r, StructRole::Code))
|
||||||
|| (options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)));
|
|| (options.detect_code
|
||||||
|
&& (in_code_block || !in_paragraph)
|
||||||
|
&& super::classify::line_is_monospace(line));
|
||||||
|
|
||||||
// Close code block when transitioning to non-code
|
// Close code block when transitioning to non-code
|
||||||
if in_code_block && !is_code_line {
|
if in_code_block && !is_code_line {
|
||||||
output.push_str("```\n");
|
flush_code_block(&mut output, &mut pending_code);
|
||||||
in_code_block = false;
|
in_code_block = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,12 +1212,9 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
|||||||
in_paragraph = false;
|
in_paragraph = false;
|
||||||
paragraph_in_wrapped_bold_run = false;
|
paragraph_in_wrapped_bold_run = false;
|
||||||
}
|
}
|
||||||
if !in_code_block {
|
|
||||||
output.push_str("```\n");
|
|
||||||
in_code_block = true;
|
in_code_block = true;
|
||||||
}
|
pending_code.push_str(plain_trimmed);
|
||||||
output.push_str(plain_trimmed);
|
pending_code.push('\n');
|
||||||
output.push('\n');
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1209,7 +1239,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
|||||||
|
|
||||||
// Close any trailing code block
|
// Close any trailing code block
|
||||||
if in_code_block {
|
if in_code_block {
|
||||||
output.push_str("```\n");
|
flush_code_block(&mut output, &mut pending_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush current page and any remaining pages with tables/images
|
// Flush current page and any remaining pages with tables/images
|
||||||
@@ -1370,7 +1400,7 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
|||||||
&& !is_toc_entry_line(plain_trimmed)
|
&& !is_toc_entry_line(plain_trimmed)
|
||||||
&& !is_heading_fragment(plain_trimmed)
|
&& !is_heading_fragment(plain_trimmed)
|
||||||
&& toc_suppress_page != Some(line.page)
|
&& toc_suppress_page != Some(line.page)
|
||||||
&& !(options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)))
|
&& !(options.detect_code && super::classify::line_is_monospace(line))
|
||||||
{
|
{
|
||||||
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
||||||
if let Some(header_level) = detect_header_level(
|
if let Some(header_level) = detect_header_level(
|
||||||
@@ -1471,20 +1501,14 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect code blocks by font
|
// Detect code blocks by font. Only at a paragraph boundary — a
|
||||||
if options.detect_code {
|
// mono-set line continuing an open prose paragraph is an inline
|
||||||
let is_mono = line.items.iter().any(|i| is_monospace_font(&i.font));
|
// code literal's style smeared across a wrapped line, not code.
|
||||||
if is_mono {
|
if options.detect_code && !in_paragraph && super::classify::line_is_monospace(line) {
|
||||||
if in_paragraph {
|
|
||||||
output.push_str("\n\n");
|
|
||||||
in_paragraph = false;
|
|
||||||
paragraph_in_wrapped_bold_run = false;
|
|
||||||
}
|
|
||||||
// Use plain text for code blocks
|
// Use plain text for code blocks
|
||||||
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
|
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Regular text - join lines within same paragraph with space
|
// Regular text - join lines within same paragraph with space
|
||||||
let cur_dot_leaders = has_dot_leaders(plain_trimmed);
|
let cur_dot_leaders = has_dot_leaders(plain_trimmed);
|
||||||
|
|||||||
Generated
+2
-2
@@ -724,7 +724,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdf-inspector"
|
name = "pdf-inspector"
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
@@ -740,7 +740,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdf-inspector-wasm"
|
name = "pdf-inspector-wasm"
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pdf-inspector-wasm"
|
name = "pdf-inspector-wasm"
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Firecrawl Team"]
|
authors = ["Firecrawl Team"]
|
||||||
description = "Browser WebAssembly bindings for pdf-inspector"
|
description = "Browser WebAssembly bindings for pdf-inspector"
|
||||||
|
|||||||
Reference in New Issue
Block a user