Compare commits

...
Author SHA1 Message Date
Abimael Martell 7ba1966b54 layout: indent paragraph breaks, heading reclassification, table script filter
Layout heuristics originally developed alongside the base-14 metrics fix
and split out of it. These are NOT bug fixes — they are new heuristics,
and they apply to every document regardless of vintage.

- First-line-indent paragraph breaks: justified documents that separate
  paragraphs by indentation only (no vertical gap) previously came out
  as walls of text
- Heading classification by dominant alphanumeric font size, so a
  leading drop cap or oversized math delimiter cannot reclassify a body
  line; tier computation filters glyph-run lines and requires uniform
  sizing
- Table detection: exclude sub/superscripts attached at a script
  baseline offset from candidates; reject tiny all-numeric grids in the
  small-font pass
- Embedded drop-cap merge for two-line caps that land as the second
  line's first item

Corpus impact: ~98 of 184 eval documents on top of the font-metrics
branch (full stack: 110 of 184; font metrics alone: 12).

KNOWN REGRESSIONS — this is why it is split out and left as a draft:

1. Heading hierarchy collapse. In revista63 every heading moves h3 -> h1
   because the new tier filters drop that document's larger tiers, so
   everything at body+2pt becomes top-level. 2 of 60 sampled documents
   lose heading depth this way.
2. The indent rule fires on non-prose. Its guard only asks whether the
   previous line ended ragged, which is true of URL lists, file
   listings, poems, and chat transcripts. In the mythos system card it
   splits a protein-sequence line and a URL list; the resulting short
   fragment is then promoted to a heading. Same mechanism yields the
   junk '#### Rr Fy' heading in 2103_07786.
3. 10 documents lose table rows with no replacement.

The semantic composite is flat (+0.0011) but char-accuracy barely
registers heading damage ('###' -> '#' is two characters), so that is
not evidence these are harmless.
2026-08-03 16:16:13 -07:00
Abimael Martell 1228a2c2ca fix(extractor): supply built-in metrics for non-embedded base-14 fonts
PDFs may legally omit /Widths for non-embedded standard fonts (Times,
Helvetica, Courier, Symbol, ZapfDingbats) — the spec requires the reader
to supply the metrics. We returned None, so every glyph advanced 0 and
each text item got width 0, silently breaking every gap-based heuristic
downstream: space synthesis, sub/superscript detection, table column
detection, heading merging.

- src/extractor/base14.rs: Adobe Core-14 AFM width tables keyed by
  Unicode char, plus the standard Symbol/ZapfDingbats encoding vectors
  (their glyphs sit at byte positions unrelated to Latin text, so widths
  must resolve through the built-in encoding, not cp1252)
- Width resolution order: Differences -> built-in encoding -> the same
  cp1252-style fallback the text decoder uses, so a code's advance always
  matches the character we emit for it
- Type3 visual sizing: PK bitmap fonts (dvips) use FontMatrix
  [1 0 0 -1 0 0] with nominal sizes like 0.12pt; scale by FontBBox height
  x |matrix_y|. Applied in the page-stream and Form XObject paths.
  Indirect numeric array elements are resolved before use.

Effect on Shannon's 'A Mathematical Theory of Communication' (1998
dvips/Distiller, the reported case): glued sentences 95 -> 5. Corpus
impact: 12 of 184 eval documents, e.g. Data-Processing-Agreement
recovers a paragraph that a phantom table had shredded into cells.

Layout heuristics tuned on the same document (indent-based paragraph
breaks, heading reclassification, table script filtering) are held back
for a separate PR — they change ~98 further documents and need to be
justified against the corpus, not against one PDF.
2026-08-03 16:15:04 -07:00
Abimael Martell a15ec2d68d docs(readme): refresh local parser benchmark results (#192)
* docs(readme): refresh parser benchmark results

* docs(readme): include stored parser results

* docs(readme): refresh remaining parser results

* docs: sync benchmark references
2026-07-31 22:58:46 -06:00
Abimael Martell 7b7960ee73 chore(pypi): bump pdf-inspector to 0.2.6 (#188) 2026-07-31 17:12:34 -06:00
Abimael Martell 7188667045 chore(wasm): bump @firecrawl/pdf-inspector-wasm to 0.1.3 (#190) 2026-07-31 16:43:52 -06:00
Abimael Martell 6ff104409a chore(npm): bump @firecrawl/pdf-inspector to 1.11.2 (#189) 2026-07-31 16:37:50 -06:00
Abimael Martell 5e8f1570f6 chore(crate): bump pdf-inspector to 0.1.7 (#187) 2026-07-31 16:25:57 -06:00
tomsideguide b31e4b1727 fix(extractor): don't flag gid Differences names covered by ToUnicode (#186)
* fix(extractor): don't flag gid Differences names covered by ToUnicode

Pages were marked as having unresolvable gid-encoded fonts whenever any
font's /Differences array used gidNNNN glyph names, and when every page
carried such a font the whole document's markdown was suppressed.
LibreOffice exports do exactly this: subset fonts get /gidNNNN names in
Differences alongside a complete ToUnicode CMap that decodes them, so
ordinary text documents lost their entire markdown output even though
extraction decoded every glyph.

Track the character codes behind the gid names and only flag the font
when its ToUnicode CMap addresses none of them. Partially mapped codes
stay unflagged: an emoji ZWJ sequence maps whole on its first code, and
the remaining component-glyph codes are subset leftovers, not damage.
Fonts without ToUnicode, or whose CMap ignores the gid codes, are
flagged as before, and the downstream garbage/encoding checks still
catch partial breakage.

* fix(extractor): require a usable ToUnicode mapping to clear the gid flag

A mapping to U+FFFD (or an empty string) is rejected by extraction as
an invalid CMap result, so it must not count as decodable when deciding
whether gid-named Differences codes are resolvable.
2026-07-31 15:54:19 -06:00
Abimael Martell 3c6eb8bf6b feat(site): add local WebAssembly demo (#181)
* feat(site): add local WebAssembly demo

* feat(site): parse PDFs on selection
2026-07-17 15:00:47 -07:00
Abimael Martell 5b287341a0 feat(wasm): add browser bindings (#180)
* feat(wasm): add browser bindings

* fix(wasm): address review feedback

* fix(wasm): preserve numeric plain text

* chore(wasm): prepare 0.1.2 release
2026-07-17 14:09:43 -07:00
Abimael Martell 55d50ad1c4 docs(site): redesign open-source project page (#179)
* docs(site): redesign open-source project page

* docs(site): lead with node and cli

* docs(site): emphasize package registries

* docs(site): align footer wordmark

* docs(site): remove hero terminal scrollbar

* docs(site): keep hero install command inline

* docs(site): feature rust core in hero

* docs(site): add hero language playground

* Revert "docs(site): add hero language playground"

This reverts commit 16df5fcf07.
2026-07-16 22:55:51 -07:00
Abimael Martell a910b7df1d docs(benchmark): refresh parser comparison (#178)
* docs: refresh benchmark comparison

* docs(site): refresh benchmark section

* docs: reframe benchmark positioning

* docs: focus benchmark positioning on best fit
2026-07-16 17:43:53 -07:00
40 changed files with 5271 additions and 488 deletions
+37
View File
@@ -42,6 +42,9 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check WASM formatting
run: cargo fmt --manifest-path wasm/Cargo.toml -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
@@ -80,3 +83,37 @@ jobs:
- name: Build
run: cargo build --release --verbose
wasm:
name: WebAssembly
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
components: clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: |
wasm -> target
key: wasm
- name: Check WebAssembly bindings
run: cargo check --manifest-path wasm/Cargo.toml --target wasm32-unknown-unknown
- name: Check root package for WebAssembly
run: cargo check --target wasm32-unknown-unknown
- name: Lint WebAssembly bindings
run: cargo clippy --manifest-path wasm/Cargo.toml --target wasm32-unknown-unknown -- -D warnings
- name: Install wasm-pack
run: cargo install wasm-pack --version 0.15.0 --locked
- name: Test WebAssembly package
run: wasm-pack test --node --release wasm
+111
View File
@@ -0,0 +1,111 @@
name: Publish WebAssembly package
on:
push:
branches: [main]
paths: ['wasm/Cargo.toml']
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
CARGO_TERM_COLOR: always
jobs:
check-version:
name: Check version change
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
package_exists: ${{ steps.check.outputs.package_exists }}
published: ${{ steps.check.outputs.published }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check package version
id: check
run: |
NEW_VERSION=$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("wasm/Cargo.toml").read_text())["package"]["version"])')
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
elif git cat-file -e HEAD~1:wasm/Cargo.toml 2>/dev/null; then
OLD_VERSION=$(git show HEAD~1:wasm/Cargo.toml | python3 -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["package"]["version"])')
if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "published=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
if ! npm view "@firecrawl/pdf-inspector-wasm" name >/dev/null 2>&1; then
echo "package_exists=false" >> "$GITHUB_OUTPUT"
echo "published=false" >> "$GITHUB_OUTPUT"
echo "The initial package must be published once before trusted publishing can be configured."
exit 0
fi
echo "package_exists=true" >> "$GITHUB_OUTPUT"
if npm view "@firecrawl/pdf-inspector-wasm@$NEW_VERSION" version >/dev/null 2>&1; then
echo "published=true" >> "$GITHUB_OUTPUT"
else
echo "published=false" >> "$GITHUB_OUTPUT"
fi
publish:
name: Build and publish
needs: check-version
if: needs.check-version.outputs.changed == 'true' && needs.check-version.outputs.package_exists == 'true' && needs.check-version.outputs.published == 'false'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Install wasm-pack
run: cargo install wasm-pack --version 0.15.0 --locked
- name: Build browser package
run: wasm-pack build wasm --target web --scope firecrawl --out-dir pkg --release
- name: Prepare package metadata
run: |
node -e '
const fs = require("fs")
const path = "wasm/pkg/package.json"
const pkg = JSON.parse(fs.readFileSync(path, "utf8"))
pkg.name = "@firecrawl/pdf-inspector-wasm"
pkg.description = "Browser WebAssembly bindings for the pdf-inspector Rust PDF parser"
pkg.keywords = ["pdf", "pdf-parser", "webassembly", "wasm", "markdown", "rust", "firecrawl"]
pkg.repository = { type: "git", url: "https://github.com/firecrawl/pdf-inspector" }
pkg.homepage = "https://github.com/firecrawl/pdf-inspector/tree/main/wasm"
pkg.publishConfig = { access: "public" }
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n")
'
- name: Inspect package contents
run: npm pack --dry-run ./wasm/pkg
- name: Publish package
run: npm publish ./wasm/pkg --provenance --access public
+3 -1
View File
@@ -1,10 +1,13 @@
# Rust build artifacts
/target/
/wasm/target/
/wasm/pkg/
debug/
*.pdb
# Cargo lock (optional for libraries)
Cargo.lock
!/wasm/Cargo.lock
# IDE
.idea/
@@ -39,4 +42,3 @@ test_output/
__pycache__/
*.pyc
.pytest_cache/
+19 -13
View File
@@ -1,6 +1,6 @@
[package]
name = "pdf-inspector"
version = "0.1.6"
version = "0.1.7"
edition = "2021"
autobins = false
authors = ["Firecrawl Team"]
@@ -12,13 +12,13 @@ readme = "docs/rust-api.md"
# alone exceeds that. external/bcmaps ships in the crate — tounicode.rs
# loads it at runtime relative to CARGO_MANIFEST_DIR.
include = [
"src/**",
"external/bcmaps/**",
"docs/rust-api.md",
"LICENSE",
"/src/**",
"/external/bcmaps/**",
"/docs/rust-api.md",
"/LICENSE",
# maturin derives the sdist file list from this allowlist; the stub must
# ship so wheels built from the sdist keep their type hints.
"pdf_inspector.pyi",
"/pdf_inspector.pyi",
]
[lib]
@@ -29,18 +29,11 @@ crate-type = ["lib", "cdylib"]
# Python bindings
pyo3 = { version = "0.25", features = ["extension-module", "abi3-py38"], optional = true }
# PDF parsing
lopdf = { version = "0.41.0", features = ["rayon"] }
# Error handling
thiserror = "2.0"
# Parallel processing
rayon = "1.10"
# Logging
log = "0.4"
env_logger = "0.11"
# Text processing
regex = "1.10"
@@ -50,6 +43,19 @@ unicode-normalization = "0.1"
# TrueType font parsing (for Identity-H CID font cmap extraction)
ttf-parser = "0.25"
# Native builds keep lopdf's parallel parser and CLI logging. Browser WASM is
# deliberately single-threaded so it works without cross-origin isolation.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
lopdf = { version = "0.41.0", features = ["rayon"] }
rayon = "1.10"
env_logger = "0.11"
# Browser builds use JavaScript randomness for encrypted PDFs and embed the
# bundled CMaps because there is no filesystem at runtime.
[target.'cfg(target_arch = "wasm32")'.dependencies]
lopdf = { version = "0.41.0", default-features = false, features = ["wasm_js"] }
include_dir = "0.7"
[dev-dependencies]
tempfile = "3.3"
+32 -9
View File
@@ -5,7 +5,7 @@
[![PyPI](https://img.shields.io/pypi/v/pdf-inspector.svg)](https://pypi.org/project/pdf-inspector/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Fast Rust library for PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Includes bindings for [Python](docs/python.md) and [Node.js](napi/README.md).
Fast Rust library for PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Includes bindings for [Python](docs/python.md), [Node.js](napi/README.md), and [browser WebAssembly](wasm/README.md).
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
@@ -19,24 +19,26 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
- **Multi-column layout** — Automatic detection of newspaper-style columns, sequential reading order, and RTL text support.
- **Encoding issue detection** — Automatically flags broken font encodings so callers can fall back to OCR.
- **Single document load** — The document is parsed once and shared between detection and extraction, avoiding redundant I/O.
- **Browser WebAssembly** — Run the same Rust parser locally in browsers and Web Workers, with embedded CMaps and no server round trip.
- **Lightweight** — Pure Rust, no ML models, no external services. Single dependency on `lopdf` for PDF parsing.
## Benchmark
Evaluated on the [opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs). Only direct text extraction engines are shown — no OCR, no ML models. Scores are 0-1, higher is better.
Evaluated on the [opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs). Only local engines without model-based PDF parsing are shown; OCR was disabled. Scores are 0-1, higher is better.
| Engine | Overall | Reading Order (NID) | Tables (TEDS) | Headings (MHS) | Speed (200 docs) |
|---|---|---|---|---|---|
| pdf-inspector | **0.875** | **0.915** | **0.814** | 0.788 | 3.3s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 3.0s |
| pymupdf4llm | 0.73 | 0.89 | 0.40 | 0.41 | 18s |
| markitdown | 0.59 | 0.84 | 0.27 | 0.00 | 23s |
| pdf-inspector | **0.875** | **0.915** | **0.814** | 0.788 | **0.470s** |
| liteparse | 0.873 | 0.913 | 0.693 | **0.811** | 0.750s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 2.569s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 17.117s |
| markitdown | 0.589 | 0.844 | 0.273 | 0.000 | 16.165s |
For context, engines that use OCR/ML (docling, marker, mineru) score 0.83-0.88 overall but take 2-180 minutes on the same corpus — pdf-inspector reaches the top of that range without any OCR, in 3.3 seconds.
Results were refreshed on July 31, 2026, on an Apple M4 Pro. Engine versions were pdf-inspector 0.2.6, LiteParse 2.10.1, OpenDataLoader 2.2.1, PyMuPDF4LLM 0.2.0, and MarkItDown 0.1.5. Speed is the median of five alternating or rotating complete corpus runs after an excluded warm-up run, with each parser processing documents sequentially in a single process.
**Where we do well:** The best overall, reading-order, and table scores among the direct extraction engines shown.
The complete parser configuration, per-document predictions, evaluator output, and generated charts are available in the [reproducible results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
**Where we lag:** Some direct engines remain slightly faster, and OCR-based engines can recover text that has no usable PDF text layer.
**Best fit:** Native-text PDFs where speed, reading order, and table structure matter. In this comparison, pdf-inspector delivered the higher overall, reading-order, and table scores, along with the fastest complete run. That makes it a strong local default for reports, research papers, financial documents, invoices, and legal PDFs that need clean, structured Markdown without adding OCR latency or infrastructure.
Use the [paired benchmark harness](docs/benchmarking.md) to compare two local builds against the exact same corpus and evaluator revision.
@@ -76,6 +78,26 @@ console.log(result.markdown); // Markdown string or null
> Full API reference: [napi/README.md](napi/README.md)
### Browser WebAssembly
```bash
npm install @firecrawl/pdf-inspector-wasm
```
```javascript
import init, { processPdf } from '@firecrawl/pdf-inspector-wasm';
await init();
const response = await fetch('/document.pdf');
const pdf = new Uint8Array(await response.arrayBuffer());
const result = processPdf(pdf);
console.log(result.pdfType);
console.log(result.markdown);
```
> Full API reference: [wasm/README.md](wasm/README.md)
### Rust
Install from [crates.io](https://crates.io/crates/pdf-inspector):
@@ -187,6 +209,7 @@ src/
markdown/ — Markdown conversion and structure detection
bin/ — CLI tools (pdf2md, detect_pdf)
napi/ — Node.js/Bun bindings (napi-rs)
wasm/ — Browser bindings (wasm-bindgen)
```
## How classification works
+11
View File
@@ -28,6 +28,17 @@ The OpenDataLoader repository is external and keeps its normal
temporary directory before evaluating it, so the baseline and candidate cannot
overwrite one another.
## Published comparison protocol
The public benchmark table was refreshed on July 31, 2026, on an Apple M4 Pro
using pdf-inspector 0.2.6, LiteParse 2.10.1, OpenDataLoader 2.2.1,
PyMuPDF4LLM 0.2.0, and MarkItDown 0.1.5. Every engine processed the same 200
PDFs sequentially in a single process with OCR disabled. Reported speed is the
median of five alternating or rotating complete corpus runs after an excluded
warm-up run; quality scores come from the benchmark evaluator over all 200
outputs. Raw timings, predictions, evaluations, and charts are available in the
[results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
## Optional backend evidence probe
The evidence probe compares positioned `pdf2md` items with MuPDF structured
+17
View File
@@ -19,3 +19,20 @@ The workflow uses `rust-lang/crates-io-auth-action@v1` to exchange GitHub's OIDC
3. The publish workflow compares the new `Cargo.toml` version with `HEAD~1`, runs `cargo publish --dry-run`, then publishes if that version is not already on crates.io.
If `Cargo.toml` changes without a package version bump, the workflow exits without publishing.
## Browser WebAssembly package
The browser package is published as `@firecrawl/pdf-inspector-wasm`. Its version lives in `wasm/Cargo.toml`, and `.github/workflows/publish-wasm.yml` builds the `web` target with `wasm-pack` before publishing the generated package.
The npm package must exist before a trusted publisher can be configured. For the first release only:
1. Build with `wasm-pack build wasm --target web --scope firecrawl --out-dir pkg --release`.
2. Inspect with `npm pack --dry-run ./wasm/pkg`.
3. Publish with `npm publish ./wasm/pkg --access public` from an authorized maintainer session.
4. In the package settings on npm, configure the GitHub Actions trusted publisher:
- Organization: `firecrawl`
- Repository: `pdf-inspector`
- Workflow: `publish-wasm.yml`
- Allowed action: `npm publish`
After that one-time bootstrap, bumping the version in `wasm/Cargo.toml` and merging it to `main` publishes through OIDC. Until the package exists, the workflow exits cleanly without attempting an unauthenticated first publish. See npm's [trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/) for the registry-side setup.
+7 -5
View File
@@ -14,15 +14,17 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
## Benchmark
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), direct-extraction engines only — no OCR, no ML. Scores 01, higher is better:
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 01, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | 3.3s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 3.0s |
| pymupdf4llm | 0.73 | 0.89 | 0.40 | 0.41 | 18s |
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **0.470s** |
| liteparse | 0.873 | 0.913 | 0.693 | **0.811** | 0.750s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 2.569s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 17.117s |
| markitdown | 0.589 | 0.844 | 0.273 | 0.000 | 16.165s |
OCR/ML engines (docling, marker, mineru) score 0.830.88 overall but take 2180 minutes on the same corpus. Full numbers in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark).
Refreshed July 31, 2026, on Apple M4 Pro; speed is the median of five complete corpus runs after an excluded warm-up. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark), with raw timings and artifacts in the [results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
## Install
+7 -5
View File
@@ -14,15 +14,17 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
## Benchmark
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), direct-extraction engines only — no OCR, no ML. Scores 01, higher is better:
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 01, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | 3.3s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 3.0s |
| pymupdf4llm | 0.73 | 0.89 | 0.40 | 0.41 | 18s |
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **0.470s** |
| liteparse | 0.873 | 0.913 | 0.693 | **0.811** | 0.750s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 2.569s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 17.117s |
| markitdown | 0.589 | 0.844 | 0.273 | 0.000 | 16.165s |
OCR/ML engines (docling, marker, mineru) score 0.830.88 overall but take 2180 minutes on the same corpus. Full numbers in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark).
Refreshed July 31, 2026, on Apple M4 Pro; speed is the median of five complete corpus runs after an excluded warm-up. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark), with raw timings and artifacts in the [results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
## Install
+1 -1
View File
@@ -830,7 +830,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pdf-inspector"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"env_logger",
"log",
+7 -5
View File
@@ -14,15 +14,17 @@ Built by [Firecrawl](https://firecrawl.dev) for hybrid OCR pipelines — extract
## Benchmark
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), direct-extraction engines only — no OCR, no ML. Scores 01, higher is better:
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 01, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| **pdf-inspector** | 0.83 | 0.88 | **0.66** | 0.74 | **4s** |
| opendataloader | 0.84 | 0.91 | 0.49 | 0.74 | 11s |
| pymupdf4llm | 0.73 | 0.89 | 0.40 | 0.41 | 18s |
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **0.470s** |
| liteparse | 0.873 | 0.913 | 0.693 | **0.811** | 0.750s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 2.569s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 17.117s |
| markitdown | 0.589 | 0.844 | 0.273 | 0.000 | 16.165s |
OCR/ML engines (docling, marker, mineru) score 0.830.88 overall but take 2180 minutes on the same corpus. Full numbers in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark).
Refreshed July 31, 2026, on Apple M4 Pro; speed is the median of five complete corpus runs after an excluded warm-up. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark), with raw timings and artifacts in the [results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
## Install
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.11.1",
"version": "1.11.2",
"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",
"types": "index.d.ts",
@@ -49,8 +49,8 @@
"@napi-rs/cli": "^3.4.1"
},
"optionalDependencies": {
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.11.1",
"@firecrawl/pdf-inspector-darwin-arm64": "1.11.1",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.11.1"
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.11.2",
"@firecrawl/pdf-inspector-darwin-arm64": "1.11.2",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.11.2"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ build-backend = "maturin"
name = "pdf-inspector"
# Bump this to publish to PyPI — CI publishes automatically when the version
# changes on main (same flow as napi/package.json for npm).
version = "0.2.5"
version = "0.2.6"
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
readme = "docs/python.md"
license = { text = "MIT" }
+1100 -356
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -63,6 +63,7 @@ fn json_escape(s: &str) -> String {
}
fn main() {
#[cfg(not(target_arch = "wasm32"))]
env_logger::init();
let args: Vec<String> = env::args().collect();
+1
View File
@@ -190,6 +190,7 @@ fn print_layout_info(layout: &LayoutComplexity) {
}
fn main() {
#[cfg(not(target_arch = "wasm32"))]
env_logger::init();
let args: Vec<String> = env::args().collect();
+837
View File
@@ -0,0 +1,837 @@
//! Built-in glyph metrics for the 14 standard PDF fonts.
//!
//! PDFs may omit `/Widths` for non-embedded base-14 fonts (Times, Helvetica,
//! Courier, Symbol, ZapfDingbats); per the PDF spec the reader must supply
//! the metrics. Without them every text item gets width 0, which breaks
//! space synthesis, sub/superscript detection, and table column detection
//! (common in 1990s dvips/Distiller output).
//!
//! Tables are generated from the Adobe Core 14 AFM files (via reportlab's
//! `_fontdata`), keyed by Unicode char, sorted for binary search.
//! Generator: scratchpad/gen_base14.py (session tooling, not checked in).
/// Width in 1000ths of an em for `c` in the given base-14 font, or `None`
/// if the font is not one of the base 14 (after name normalization) or the
/// char has no glyph in its AFM.
pub(crate) fn base14_char_width(base_font: &str, c: char) -> Option<u16> {
let table = base14_table(base_font)?;
// AFM tables key visible glyphs only; alias the invisible variants the
// cp1252 fallback can produce so they get the metric of their visible
// counterpart instead of the generic default.
let c = match c {
'\u{00A0}' => ' ', // no-break space -> space
'\u{00AD}' => '-', // soft hyphen -> hyphen
_ => c,
};
table
.binary_search_by_key(&c, |&(ch, _)| ch)
.ok()
.map(|i| table[i].1)
}
/// True when the base font name normalizes to one of the standard 14 fonts.
pub(crate) fn is_base14_font(base_font: &str) -> bool {
base14_table(base_font).is_some()
}
/// Code → Unicode through the font's BUILT-IN encoding, for the base-14
/// fonts whose repertoire is not Latin (Symbol, ZapfDingbats). Their glyphs
/// live at byte positions that have nothing to do with cp1252 (Symbol 0x61
/// renders α, Zapf 0x21 renders ✁), so advance widths must be resolved
/// through this mapping — the renderer draws these glyphs regardless of how
/// the text decoder transliterates them. Returns `None` for the Latin text
/// fonts, which follow standard single-byte encodings.
pub(crate) fn builtin_encoding_char(base_font: &str, code: u8) -> Option<char> {
let table = base14_table(base_font)?;
let enc: &[(u8, char)] = if std::ptr::eq(table, SYMBOL) {
SYMBOL_ENCODING
} else if std::ptr::eq(table, ZAPFDINGBATS) {
ZAPFDINGBATS_ENCODING
} else {
return None;
};
enc.binary_search_by_key(&code, |&(b, _)| b)
.ok()
.map(|i| enc[i].1)
}
/// Map a BaseFont name (possibly subset-prefixed, e.g. "ABCDEF+Times-Bold",
/// or a common alias like "Arial" / "TimesNewRomanPSMT") to its width table.
fn base14_table(base_font: &str) -> Option<&'static [(char, u16)]> {
// Strip subset prefix "ABCDEF+"
let name = match base_font.split_once('+') {
Some((prefix, rest))
if prefix.len() == 6 && prefix.chars().all(|c| c.is_ascii_uppercase()) =>
{
rest
}
_ => base_font,
};
let lower = name.to_ascii_lowercase();
let bold = lower.contains("bold");
let italic = lower.contains("italic") || lower.contains("oblique");
if lower.contains("courier") {
return Some(match (bold, italic) {
(false, false) => COURIER,
(true, false) => COURIER_BOLD,
(false, true) => COURIER_OBLIQUE,
(true, true) => COURIER_BOLDOBLIQUE,
});
}
if lower.contains("helvetica") || lower.contains("arial") {
return Some(match (bold, italic) {
(false, false) => HELVETICA,
(true, false) => HELVETICA_BOLD,
(false, true) => HELVETICA_OBLIQUE,
(true, true) => HELVETICA_BOLDOBLIQUE,
});
}
if lower.contains("times") {
return Some(match (bold, italic) {
(false, false) => TIMES_ROMAN,
(true, false) => TIMES_BOLD,
(false, true) => TIMES_ITALIC,
(true, true) => TIMES_BOLDITALIC,
});
}
// Symbol and ZapfDingbats have unique glyph repertoires, so only exact
// names (plus the common MT/ITC aliases) qualify — a custom font that
// merely mentions "Symbol" in its name must not get these metrics.
match lower.as_str() {
"zapfdingbats" | "dingbats" | "itczapfdingbats" | "zapfdingbatsitc" => {
return Some(ZAPFDINGBATS)
}
"symbol" | "symbolmt" | "symbolitc" => return Some(SYMBOL),
_ => {}
}
None
}
#[rustfmt::skip]
static COURIER: &[(char, u16)] = &[
(' ', 600), ('!', 600), ('"', 600), ('#', 600), ('$', 600), ('%', 600),
('&', 600), ('\'', 600), ('(', 600), (')', 600), ('*', 600), ('+', 600),
(',', 600), ('-', 600), ('.', 600), ('/', 600), ('0', 600), ('1', 600),
('2', 600), ('3', 600), ('4', 600), ('5', 600), ('6', 600), ('7', 600),
('8', 600), ('9', 600), (':', 600), (';', 600), ('<', 600), ('=', 600),
('>', 600), ('?', 600), ('@', 600), ('A', 600), ('B', 600), ('C', 600),
('D', 600), ('E', 600), ('F', 600), ('G', 600), ('H', 600), ('I', 600),
('J', 600), ('K', 600), ('L', 600), ('M', 600), ('N', 600), ('O', 600),
('P', 600), ('Q', 600), ('R', 600), ('S', 600), ('T', 600), ('U', 600),
('V', 600), ('W', 600), ('X', 600), ('Y', 600), ('Z', 600), ('[', 600),
('\\', 600), (']', 600), ('^', 600), ('_', 600), ('`', 600), ('a', 600),
('b', 600), ('c', 600), ('d', 600), ('e', 600), ('f', 600), ('g', 600),
('h', 600), ('i', 600), ('j', 600), ('k', 600), ('l', 600), ('m', 600),
('n', 600), ('o', 600), ('p', 600), ('q', 600), ('r', 600), ('s', 600),
('t', 600), ('u', 600), ('v', 600), ('w', 600), ('x', 600), ('y', 600),
('z', 600), ('{', 600), ('|', 600), ('}', 600), ('~', 600), ('\u{00A1}', 600),
('\u{00A2}', 600), ('\u{00A3}', 600), ('\u{00A4}', 600), ('\u{00A5}', 600), ('\u{00A6}', 600), ('\u{00A7}', 600),
('\u{00A8}', 600), ('\u{00A9}', 600), ('\u{00AA}', 600), ('\u{00AB}', 600), ('\u{00AC}', 600), ('\u{00AE}', 600),
('\u{00AF}', 600), ('\u{00B0}', 600), ('\u{00B1}', 600), ('\u{00B2}', 600), ('\u{00B3}', 600), ('\u{00B4}', 600),
('\u{00B5}', 600), ('\u{00B6}', 600), ('\u{00B7}', 600), ('\u{00B8}', 600), ('\u{00B9}', 600), ('\u{00BA}', 600),
('\u{00BB}', 600), ('\u{00BC}', 600), ('\u{00BD}', 600), ('\u{00BE}', 600), ('\u{00BF}', 600), ('\u{00C0}', 600),
('\u{00C1}', 600), ('\u{00C2}', 600), ('\u{00C3}', 600), ('\u{00C4}', 600), ('\u{00C5}', 600), ('\u{00C6}', 600),
('\u{00C7}', 600), ('\u{00C8}', 600), ('\u{00C9}', 600), ('\u{00CA}', 600), ('\u{00CB}', 600), ('\u{00CC}', 600),
('\u{00CD}', 600), ('\u{00CE}', 600), ('\u{00CF}', 600), ('\u{00D0}', 600), ('\u{00D1}', 600), ('\u{00D2}', 600),
('\u{00D3}', 600), ('\u{00D4}', 600), ('\u{00D5}', 600), ('\u{00D6}', 600), ('\u{00D7}', 600), ('\u{00D8}', 600),
('\u{00D9}', 600), ('\u{00DA}', 600), ('\u{00DB}', 600), ('\u{00DC}', 600), ('\u{00DD}', 600), ('\u{00DE}', 600),
('\u{00DF}', 600), ('\u{00E0}', 600), ('\u{00E1}', 600), ('\u{00E2}', 600), ('\u{00E3}', 600), ('\u{00E4}', 600),
('\u{00E5}', 600), ('\u{00E6}', 600), ('\u{00E7}', 600), ('\u{00E8}', 600), ('\u{00E9}', 600), ('\u{00EA}', 600),
('\u{00EB}', 600), ('\u{00EC}', 600), ('\u{00ED}', 600), ('\u{00EE}', 600), ('\u{00EF}', 600), ('\u{00F0}', 600),
('\u{00F1}', 600), ('\u{00F2}', 600), ('\u{00F3}', 600), ('\u{00F4}', 600), ('\u{00F5}', 600), ('\u{00F6}', 600),
('\u{00F7}', 600), ('\u{00F8}', 600), ('\u{00F9}', 600), ('\u{00FA}', 600), ('\u{00FB}', 600), ('\u{00FC}', 600),
('\u{00FD}', 600), ('\u{00FE}', 600), ('\u{00FF}', 600), ('\u{0131}', 600), ('\u{0141}', 600), ('\u{0142}', 600),
('\u{0152}', 600), ('\u{0153}', 600), ('\u{0160}', 600), ('\u{0161}', 600), ('\u{0178}', 600), ('\u{017D}', 600),
('\u{017E}', 600), ('\u{0192}', 600), ('\u{02C6}', 600), ('\u{02C7}', 600), ('\u{02D8}', 600), ('\u{02D9}', 600),
('\u{02DA}', 600), ('\u{02DB}', 600), ('\u{02DC}', 600), ('\u{02DD}', 600), ('\u{2013}', 600), ('\u{2014}', 600),
('\u{2018}', 600), ('\u{2019}', 600), ('\u{201A}', 600), ('\u{201C}', 600), ('\u{201D}', 600), ('\u{201E}', 600),
('\u{2020}', 600), ('\u{2021}', 600), ('\u{2022}', 600), ('\u{2026}', 600), ('\u{2030}', 600), ('\u{2039}', 600),
('\u{203A}', 600), ('\u{2044}', 600), ('\u{20AC}', 600), ('\u{2122}', 600), ('\u{2212}', 600), ('\u{FB01}', 600),
('\u{FB02}', 600),
];
#[rustfmt::skip]
static COURIER_BOLD: &[(char, u16)] = &[
(' ', 600), ('!', 600), ('"', 600), ('#', 600), ('$', 600), ('%', 600),
('&', 600), ('\'', 600), ('(', 600), (')', 600), ('*', 600), ('+', 600),
(',', 600), ('-', 600), ('.', 600), ('/', 600), ('0', 600), ('1', 600),
('2', 600), ('3', 600), ('4', 600), ('5', 600), ('6', 600), ('7', 600),
('8', 600), ('9', 600), (':', 600), (';', 600), ('<', 600), ('=', 600),
('>', 600), ('?', 600), ('@', 600), ('A', 600), ('B', 600), ('C', 600),
('D', 600), ('E', 600), ('F', 600), ('G', 600), ('H', 600), ('I', 600),
('J', 600), ('K', 600), ('L', 600), ('M', 600), ('N', 600), ('O', 600),
('P', 600), ('Q', 600), ('R', 600), ('S', 600), ('T', 600), ('U', 600),
('V', 600), ('W', 600), ('X', 600), ('Y', 600), ('Z', 600), ('[', 600),
('\\', 600), (']', 600), ('^', 600), ('_', 600), ('`', 600), ('a', 600),
('b', 600), ('c', 600), ('d', 600), ('e', 600), ('f', 600), ('g', 600),
('h', 600), ('i', 600), ('j', 600), ('k', 600), ('l', 600), ('m', 600),
('n', 600), ('o', 600), ('p', 600), ('q', 600), ('r', 600), ('s', 600),
('t', 600), ('u', 600), ('v', 600), ('w', 600), ('x', 600), ('y', 600),
('z', 600), ('{', 600), ('|', 600), ('}', 600), ('~', 600), ('\u{00A1}', 600),
('\u{00A2}', 600), ('\u{00A3}', 600), ('\u{00A4}', 600), ('\u{00A5}', 600), ('\u{00A6}', 600), ('\u{00A7}', 600),
('\u{00A8}', 600), ('\u{00A9}', 600), ('\u{00AA}', 600), ('\u{00AB}', 600), ('\u{00AC}', 600), ('\u{00AE}', 600),
('\u{00AF}', 600), ('\u{00B0}', 600), ('\u{00B1}', 600), ('\u{00B2}', 600), ('\u{00B3}', 600), ('\u{00B4}', 600),
('\u{00B5}', 600), ('\u{00B6}', 600), ('\u{00B7}', 600), ('\u{00B8}', 600), ('\u{00B9}', 600), ('\u{00BA}', 600),
('\u{00BB}', 600), ('\u{00BC}', 600), ('\u{00BD}', 600), ('\u{00BE}', 600), ('\u{00BF}', 600), ('\u{00C0}', 600),
('\u{00C1}', 600), ('\u{00C2}', 600), ('\u{00C3}', 600), ('\u{00C4}', 600), ('\u{00C5}', 600), ('\u{00C6}', 600),
('\u{00C7}', 600), ('\u{00C8}', 600), ('\u{00C9}', 600), ('\u{00CA}', 600), ('\u{00CB}', 600), ('\u{00CC}', 600),
('\u{00CD}', 600), ('\u{00CE}', 600), ('\u{00CF}', 600), ('\u{00D0}', 600), ('\u{00D1}', 600), ('\u{00D2}', 600),
('\u{00D3}', 600), ('\u{00D4}', 600), ('\u{00D5}', 600), ('\u{00D6}', 600), ('\u{00D7}', 600), ('\u{00D8}', 600),
('\u{00D9}', 600), ('\u{00DA}', 600), ('\u{00DB}', 600), ('\u{00DC}', 600), ('\u{00DD}', 600), ('\u{00DE}', 600),
('\u{00DF}', 600), ('\u{00E0}', 600), ('\u{00E1}', 600), ('\u{00E2}', 600), ('\u{00E3}', 600), ('\u{00E4}', 600),
('\u{00E5}', 600), ('\u{00E6}', 600), ('\u{00E7}', 600), ('\u{00E8}', 600), ('\u{00E9}', 600), ('\u{00EA}', 600),
('\u{00EB}', 600), ('\u{00EC}', 600), ('\u{00ED}', 600), ('\u{00EE}', 600), ('\u{00EF}', 600), ('\u{00F0}', 600),
('\u{00F1}', 600), ('\u{00F2}', 600), ('\u{00F3}', 600), ('\u{00F4}', 600), ('\u{00F5}', 600), ('\u{00F6}', 600),
('\u{00F7}', 600), ('\u{00F8}', 600), ('\u{00F9}', 600), ('\u{00FA}', 600), ('\u{00FB}', 600), ('\u{00FC}', 600),
('\u{00FD}', 600), ('\u{00FE}', 600), ('\u{00FF}', 600), ('\u{0131}', 600), ('\u{0141}', 600), ('\u{0142}', 600),
('\u{0152}', 600), ('\u{0153}', 600), ('\u{0160}', 600), ('\u{0161}', 600), ('\u{0178}', 600), ('\u{017D}', 600),
('\u{017E}', 600), ('\u{0192}', 600), ('\u{02C6}', 600), ('\u{02C7}', 600), ('\u{02D8}', 600), ('\u{02D9}', 600),
('\u{02DA}', 600), ('\u{02DB}', 600), ('\u{02DC}', 600), ('\u{02DD}', 600), ('\u{2013}', 600), ('\u{2014}', 600),
('\u{2018}', 600), ('\u{2019}', 600), ('\u{201A}', 600), ('\u{201C}', 600), ('\u{201D}', 600), ('\u{201E}', 600),
('\u{2020}', 600), ('\u{2021}', 600), ('\u{2022}', 600), ('\u{2026}', 600), ('\u{2030}', 600), ('\u{2039}', 600),
('\u{203A}', 600), ('\u{2044}', 600), ('\u{20AC}', 600), ('\u{2122}', 600), ('\u{2212}', 600), ('\u{FB01}', 600),
('\u{FB02}', 600),
];
#[rustfmt::skip]
static COURIER_OBLIQUE: &[(char, u16)] = &[
(' ', 600), ('!', 600), ('"', 600), ('#', 600), ('$', 600), ('%', 600),
('&', 600), ('\'', 600), ('(', 600), (')', 600), ('*', 600), ('+', 600),
(',', 600), ('-', 600), ('.', 600), ('/', 600), ('0', 600), ('1', 600),
('2', 600), ('3', 600), ('4', 600), ('5', 600), ('6', 600), ('7', 600),
('8', 600), ('9', 600), (':', 600), (';', 600), ('<', 600), ('=', 600),
('>', 600), ('?', 600), ('@', 600), ('A', 600), ('B', 600), ('C', 600),
('D', 600), ('E', 600), ('F', 600), ('G', 600), ('H', 600), ('I', 600),
('J', 600), ('K', 600), ('L', 600), ('M', 600), ('N', 600), ('O', 600),
('P', 600), ('Q', 600), ('R', 600), ('S', 600), ('T', 600), ('U', 600),
('V', 600), ('W', 600), ('X', 600), ('Y', 600), ('Z', 600), ('[', 600),
('\\', 600), (']', 600), ('^', 600), ('_', 600), ('`', 600), ('a', 600),
('b', 600), ('c', 600), ('d', 600), ('e', 600), ('f', 600), ('g', 600),
('h', 600), ('i', 600), ('j', 600), ('k', 600), ('l', 600), ('m', 600),
('n', 600), ('o', 600), ('p', 600), ('q', 600), ('r', 600), ('s', 600),
('t', 600), ('u', 600), ('v', 600), ('w', 600), ('x', 600), ('y', 600),
('z', 600), ('{', 600), ('|', 600), ('}', 600), ('~', 600), ('\u{00A1}', 600),
('\u{00A2}', 600), ('\u{00A3}', 600), ('\u{00A4}', 600), ('\u{00A5}', 600), ('\u{00A6}', 600), ('\u{00A7}', 600),
('\u{00A8}', 600), ('\u{00A9}', 600), ('\u{00AA}', 600), ('\u{00AB}', 600), ('\u{00AC}', 600), ('\u{00AE}', 600),
('\u{00AF}', 600), ('\u{00B0}', 600), ('\u{00B1}', 600), ('\u{00B2}', 600), ('\u{00B3}', 600), ('\u{00B4}', 600),
('\u{00B5}', 600), ('\u{00B6}', 600), ('\u{00B7}', 600), ('\u{00B8}', 600), ('\u{00B9}', 600), ('\u{00BA}', 600),
('\u{00BB}', 600), ('\u{00BC}', 600), ('\u{00BD}', 600), ('\u{00BE}', 600), ('\u{00BF}', 600), ('\u{00C0}', 600),
('\u{00C1}', 600), ('\u{00C2}', 600), ('\u{00C3}', 600), ('\u{00C4}', 600), ('\u{00C5}', 600), ('\u{00C6}', 600),
('\u{00C7}', 600), ('\u{00C8}', 600), ('\u{00C9}', 600), ('\u{00CA}', 600), ('\u{00CB}', 600), ('\u{00CC}', 600),
('\u{00CD}', 600), ('\u{00CE}', 600), ('\u{00CF}', 600), ('\u{00D0}', 600), ('\u{00D1}', 600), ('\u{00D2}', 600),
('\u{00D3}', 600), ('\u{00D4}', 600), ('\u{00D5}', 600), ('\u{00D6}', 600), ('\u{00D7}', 600), ('\u{00D8}', 600),
('\u{00D9}', 600), ('\u{00DA}', 600), ('\u{00DB}', 600), ('\u{00DC}', 600), ('\u{00DD}', 600), ('\u{00DE}', 600),
('\u{00DF}', 600), ('\u{00E0}', 600), ('\u{00E1}', 600), ('\u{00E2}', 600), ('\u{00E3}', 600), ('\u{00E4}', 600),
('\u{00E5}', 600), ('\u{00E6}', 600), ('\u{00E7}', 600), ('\u{00E8}', 600), ('\u{00E9}', 600), ('\u{00EA}', 600),
('\u{00EB}', 600), ('\u{00EC}', 600), ('\u{00ED}', 600), ('\u{00EE}', 600), ('\u{00EF}', 600), ('\u{00F0}', 600),
('\u{00F1}', 600), ('\u{00F2}', 600), ('\u{00F3}', 600), ('\u{00F4}', 600), ('\u{00F5}', 600), ('\u{00F6}', 600),
('\u{00F7}', 600), ('\u{00F8}', 600), ('\u{00F9}', 600), ('\u{00FA}', 600), ('\u{00FB}', 600), ('\u{00FC}', 600),
('\u{00FD}', 600), ('\u{00FE}', 600), ('\u{00FF}', 600), ('\u{0131}', 600), ('\u{0141}', 600), ('\u{0142}', 600),
('\u{0152}', 600), ('\u{0153}', 600), ('\u{0160}', 600), ('\u{0161}', 600), ('\u{0178}', 600), ('\u{017D}', 600),
('\u{017E}', 600), ('\u{0192}', 600), ('\u{02C6}', 600), ('\u{02C7}', 600), ('\u{02D8}', 600), ('\u{02D9}', 600),
('\u{02DA}', 600), ('\u{02DB}', 600), ('\u{02DC}', 600), ('\u{02DD}', 600), ('\u{2013}', 600), ('\u{2014}', 600),
('\u{2018}', 600), ('\u{2019}', 600), ('\u{201A}', 600), ('\u{201C}', 600), ('\u{201D}', 600), ('\u{201E}', 600),
('\u{2020}', 600), ('\u{2021}', 600), ('\u{2022}', 600), ('\u{2026}', 600), ('\u{2030}', 600), ('\u{2039}', 600),
('\u{203A}', 600), ('\u{2044}', 600), ('\u{20AC}', 600), ('\u{2122}', 600), ('\u{2212}', 600), ('\u{FB01}', 600),
('\u{FB02}', 600),
];
#[rustfmt::skip]
static COURIER_BOLDOBLIQUE: &[(char, u16)] = &[
(' ', 600), ('!', 600), ('"', 600), ('#', 600), ('$', 600), ('%', 600),
('&', 600), ('\'', 600), ('(', 600), (')', 600), ('*', 600), ('+', 600),
(',', 600), ('-', 600), ('.', 600), ('/', 600), ('0', 600), ('1', 600),
('2', 600), ('3', 600), ('4', 600), ('5', 600), ('6', 600), ('7', 600),
('8', 600), ('9', 600), (':', 600), (';', 600), ('<', 600), ('=', 600),
('>', 600), ('?', 600), ('@', 600), ('A', 600), ('B', 600), ('C', 600),
('D', 600), ('E', 600), ('F', 600), ('G', 600), ('H', 600), ('I', 600),
('J', 600), ('K', 600), ('L', 600), ('M', 600), ('N', 600), ('O', 600),
('P', 600), ('Q', 600), ('R', 600), ('S', 600), ('T', 600), ('U', 600),
('V', 600), ('W', 600), ('X', 600), ('Y', 600), ('Z', 600), ('[', 600),
('\\', 600), (']', 600), ('^', 600), ('_', 600), ('`', 600), ('a', 600),
('b', 600), ('c', 600), ('d', 600), ('e', 600), ('f', 600), ('g', 600),
('h', 600), ('i', 600), ('j', 600), ('k', 600), ('l', 600), ('m', 600),
('n', 600), ('o', 600), ('p', 600), ('q', 600), ('r', 600), ('s', 600),
('t', 600), ('u', 600), ('v', 600), ('w', 600), ('x', 600), ('y', 600),
('z', 600), ('{', 600), ('|', 600), ('}', 600), ('~', 600), ('\u{00A1}', 600),
('\u{00A2}', 600), ('\u{00A3}', 600), ('\u{00A4}', 600), ('\u{00A5}', 600), ('\u{00A6}', 600), ('\u{00A7}', 600),
('\u{00A8}', 600), ('\u{00A9}', 600), ('\u{00AA}', 600), ('\u{00AB}', 600), ('\u{00AC}', 600), ('\u{00AE}', 600),
('\u{00AF}', 600), ('\u{00B0}', 600), ('\u{00B1}', 600), ('\u{00B2}', 600), ('\u{00B3}', 600), ('\u{00B4}', 600),
('\u{00B5}', 600), ('\u{00B6}', 600), ('\u{00B7}', 600), ('\u{00B8}', 600), ('\u{00B9}', 600), ('\u{00BA}', 600),
('\u{00BB}', 600), ('\u{00BC}', 600), ('\u{00BD}', 600), ('\u{00BE}', 600), ('\u{00BF}', 600), ('\u{00C0}', 600),
('\u{00C1}', 600), ('\u{00C2}', 600), ('\u{00C3}', 600), ('\u{00C4}', 600), ('\u{00C5}', 600), ('\u{00C6}', 600),
('\u{00C7}', 600), ('\u{00C8}', 600), ('\u{00C9}', 600), ('\u{00CA}', 600), ('\u{00CB}', 600), ('\u{00CC}', 600),
('\u{00CD}', 600), ('\u{00CE}', 600), ('\u{00CF}', 600), ('\u{00D0}', 600), ('\u{00D1}', 600), ('\u{00D2}', 600),
('\u{00D3}', 600), ('\u{00D4}', 600), ('\u{00D5}', 600), ('\u{00D6}', 600), ('\u{00D7}', 600), ('\u{00D8}', 600),
('\u{00D9}', 600), ('\u{00DA}', 600), ('\u{00DB}', 600), ('\u{00DC}', 600), ('\u{00DD}', 600), ('\u{00DE}', 600),
('\u{00DF}', 600), ('\u{00E0}', 600), ('\u{00E1}', 600), ('\u{00E2}', 600), ('\u{00E3}', 600), ('\u{00E4}', 600),
('\u{00E5}', 600), ('\u{00E6}', 600), ('\u{00E7}', 600), ('\u{00E8}', 600), ('\u{00E9}', 600), ('\u{00EA}', 600),
('\u{00EB}', 600), ('\u{00EC}', 600), ('\u{00ED}', 600), ('\u{00EE}', 600), ('\u{00EF}', 600), ('\u{00F0}', 600),
('\u{00F1}', 600), ('\u{00F2}', 600), ('\u{00F3}', 600), ('\u{00F4}', 600), ('\u{00F5}', 600), ('\u{00F6}', 600),
('\u{00F7}', 600), ('\u{00F8}', 600), ('\u{00F9}', 600), ('\u{00FA}', 600), ('\u{00FB}', 600), ('\u{00FC}', 600),
('\u{00FD}', 600), ('\u{00FE}', 600), ('\u{00FF}', 600), ('\u{0131}', 600), ('\u{0141}', 600), ('\u{0142}', 600),
('\u{0152}', 600), ('\u{0153}', 600), ('\u{0160}', 600), ('\u{0161}', 600), ('\u{0178}', 600), ('\u{017D}', 600),
('\u{017E}', 600), ('\u{0192}', 600), ('\u{02C6}', 600), ('\u{02C7}', 600), ('\u{02D8}', 600), ('\u{02D9}', 600),
('\u{02DA}', 600), ('\u{02DB}', 600), ('\u{02DC}', 600), ('\u{02DD}', 600), ('\u{2013}', 600), ('\u{2014}', 600),
('\u{2018}', 600), ('\u{2019}', 600), ('\u{201A}', 600), ('\u{201C}', 600), ('\u{201D}', 600), ('\u{201E}', 600),
('\u{2020}', 600), ('\u{2021}', 600), ('\u{2022}', 600), ('\u{2026}', 600), ('\u{2030}', 600), ('\u{2039}', 600),
('\u{203A}', 600), ('\u{2044}', 600), ('\u{20AC}', 600), ('\u{2122}', 600), ('\u{2212}', 600), ('\u{FB01}', 600),
('\u{FB02}', 600),
];
#[rustfmt::skip]
static HELVETICA: &[(char, u16)] = &[
(' ', 278), ('!', 278), ('"', 355), ('#', 556), ('$', 556), ('%', 889),
('&', 667), ('\'', 191), ('(', 333), (')', 333), ('*', 389), ('+', 584),
(',', 278), ('-', 333), ('.', 278), ('/', 278), ('0', 556), ('1', 556),
('2', 556), ('3', 556), ('4', 556), ('5', 556), ('6', 556), ('7', 556),
('8', 556), ('9', 556), (':', 278), (';', 278), ('<', 584), ('=', 584),
('>', 584), ('?', 556), ('@', 1015), ('A', 667), ('B', 667), ('C', 722),
('D', 722), ('E', 667), ('F', 611), ('G', 778), ('H', 722), ('I', 278),
('J', 500), ('K', 667), ('L', 556), ('M', 833), ('N', 722), ('O', 778),
('P', 667), ('Q', 778), ('R', 722), ('S', 667), ('T', 611), ('U', 722),
('V', 667), ('W', 944), ('X', 667), ('Y', 667), ('Z', 611), ('[', 278),
('\\', 278), (']', 278), ('^', 469), ('_', 556), ('`', 333), ('a', 556),
('b', 556), ('c', 500), ('d', 556), ('e', 556), ('f', 278), ('g', 556),
('h', 556), ('i', 222), ('j', 222), ('k', 500), ('l', 222), ('m', 833),
('n', 556), ('o', 556), ('p', 556), ('q', 556), ('r', 333), ('s', 500),
('t', 278), ('u', 556), ('v', 500), ('w', 722), ('x', 500), ('y', 500),
('z', 500), ('{', 334), ('|', 260), ('}', 334), ('~', 584), ('\u{00A1}', 333),
('\u{00A2}', 556), ('\u{00A3}', 556), ('\u{00A4}', 556), ('\u{00A5}', 556), ('\u{00A6}', 260), ('\u{00A7}', 556),
('\u{00A8}', 333), ('\u{00A9}', 737), ('\u{00AA}', 370), ('\u{00AB}', 556), ('\u{00AC}', 584), ('\u{00AE}', 737),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 584), ('\u{00B2}', 333), ('\u{00B3}', 333), ('\u{00B4}', 333),
('\u{00B5}', 556), ('\u{00B6}', 537), ('\u{00B7}', 278), ('\u{00B8}', 333), ('\u{00B9}', 333), ('\u{00BA}', 365),
('\u{00BB}', 556), ('\u{00BC}', 834), ('\u{00BD}', 834), ('\u{00BE}', 834), ('\u{00BF}', 611), ('\u{00C0}', 667),
('\u{00C1}', 667), ('\u{00C2}', 667), ('\u{00C3}', 667), ('\u{00C4}', 667), ('\u{00C5}', 667), ('\u{00C6}', 1000),
('\u{00C7}', 722), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 278),
('\u{00CD}', 278), ('\u{00CE}', 278), ('\u{00CF}', 278), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 778),
('\u{00D3}', 778), ('\u{00D4}', 778), ('\u{00D5}', 778), ('\u{00D6}', 778), ('\u{00D7}', 584), ('\u{00D8}', 778),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 667), ('\u{00DE}', 667),
('\u{00DF}', 611), ('\u{00E0}', 556), ('\u{00E1}', 556), ('\u{00E2}', 556), ('\u{00E3}', 556), ('\u{00E4}', 556),
('\u{00E5}', 556), ('\u{00E6}', 889), ('\u{00E7}', 500), ('\u{00E8}', 556), ('\u{00E9}', 556), ('\u{00EA}', 556),
('\u{00EB}', 556), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 556),
('\u{00F1}', 556), ('\u{00F2}', 556), ('\u{00F3}', 556), ('\u{00F4}', 556), ('\u{00F5}', 556), ('\u{00F6}', 556),
('\u{00F7}', 584), ('\u{00F8}', 611), ('\u{00F9}', 556), ('\u{00FA}', 556), ('\u{00FB}', 556), ('\u{00FC}', 556),
('\u{00FD}', 500), ('\u{00FE}', 556), ('\u{00FF}', 500), ('\u{0131}', 278), ('\u{0141}', 556), ('\u{0142}', 222),
('\u{0152}', 1000), ('\u{0153}', 944), ('\u{0160}', 667), ('\u{0161}', 500), ('\u{0178}', 667), ('\u{017D}', 611),
('\u{017E}', 500), ('\u{0192}', 556), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 556), ('\u{2014}', 1000),
('\u{2018}', 222), ('\u{2019}', 222), ('\u{201A}', 222), ('\u{201C}', 333), ('\u{201D}', 333), ('\u{201E}', 333),
('\u{2020}', 556), ('\u{2021}', 556), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 556), ('\u{2122}', 1000), ('\u{2212}', 584), ('\u{FB01}', 500),
('\u{FB02}', 500),
];
#[rustfmt::skip]
static HELVETICA_BOLD: &[(char, u16)] = &[
(' ', 278), ('!', 333), ('"', 474), ('#', 556), ('$', 556), ('%', 889),
('&', 722), ('\'', 238), ('(', 333), (')', 333), ('*', 389), ('+', 584),
(',', 278), ('-', 333), ('.', 278), ('/', 278), ('0', 556), ('1', 556),
('2', 556), ('3', 556), ('4', 556), ('5', 556), ('6', 556), ('7', 556),
('8', 556), ('9', 556), (':', 333), (';', 333), ('<', 584), ('=', 584),
('>', 584), ('?', 611), ('@', 975), ('A', 722), ('B', 722), ('C', 722),
('D', 722), ('E', 667), ('F', 611), ('G', 778), ('H', 722), ('I', 278),
('J', 556), ('K', 722), ('L', 611), ('M', 833), ('N', 722), ('O', 778),
('P', 667), ('Q', 778), ('R', 722), ('S', 667), ('T', 611), ('U', 722),
('V', 667), ('W', 944), ('X', 667), ('Y', 667), ('Z', 611), ('[', 333),
('\\', 278), (']', 333), ('^', 584), ('_', 556), ('`', 333), ('a', 556),
('b', 611), ('c', 556), ('d', 611), ('e', 556), ('f', 333), ('g', 611),
('h', 611), ('i', 278), ('j', 278), ('k', 556), ('l', 278), ('m', 889),
('n', 611), ('o', 611), ('p', 611), ('q', 611), ('r', 389), ('s', 556),
('t', 333), ('u', 611), ('v', 556), ('w', 778), ('x', 556), ('y', 556),
('z', 500), ('{', 389), ('|', 280), ('}', 389), ('~', 584), ('\u{00A1}', 333),
('\u{00A2}', 556), ('\u{00A3}', 556), ('\u{00A4}', 556), ('\u{00A5}', 556), ('\u{00A6}', 280), ('\u{00A7}', 556),
('\u{00A8}', 333), ('\u{00A9}', 737), ('\u{00AA}', 370), ('\u{00AB}', 556), ('\u{00AC}', 584), ('\u{00AE}', 737),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 584), ('\u{00B2}', 333), ('\u{00B3}', 333), ('\u{00B4}', 333),
('\u{00B5}', 611), ('\u{00B6}', 556), ('\u{00B7}', 278), ('\u{00B8}', 333), ('\u{00B9}', 333), ('\u{00BA}', 365),
('\u{00BB}', 556), ('\u{00BC}', 834), ('\u{00BD}', 834), ('\u{00BE}', 834), ('\u{00BF}', 611), ('\u{00C0}', 722),
('\u{00C1}', 722), ('\u{00C2}', 722), ('\u{00C3}', 722), ('\u{00C4}', 722), ('\u{00C5}', 722), ('\u{00C6}', 1000),
('\u{00C7}', 722), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 278),
('\u{00CD}', 278), ('\u{00CE}', 278), ('\u{00CF}', 278), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 778),
('\u{00D3}', 778), ('\u{00D4}', 778), ('\u{00D5}', 778), ('\u{00D6}', 778), ('\u{00D7}', 584), ('\u{00D8}', 778),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 667), ('\u{00DE}', 667),
('\u{00DF}', 611), ('\u{00E0}', 556), ('\u{00E1}', 556), ('\u{00E2}', 556), ('\u{00E3}', 556), ('\u{00E4}', 556),
('\u{00E5}', 556), ('\u{00E6}', 889), ('\u{00E7}', 556), ('\u{00E8}', 556), ('\u{00E9}', 556), ('\u{00EA}', 556),
('\u{00EB}', 556), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 611),
('\u{00F1}', 611), ('\u{00F2}', 611), ('\u{00F3}', 611), ('\u{00F4}', 611), ('\u{00F5}', 611), ('\u{00F6}', 611),
('\u{00F7}', 584), ('\u{00F8}', 611), ('\u{00F9}', 611), ('\u{00FA}', 611), ('\u{00FB}', 611), ('\u{00FC}', 611),
('\u{00FD}', 556), ('\u{00FE}', 611), ('\u{00FF}', 556), ('\u{0131}', 278), ('\u{0141}', 611), ('\u{0142}', 278),
('\u{0152}', 1000), ('\u{0153}', 944), ('\u{0160}', 667), ('\u{0161}', 556), ('\u{0178}', 667), ('\u{017D}', 611),
('\u{017E}', 500), ('\u{0192}', 556), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 556), ('\u{2014}', 1000),
('\u{2018}', 278), ('\u{2019}', 278), ('\u{201A}', 278), ('\u{201C}', 500), ('\u{201D}', 500), ('\u{201E}', 500),
('\u{2020}', 556), ('\u{2021}', 556), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 556), ('\u{2122}', 1000), ('\u{2212}', 584), ('\u{FB01}', 611),
('\u{FB02}', 611),
];
#[rustfmt::skip]
static HELVETICA_OBLIQUE: &[(char, u16)] = &[
(' ', 278), ('!', 278), ('"', 355), ('#', 556), ('$', 556), ('%', 889),
('&', 667), ('\'', 191), ('(', 333), (')', 333), ('*', 389), ('+', 584),
(',', 278), ('-', 333), ('.', 278), ('/', 278), ('0', 556), ('1', 556),
('2', 556), ('3', 556), ('4', 556), ('5', 556), ('6', 556), ('7', 556),
('8', 556), ('9', 556), (':', 278), (';', 278), ('<', 584), ('=', 584),
('>', 584), ('?', 556), ('@', 1015), ('A', 667), ('B', 667), ('C', 722),
('D', 722), ('E', 667), ('F', 611), ('G', 778), ('H', 722), ('I', 278),
('J', 500), ('K', 667), ('L', 556), ('M', 833), ('N', 722), ('O', 778),
('P', 667), ('Q', 778), ('R', 722), ('S', 667), ('T', 611), ('U', 722),
('V', 667), ('W', 944), ('X', 667), ('Y', 667), ('Z', 611), ('[', 278),
('\\', 278), (']', 278), ('^', 469), ('_', 556), ('`', 333), ('a', 556),
('b', 556), ('c', 500), ('d', 556), ('e', 556), ('f', 278), ('g', 556),
('h', 556), ('i', 222), ('j', 222), ('k', 500), ('l', 222), ('m', 833),
('n', 556), ('o', 556), ('p', 556), ('q', 556), ('r', 333), ('s', 500),
('t', 278), ('u', 556), ('v', 500), ('w', 722), ('x', 500), ('y', 500),
('z', 500), ('{', 334), ('|', 260), ('}', 334), ('~', 584), ('\u{00A1}', 333),
('\u{00A2}', 556), ('\u{00A3}', 556), ('\u{00A4}', 556), ('\u{00A5}', 556), ('\u{00A6}', 260), ('\u{00A7}', 556),
('\u{00A8}', 333), ('\u{00A9}', 737), ('\u{00AA}', 370), ('\u{00AB}', 556), ('\u{00AC}', 584), ('\u{00AE}', 737),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 584), ('\u{00B2}', 333), ('\u{00B3}', 333), ('\u{00B4}', 333),
('\u{00B5}', 556), ('\u{00B6}', 537), ('\u{00B7}', 278), ('\u{00B8}', 333), ('\u{00B9}', 333), ('\u{00BA}', 365),
('\u{00BB}', 556), ('\u{00BC}', 834), ('\u{00BD}', 834), ('\u{00BE}', 834), ('\u{00BF}', 611), ('\u{00C0}', 667),
('\u{00C1}', 667), ('\u{00C2}', 667), ('\u{00C3}', 667), ('\u{00C4}', 667), ('\u{00C5}', 667), ('\u{00C6}', 1000),
('\u{00C7}', 722), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 278),
('\u{00CD}', 278), ('\u{00CE}', 278), ('\u{00CF}', 278), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 778),
('\u{00D3}', 778), ('\u{00D4}', 778), ('\u{00D5}', 778), ('\u{00D6}', 778), ('\u{00D7}', 584), ('\u{00D8}', 778),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 667), ('\u{00DE}', 667),
('\u{00DF}', 611), ('\u{00E0}', 556), ('\u{00E1}', 556), ('\u{00E2}', 556), ('\u{00E3}', 556), ('\u{00E4}', 556),
('\u{00E5}', 556), ('\u{00E6}', 889), ('\u{00E7}', 500), ('\u{00E8}', 556), ('\u{00E9}', 556), ('\u{00EA}', 556),
('\u{00EB}', 556), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 556),
('\u{00F1}', 556), ('\u{00F2}', 556), ('\u{00F3}', 556), ('\u{00F4}', 556), ('\u{00F5}', 556), ('\u{00F6}', 556),
('\u{00F7}', 584), ('\u{00F8}', 611), ('\u{00F9}', 556), ('\u{00FA}', 556), ('\u{00FB}', 556), ('\u{00FC}', 556),
('\u{00FD}', 500), ('\u{00FE}', 556), ('\u{00FF}', 500), ('\u{0131}', 278), ('\u{0141}', 556), ('\u{0142}', 222),
('\u{0152}', 1000), ('\u{0153}', 944), ('\u{0160}', 667), ('\u{0161}', 500), ('\u{0178}', 667), ('\u{017D}', 611),
('\u{017E}', 500), ('\u{0192}', 556), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 556), ('\u{2014}', 1000),
('\u{2018}', 222), ('\u{2019}', 222), ('\u{201A}', 222), ('\u{201C}', 333), ('\u{201D}', 333), ('\u{201E}', 333),
('\u{2020}', 556), ('\u{2021}', 556), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 556), ('\u{2122}', 1000), ('\u{2212}', 584), ('\u{FB01}', 500),
('\u{FB02}', 500),
];
#[rustfmt::skip]
static HELVETICA_BOLDOBLIQUE: &[(char, u16)] = &[
(' ', 278), ('!', 333), ('"', 474), ('#', 556), ('$', 556), ('%', 889),
('&', 722), ('\'', 238), ('(', 333), (')', 333), ('*', 389), ('+', 584),
(',', 278), ('-', 333), ('.', 278), ('/', 278), ('0', 556), ('1', 556),
('2', 556), ('3', 556), ('4', 556), ('5', 556), ('6', 556), ('7', 556),
('8', 556), ('9', 556), (':', 333), (';', 333), ('<', 584), ('=', 584),
('>', 584), ('?', 611), ('@', 975), ('A', 722), ('B', 722), ('C', 722),
('D', 722), ('E', 667), ('F', 611), ('G', 778), ('H', 722), ('I', 278),
('J', 556), ('K', 722), ('L', 611), ('M', 833), ('N', 722), ('O', 778),
('P', 667), ('Q', 778), ('R', 722), ('S', 667), ('T', 611), ('U', 722),
('V', 667), ('W', 944), ('X', 667), ('Y', 667), ('Z', 611), ('[', 333),
('\\', 278), (']', 333), ('^', 584), ('_', 556), ('`', 333), ('a', 556),
('b', 611), ('c', 556), ('d', 611), ('e', 556), ('f', 333), ('g', 611),
('h', 611), ('i', 278), ('j', 278), ('k', 556), ('l', 278), ('m', 889),
('n', 611), ('o', 611), ('p', 611), ('q', 611), ('r', 389), ('s', 556),
('t', 333), ('u', 611), ('v', 556), ('w', 778), ('x', 556), ('y', 556),
('z', 500), ('{', 389), ('|', 280), ('}', 389), ('~', 584), ('\u{00A1}', 333),
('\u{00A2}', 556), ('\u{00A3}', 556), ('\u{00A4}', 556), ('\u{00A5}', 556), ('\u{00A6}', 280), ('\u{00A7}', 556),
('\u{00A8}', 333), ('\u{00A9}', 737), ('\u{00AA}', 370), ('\u{00AB}', 556), ('\u{00AC}', 584), ('\u{00AE}', 737),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 584), ('\u{00B2}', 333), ('\u{00B3}', 333), ('\u{00B4}', 333),
('\u{00B5}', 611), ('\u{00B6}', 556), ('\u{00B7}', 278), ('\u{00B8}', 333), ('\u{00B9}', 333), ('\u{00BA}', 365),
('\u{00BB}', 556), ('\u{00BC}', 834), ('\u{00BD}', 834), ('\u{00BE}', 834), ('\u{00BF}', 611), ('\u{00C0}', 722),
('\u{00C1}', 722), ('\u{00C2}', 722), ('\u{00C3}', 722), ('\u{00C4}', 722), ('\u{00C5}', 722), ('\u{00C6}', 1000),
('\u{00C7}', 722), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 278),
('\u{00CD}', 278), ('\u{00CE}', 278), ('\u{00CF}', 278), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 778),
('\u{00D3}', 778), ('\u{00D4}', 778), ('\u{00D5}', 778), ('\u{00D6}', 778), ('\u{00D7}', 584), ('\u{00D8}', 778),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 667), ('\u{00DE}', 667),
('\u{00DF}', 611), ('\u{00E0}', 556), ('\u{00E1}', 556), ('\u{00E2}', 556), ('\u{00E3}', 556), ('\u{00E4}', 556),
('\u{00E5}', 556), ('\u{00E6}', 889), ('\u{00E7}', 556), ('\u{00E8}', 556), ('\u{00E9}', 556), ('\u{00EA}', 556),
('\u{00EB}', 556), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 611),
('\u{00F1}', 611), ('\u{00F2}', 611), ('\u{00F3}', 611), ('\u{00F4}', 611), ('\u{00F5}', 611), ('\u{00F6}', 611),
('\u{00F7}', 584), ('\u{00F8}', 611), ('\u{00F9}', 611), ('\u{00FA}', 611), ('\u{00FB}', 611), ('\u{00FC}', 611),
('\u{00FD}', 556), ('\u{00FE}', 611), ('\u{00FF}', 556), ('\u{0131}', 278), ('\u{0141}', 611), ('\u{0142}', 278),
('\u{0152}', 1000), ('\u{0153}', 944), ('\u{0160}', 667), ('\u{0161}', 556), ('\u{0178}', 667), ('\u{017D}', 611),
('\u{017E}', 500), ('\u{0192}', 556), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 556), ('\u{2014}', 1000),
('\u{2018}', 278), ('\u{2019}', 278), ('\u{201A}', 278), ('\u{201C}', 500), ('\u{201D}', 500), ('\u{201E}', 500),
('\u{2020}', 556), ('\u{2021}', 556), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 556), ('\u{2122}', 1000), ('\u{2212}', 584), ('\u{FB01}', 611),
('\u{FB02}', 611),
];
#[rustfmt::skip]
static TIMES_ROMAN: &[(char, u16)] = &[
(' ', 250), ('!', 333), ('"', 408), ('#', 500), ('$', 500), ('%', 833),
('&', 778), ('\'', 180), ('(', 333), (')', 333), ('*', 500), ('+', 564),
(',', 250), ('-', 333), ('.', 250), ('/', 278), ('0', 500), ('1', 500),
('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500), ('7', 500),
('8', 500), ('9', 500), (':', 278), (';', 278), ('<', 564), ('=', 564),
('>', 564), ('?', 444), ('@', 921), ('A', 722), ('B', 667), ('C', 667),
('D', 722), ('E', 611), ('F', 556), ('G', 722), ('H', 722), ('I', 333),
('J', 389), ('K', 722), ('L', 611), ('M', 889), ('N', 722), ('O', 722),
('P', 556), ('Q', 722), ('R', 667), ('S', 556), ('T', 611), ('U', 722),
('V', 722), ('W', 944), ('X', 722), ('Y', 722), ('Z', 611), ('[', 333),
('\\', 278), (']', 333), ('^', 469), ('_', 500), ('`', 333), ('a', 444),
('b', 500), ('c', 444), ('d', 500), ('e', 444), ('f', 333), ('g', 500),
('h', 500), ('i', 278), ('j', 278), ('k', 500), ('l', 278), ('m', 778),
('n', 500), ('o', 500), ('p', 500), ('q', 500), ('r', 333), ('s', 389),
('t', 278), ('u', 500), ('v', 500), ('w', 722), ('x', 500), ('y', 500),
('z', 444), ('{', 480), ('|', 200), ('}', 480), ('~', 541), ('\u{00A1}', 333),
('\u{00A2}', 500), ('\u{00A3}', 500), ('\u{00A4}', 500), ('\u{00A5}', 500), ('\u{00A6}', 200), ('\u{00A7}', 500),
('\u{00A8}', 333), ('\u{00A9}', 760), ('\u{00AA}', 276), ('\u{00AB}', 500), ('\u{00AC}', 564), ('\u{00AE}', 760),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 564), ('\u{00B2}', 300), ('\u{00B3}', 300), ('\u{00B4}', 333),
('\u{00B5}', 500), ('\u{00B6}', 453), ('\u{00B7}', 250), ('\u{00B8}', 333), ('\u{00B9}', 300), ('\u{00BA}', 310),
('\u{00BB}', 500), ('\u{00BC}', 750), ('\u{00BD}', 750), ('\u{00BE}', 750), ('\u{00BF}', 444), ('\u{00C0}', 722),
('\u{00C1}', 722), ('\u{00C2}', 722), ('\u{00C3}', 722), ('\u{00C4}', 722), ('\u{00C5}', 722), ('\u{00C6}', 889),
('\u{00C7}', 667), ('\u{00C8}', 611), ('\u{00C9}', 611), ('\u{00CA}', 611), ('\u{00CB}', 611), ('\u{00CC}', 333),
('\u{00CD}', 333), ('\u{00CE}', 333), ('\u{00CF}', 333), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 722),
('\u{00D3}', 722), ('\u{00D4}', 722), ('\u{00D5}', 722), ('\u{00D6}', 722), ('\u{00D7}', 564), ('\u{00D8}', 722),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 722), ('\u{00DE}', 556),
('\u{00DF}', 500), ('\u{00E0}', 444), ('\u{00E1}', 444), ('\u{00E2}', 444), ('\u{00E3}', 444), ('\u{00E4}', 444),
('\u{00E5}', 444), ('\u{00E6}', 667), ('\u{00E7}', 444), ('\u{00E8}', 444), ('\u{00E9}', 444), ('\u{00EA}', 444),
('\u{00EB}', 444), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 500),
('\u{00F1}', 500), ('\u{00F2}', 500), ('\u{00F3}', 500), ('\u{00F4}', 500), ('\u{00F5}', 500), ('\u{00F6}', 500),
('\u{00F7}', 564), ('\u{00F8}', 500), ('\u{00F9}', 500), ('\u{00FA}', 500), ('\u{00FB}', 500), ('\u{00FC}', 500),
('\u{00FD}', 500), ('\u{00FE}', 500), ('\u{00FF}', 500), ('\u{0131}', 278), ('\u{0141}', 611), ('\u{0142}', 278),
('\u{0152}', 889), ('\u{0153}', 722), ('\u{0160}', 556), ('\u{0161}', 389), ('\u{0178}', 722), ('\u{017D}', 611),
('\u{017E}', 444), ('\u{0192}', 500), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 500), ('\u{2014}', 1000),
('\u{2018}', 333), ('\u{2019}', 333), ('\u{201A}', 333), ('\u{201C}', 444), ('\u{201D}', 444), ('\u{201E}', 444),
('\u{2020}', 500), ('\u{2021}', 500), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 500), ('\u{2122}', 980), ('\u{2212}', 564), ('\u{FB01}', 556),
('\u{FB02}', 556),
];
#[rustfmt::skip]
static TIMES_BOLD: &[(char, u16)] = &[
(' ', 250), ('!', 333), ('"', 555), ('#', 500), ('$', 500), ('%', 1000),
('&', 833), ('\'', 278), ('(', 333), (')', 333), ('*', 500), ('+', 570),
(',', 250), ('-', 333), ('.', 250), ('/', 278), ('0', 500), ('1', 500),
('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500), ('7', 500),
('8', 500), ('9', 500), (':', 333), (';', 333), ('<', 570), ('=', 570),
('>', 570), ('?', 500), ('@', 930), ('A', 722), ('B', 667), ('C', 722),
('D', 722), ('E', 667), ('F', 611), ('G', 778), ('H', 778), ('I', 389),
('J', 500), ('K', 778), ('L', 667), ('M', 944), ('N', 722), ('O', 778),
('P', 611), ('Q', 778), ('R', 722), ('S', 556), ('T', 667), ('U', 722),
('V', 722), ('W', 1000), ('X', 722), ('Y', 722), ('Z', 667), ('[', 333),
('\\', 278), (']', 333), ('^', 581), ('_', 500), ('`', 333), ('a', 500),
('b', 556), ('c', 444), ('d', 556), ('e', 444), ('f', 333), ('g', 500),
('h', 556), ('i', 278), ('j', 333), ('k', 556), ('l', 278), ('m', 833),
('n', 556), ('o', 500), ('p', 556), ('q', 556), ('r', 444), ('s', 389),
('t', 333), ('u', 556), ('v', 500), ('w', 722), ('x', 500), ('y', 500),
('z', 444), ('{', 394), ('|', 220), ('}', 394), ('~', 520), ('\u{00A1}', 333),
('\u{00A2}', 500), ('\u{00A3}', 500), ('\u{00A4}', 500), ('\u{00A5}', 500), ('\u{00A6}', 220), ('\u{00A7}', 500),
('\u{00A8}', 333), ('\u{00A9}', 747), ('\u{00AA}', 300), ('\u{00AB}', 500), ('\u{00AC}', 570), ('\u{00AE}', 747),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 570), ('\u{00B2}', 300), ('\u{00B3}', 300), ('\u{00B4}', 333),
('\u{00B5}', 556), ('\u{00B6}', 540), ('\u{00B7}', 250), ('\u{00B8}', 333), ('\u{00B9}', 300), ('\u{00BA}', 330),
('\u{00BB}', 500), ('\u{00BC}', 750), ('\u{00BD}', 750), ('\u{00BE}', 750), ('\u{00BF}', 500), ('\u{00C0}', 722),
('\u{00C1}', 722), ('\u{00C2}', 722), ('\u{00C3}', 722), ('\u{00C4}', 722), ('\u{00C5}', 722), ('\u{00C6}', 1000),
('\u{00C7}', 722), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 389),
('\u{00CD}', 389), ('\u{00CE}', 389), ('\u{00CF}', 389), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 778),
('\u{00D3}', 778), ('\u{00D4}', 778), ('\u{00D5}', 778), ('\u{00D6}', 778), ('\u{00D7}', 570), ('\u{00D8}', 778),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 722), ('\u{00DE}', 611),
('\u{00DF}', 556), ('\u{00E0}', 500), ('\u{00E1}', 500), ('\u{00E2}', 500), ('\u{00E3}', 500), ('\u{00E4}', 500),
('\u{00E5}', 500), ('\u{00E6}', 722), ('\u{00E7}', 444), ('\u{00E8}', 444), ('\u{00E9}', 444), ('\u{00EA}', 444),
('\u{00EB}', 444), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 500),
('\u{00F1}', 556), ('\u{00F2}', 500), ('\u{00F3}', 500), ('\u{00F4}', 500), ('\u{00F5}', 500), ('\u{00F6}', 500),
('\u{00F7}', 570), ('\u{00F8}', 500), ('\u{00F9}', 556), ('\u{00FA}', 556), ('\u{00FB}', 556), ('\u{00FC}', 556),
('\u{00FD}', 500), ('\u{00FE}', 556), ('\u{00FF}', 500), ('\u{0131}', 278), ('\u{0141}', 667), ('\u{0142}', 278),
('\u{0152}', 1000), ('\u{0153}', 722), ('\u{0160}', 556), ('\u{0161}', 389), ('\u{0178}', 722), ('\u{017D}', 667),
('\u{017E}', 444), ('\u{0192}', 500), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 500), ('\u{2014}', 1000),
('\u{2018}', 333), ('\u{2019}', 333), ('\u{201A}', 333), ('\u{201C}', 500), ('\u{201D}', 500), ('\u{201E}', 500),
('\u{2020}', 500), ('\u{2021}', 500), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 500), ('\u{2122}', 1000), ('\u{2212}', 570), ('\u{FB01}', 556),
('\u{FB02}', 556),
];
#[rustfmt::skip]
static TIMES_ITALIC: &[(char, u16)] = &[
(' ', 250), ('!', 333), ('"', 420), ('#', 500), ('$', 500), ('%', 833),
('&', 778), ('\'', 214), ('(', 333), (')', 333), ('*', 500), ('+', 675),
(',', 250), ('-', 333), ('.', 250), ('/', 278), ('0', 500), ('1', 500),
('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500), ('7', 500),
('8', 500), ('9', 500), (':', 333), (';', 333), ('<', 675), ('=', 675),
('>', 675), ('?', 500), ('@', 920), ('A', 611), ('B', 611), ('C', 667),
('D', 722), ('E', 611), ('F', 611), ('G', 722), ('H', 722), ('I', 333),
('J', 444), ('K', 667), ('L', 556), ('M', 833), ('N', 667), ('O', 722),
('P', 611), ('Q', 722), ('R', 611), ('S', 500), ('T', 556), ('U', 722),
('V', 611), ('W', 833), ('X', 611), ('Y', 556), ('Z', 556), ('[', 389),
('\\', 278), (']', 389), ('^', 422), ('_', 500), ('`', 333), ('a', 500),
('b', 500), ('c', 444), ('d', 500), ('e', 444), ('f', 278), ('g', 500),
('h', 500), ('i', 278), ('j', 278), ('k', 444), ('l', 278), ('m', 722),
('n', 500), ('o', 500), ('p', 500), ('q', 500), ('r', 389), ('s', 389),
('t', 278), ('u', 500), ('v', 444), ('w', 667), ('x', 444), ('y', 444),
('z', 389), ('{', 400), ('|', 275), ('}', 400), ('~', 541), ('\u{00A1}', 389),
('\u{00A2}', 500), ('\u{00A3}', 500), ('\u{00A4}', 500), ('\u{00A5}', 500), ('\u{00A6}', 275), ('\u{00A7}', 500),
('\u{00A8}', 333), ('\u{00A9}', 760), ('\u{00AA}', 276), ('\u{00AB}', 500), ('\u{00AC}', 675), ('\u{00AE}', 760),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 675), ('\u{00B2}', 300), ('\u{00B3}', 300), ('\u{00B4}', 333),
('\u{00B5}', 500), ('\u{00B6}', 523), ('\u{00B7}', 250), ('\u{00B8}', 333), ('\u{00B9}', 300), ('\u{00BA}', 310),
('\u{00BB}', 500), ('\u{00BC}', 750), ('\u{00BD}', 750), ('\u{00BE}', 750), ('\u{00BF}', 500), ('\u{00C0}', 611),
('\u{00C1}', 611), ('\u{00C2}', 611), ('\u{00C3}', 611), ('\u{00C4}', 611), ('\u{00C5}', 611), ('\u{00C6}', 889),
('\u{00C7}', 667), ('\u{00C8}', 611), ('\u{00C9}', 611), ('\u{00CA}', 611), ('\u{00CB}', 611), ('\u{00CC}', 333),
('\u{00CD}', 333), ('\u{00CE}', 333), ('\u{00CF}', 333), ('\u{00D0}', 722), ('\u{00D1}', 667), ('\u{00D2}', 722),
('\u{00D3}', 722), ('\u{00D4}', 722), ('\u{00D5}', 722), ('\u{00D6}', 722), ('\u{00D7}', 675), ('\u{00D8}', 722),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 556), ('\u{00DE}', 611),
('\u{00DF}', 500), ('\u{00E0}', 500), ('\u{00E1}', 500), ('\u{00E2}', 500), ('\u{00E3}', 500), ('\u{00E4}', 500),
('\u{00E5}', 500), ('\u{00E6}', 667), ('\u{00E7}', 444), ('\u{00E8}', 444), ('\u{00E9}', 444), ('\u{00EA}', 444),
('\u{00EB}', 444), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 500),
('\u{00F1}', 500), ('\u{00F2}', 500), ('\u{00F3}', 500), ('\u{00F4}', 500), ('\u{00F5}', 500), ('\u{00F6}', 500),
('\u{00F7}', 675), ('\u{00F8}', 500), ('\u{00F9}', 500), ('\u{00FA}', 500), ('\u{00FB}', 500), ('\u{00FC}', 500),
('\u{00FD}', 444), ('\u{00FE}', 500), ('\u{00FF}', 444), ('\u{0131}', 278), ('\u{0141}', 556), ('\u{0142}', 278),
('\u{0152}', 944), ('\u{0153}', 667), ('\u{0160}', 500), ('\u{0161}', 389), ('\u{0178}', 556), ('\u{017D}', 556),
('\u{017E}', 389), ('\u{0192}', 500), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 500), ('\u{2014}', 889),
('\u{2018}', 333), ('\u{2019}', 333), ('\u{201A}', 333), ('\u{201C}', 556), ('\u{201D}', 556), ('\u{201E}', 556),
('\u{2020}', 500), ('\u{2021}', 500), ('\u{2022}', 350), ('\u{2026}', 889), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 500), ('\u{2122}', 980), ('\u{2212}', 675), ('\u{FB01}', 500),
('\u{FB02}', 500),
];
#[rustfmt::skip]
static TIMES_BOLDITALIC: &[(char, u16)] = &[
(' ', 250), ('!', 389), ('"', 555), ('#', 500), ('$', 500), ('%', 833),
('&', 778), ('\'', 278), ('(', 333), (')', 333), ('*', 500), ('+', 570),
(',', 250), ('-', 333), ('.', 250), ('/', 278), ('0', 500), ('1', 500),
('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500), ('7', 500),
('8', 500), ('9', 500), (':', 333), (';', 333), ('<', 570), ('=', 570),
('>', 570), ('?', 500), ('@', 832), ('A', 667), ('B', 667), ('C', 667),
('D', 722), ('E', 667), ('F', 667), ('G', 722), ('H', 778), ('I', 389),
('J', 500), ('K', 667), ('L', 611), ('M', 889), ('N', 722), ('O', 722),
('P', 611), ('Q', 722), ('R', 667), ('S', 556), ('T', 611), ('U', 722),
('V', 667), ('W', 889), ('X', 667), ('Y', 611), ('Z', 611), ('[', 333),
('\\', 278), (']', 333), ('^', 570), ('_', 500), ('`', 333), ('a', 500),
('b', 500), ('c', 444), ('d', 500), ('e', 444), ('f', 333), ('g', 500),
('h', 556), ('i', 278), ('j', 278), ('k', 500), ('l', 278), ('m', 778),
('n', 556), ('o', 500), ('p', 500), ('q', 500), ('r', 389), ('s', 389),
('t', 278), ('u', 556), ('v', 444), ('w', 667), ('x', 500), ('y', 444),
('z', 389), ('{', 348), ('|', 220), ('}', 348), ('~', 570), ('\u{00A1}', 389),
('\u{00A2}', 500), ('\u{00A3}', 500), ('\u{00A4}', 500), ('\u{00A5}', 500), ('\u{00A6}', 220), ('\u{00A7}', 500),
('\u{00A8}', 333), ('\u{00A9}', 747), ('\u{00AA}', 266), ('\u{00AB}', 500), ('\u{00AC}', 606), ('\u{00AE}', 747),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 570), ('\u{00B2}', 300), ('\u{00B3}', 300), ('\u{00B4}', 333),
('\u{00B5}', 576), ('\u{00B6}', 500), ('\u{00B7}', 250), ('\u{00B8}', 333), ('\u{00B9}', 300), ('\u{00BA}', 300),
('\u{00BB}', 500), ('\u{00BC}', 750), ('\u{00BD}', 750), ('\u{00BE}', 750), ('\u{00BF}', 500), ('\u{00C0}', 667),
('\u{00C1}', 667), ('\u{00C2}', 667), ('\u{00C3}', 667), ('\u{00C4}', 667), ('\u{00C5}', 667), ('\u{00C6}', 944),
('\u{00C7}', 667), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 389),
('\u{00CD}', 389), ('\u{00CE}', 389), ('\u{00CF}', 389), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 722),
('\u{00D3}', 722), ('\u{00D4}', 722), ('\u{00D5}', 722), ('\u{00D6}', 722), ('\u{00D7}', 570), ('\u{00D8}', 722),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 611), ('\u{00DE}', 611),
('\u{00DF}', 500), ('\u{00E0}', 500), ('\u{00E1}', 500), ('\u{00E2}', 500), ('\u{00E3}', 500), ('\u{00E4}', 500),
('\u{00E5}', 500), ('\u{00E6}', 722), ('\u{00E7}', 444), ('\u{00E8}', 444), ('\u{00E9}', 444), ('\u{00EA}', 444),
('\u{00EB}', 444), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 500),
('\u{00F1}', 556), ('\u{00F2}', 500), ('\u{00F3}', 500), ('\u{00F4}', 500), ('\u{00F5}', 500), ('\u{00F6}', 500),
('\u{00F7}', 570), ('\u{00F8}', 500), ('\u{00F9}', 556), ('\u{00FA}', 556), ('\u{00FB}', 556), ('\u{00FC}', 556),
('\u{00FD}', 444), ('\u{00FE}', 500), ('\u{00FF}', 444), ('\u{0131}', 278), ('\u{0141}', 611), ('\u{0142}', 278),
('\u{0152}', 944), ('\u{0153}', 722), ('\u{0160}', 556), ('\u{0161}', 389), ('\u{0178}', 611), ('\u{017D}', 611),
('\u{017E}', 389), ('\u{0192}', 500), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 500), ('\u{2014}', 1000),
('\u{2018}', 333), ('\u{2019}', 333), ('\u{201A}', 333), ('\u{201C}', 500), ('\u{201D}', 500), ('\u{201E}', 500),
('\u{2020}', 500), ('\u{2021}', 500), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 500), ('\u{2122}', 1000), ('\u{2212}', 606), ('\u{FB01}', 556),
('\u{FB02}', 556),
];
#[rustfmt::skip]
static SYMBOL: &[(char, u16)] = &[
(' ', 250), ('!', 333), ('#', 500), ('%', 833), ('&', 778), ('(', 333),
(')', 333), ('+', 549), (',', 250), ('.', 250), ('/', 278), ('0', 500),
('1', 500), ('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500),
('7', 500), ('8', 500), ('9', 500), (':', 278), (';', 278), ('<', 549),
('=', 549), ('>', 549), ('?', 444), ('[', 333), (']', 333), ('_', 500),
('{', 480), ('|', 200), ('}', 480), ('\u{00AC}', 713), ('\u{00B0}', 400), ('\u{00B1}', 549),
('\u{00B5}', 576), ('\u{00D7}', 549), ('\u{00F7}', 549), ('\u{0192}', 500), ('\u{0391}', 722), ('\u{0392}', 667),
('\u{0393}', 603), ('\u{0395}', 611), ('\u{0396}', 611), ('\u{0397}', 722), ('\u{0398}', 741), ('\u{0399}', 333),
('\u{039A}', 722), ('\u{039B}', 686), ('\u{039C}', 889), ('\u{039D}', 722), ('\u{039E}', 645), ('\u{039F}', 722),
('\u{03A0}', 768), ('\u{03A1}', 556), ('\u{03A3}', 592), ('\u{03A4}', 611), ('\u{03A5}', 690), ('\u{03A6}', 763),
('\u{03A7}', 722), ('\u{03A8}', 795), ('\u{03B1}', 631), ('\u{03B2}', 549), ('\u{03B3}', 411), ('\u{03B4}', 494),
('\u{03B5}', 439), ('\u{03B6}', 494), ('\u{03B7}', 603), ('\u{03B8}', 521), ('\u{03B9}', 329), ('\u{03BA}', 549),
('\u{03BB}', 549), ('\u{03BD}', 521), ('\u{03BE}', 493), ('\u{03BF}', 549), ('\u{03C0}', 549), ('\u{03C1}', 549),
('\u{03C2}', 439), ('\u{03C3}', 603), ('\u{03C4}', 439), ('\u{03C5}', 576), ('\u{03C6}', 521), ('\u{03C7}', 549),
('\u{03C8}', 686), ('\u{03C9}', 686), ('\u{03D1}', 631), ('\u{03D2}', 620), ('\u{03D5}', 603), ('\u{03D6}', 713),
('\u{2022}', 460), ('\u{2026}', 1000), ('\u{2032}', 247), ('\u{2033}', 411), ('\u{2044}', 167), ('\u{20AC}', 750),
('\u{2111}', 686), ('\u{2118}', 987), ('\u{211C}', 795), ('\u{2126}', 768), ('\u{2135}', 823), ('\u{2190}', 987),
('\u{2191}', 603), ('\u{2192}', 987), ('\u{2193}', 603), ('\u{2194}', 1042), ('\u{21B5}', 658), ('\u{21D0}', 987),
('\u{21D1}', 603), ('\u{21D2}', 987), ('\u{21D3}', 603), ('\u{21D4}', 1042), ('\u{2200}', 713), ('\u{2202}', 494),
('\u{2203}', 549), ('\u{2205}', 823), ('\u{2206}', 612), ('\u{2207}', 713), ('\u{2208}', 713), ('\u{2209}', 713),
('\u{220B}', 439), ('\u{220F}', 823), ('\u{2211}', 713), ('\u{2212}', 549), ('\u{2217}', 500), ('\u{221A}', 549),
('\u{221D}', 713), ('\u{221E}', 713), ('\u{2220}', 768), ('\u{2227}', 603), ('\u{2228}', 603), ('\u{2229}', 768),
('\u{222A}', 768), ('\u{222B}', 274), ('\u{2234}', 863), ('\u{223C}', 549), ('\u{2245}', 549), ('\u{2248}', 549),
('\u{2260}', 549), ('\u{2261}', 549), ('\u{2264}', 549), ('\u{2265}', 549), ('\u{2282}', 713), ('\u{2283}', 713),
('\u{2284}', 713), ('\u{2286}', 713), ('\u{2287}', 713), ('\u{2295}', 768), ('\u{2297}', 768), ('\u{22A5}', 658),
('\u{22C5}', 250), ('\u{2320}', 686), ('\u{2321}', 686), ('\u{2329}', 329), ('\u{232A}', 329), ('\u{25CA}', 494),
('\u{2660}', 753), ('\u{2663}', 753), ('\u{2665}', 753), ('\u{2666}', 753), ('\u{F6D9}', 790), ('\u{F6DA}', 790),
('\u{F6DB}', 890), ('\u{F8E5}', 500), ('\u{F8E6}', 603), ('\u{F8E7}', 1000), ('\u{F8E8}', 790), ('\u{F8E9}', 790),
('\u{F8EA}', 786), ('\u{F8EB}', 384), ('\u{F8EC}', 384), ('\u{F8ED}', 384), ('\u{F8EE}', 384), ('\u{F8EF}', 384),
('\u{F8F0}', 384), ('\u{F8F1}', 494), ('\u{F8F2}', 494), ('\u{F8F3}', 494), ('\u{F8F4}', 494), ('\u{F8F5}', 686),
('\u{F8F6}', 384), ('\u{F8F7}', 384), ('\u{F8F8}', 384), ('\u{F8F9}', 384), ('\u{F8FA}', 384), ('\u{F8FB}', 384),
('\u{F8FC}', 494), ('\u{F8FD}', 494), ('\u{F8FE}', 494), ('\u{F8FF}', 790),
];
#[rustfmt::skip]
static ZAPFDINGBATS: &[(char, u16)] = &[
(' ', 278), ('\u{2192}', 838), ('\u{2194}', 1016), ('\u{2195}', 458), ('\u{2460}', 788), ('\u{2461}', 788),
('\u{2462}', 788), ('\u{2463}', 788), ('\u{2464}', 788), ('\u{2465}', 788), ('\u{2466}', 788), ('\u{2467}', 788),
('\u{2468}', 788), ('\u{2469}', 788), ('\u{25A0}', 761), ('\u{25B2}', 892), ('\u{25BC}', 892), ('\u{25C6}', 788),
('\u{25CF}', 791), ('\u{25D7}', 438), ('\u{2605}', 816), ('\u{260E}', 719), ('\u{261B}', 960), ('\u{261E}', 939),
('\u{2660}', 626), ('\u{2663}', 776), ('\u{2665}', 694), ('\u{2666}', 595), ('\u{2701}', 974), ('\u{2702}', 961),
('\u{2703}', 974), ('\u{2704}', 980), ('\u{2706}', 789), ('\u{2707}', 790), ('\u{2708}', 791), ('\u{2709}', 690),
('\u{270C}', 549), ('\u{270D}', 855), ('\u{270E}', 911), ('\u{270F}', 933), ('\u{2710}', 911), ('\u{2711}', 945),
('\u{2712}', 974), ('\u{2713}', 755), ('\u{2714}', 846), ('\u{2715}', 762), ('\u{2716}', 761), ('\u{2717}', 571),
('\u{2718}', 677), ('\u{2719}', 763), ('\u{271A}', 760), ('\u{271B}', 759), ('\u{271C}', 754), ('\u{271D}', 494),
('\u{271E}', 552), ('\u{271F}', 537), ('\u{2720}', 577), ('\u{2721}', 692), ('\u{2722}', 786), ('\u{2723}', 788),
('\u{2724}', 788), ('\u{2725}', 790), ('\u{2726}', 793), ('\u{2727}', 794), ('\u{2729}', 823), ('\u{272A}', 789),
('\u{272B}', 841), ('\u{272C}', 823), ('\u{272D}', 833), ('\u{272E}', 816), ('\u{272F}', 831), ('\u{2730}', 923),
('\u{2731}', 744), ('\u{2732}', 723), ('\u{2733}', 749), ('\u{2734}', 790), ('\u{2735}', 792), ('\u{2736}', 695),
('\u{2737}', 776), ('\u{2738}', 768), ('\u{2739}', 792), ('\u{273A}', 759), ('\u{273B}', 707), ('\u{273C}', 708),
('\u{273D}', 682), ('\u{273E}', 701), ('\u{273F}', 826), ('\u{2740}', 815), ('\u{2741}', 789), ('\u{2742}', 789),
('\u{2743}', 707), ('\u{2744}', 687), ('\u{2745}', 696), ('\u{2746}', 689), ('\u{2747}', 786), ('\u{2748}', 787),
('\u{2749}', 713), ('\u{274A}', 791), ('\u{274B}', 785), ('\u{274D}', 873), ('\u{274F}', 762), ('\u{2750}', 762),
('\u{2751}', 759), ('\u{2752}', 759), ('\u{2756}', 784), ('\u{2758}', 138), ('\u{2759}', 277), ('\u{275A}', 415),
('\u{275B}', 392), ('\u{275C}', 392), ('\u{275D}', 668), ('\u{275E}', 668), ('\u{2761}', 732), ('\u{2762}', 544),
('\u{2763}', 544), ('\u{2764}', 910), ('\u{2765}', 667), ('\u{2766}', 760), ('\u{2767}', 760), ('\u{2768}', 390),
('\u{2769}', 390), ('\u{276A}', 317), ('\u{276B}', 317), ('\u{276C}', 276), ('\u{276D}', 276), ('\u{276E}', 509),
('\u{276F}', 509), ('\u{2770}', 410), ('\u{2771}', 410), ('\u{2772}', 234), ('\u{2773}', 234), ('\u{2774}', 334),
('\u{2775}', 334), ('\u{2776}', 788), ('\u{2777}', 788), ('\u{2778}', 788), ('\u{2779}', 788), ('\u{277A}', 788),
('\u{277B}', 788), ('\u{277C}', 788), ('\u{277D}', 788), ('\u{277E}', 788), ('\u{277F}', 788), ('\u{2780}', 788),
('\u{2781}', 788), ('\u{2782}', 788), ('\u{2783}', 788), ('\u{2784}', 788), ('\u{2785}', 788), ('\u{2786}', 788),
('\u{2787}', 788), ('\u{2788}', 788), ('\u{2789}', 788), ('\u{278A}', 788), ('\u{278B}', 788), ('\u{278C}', 788),
('\u{278D}', 788), ('\u{278E}', 788), ('\u{278F}', 788), ('\u{2790}', 788), ('\u{2791}', 788), ('\u{2792}', 788),
('\u{2793}', 788), ('\u{2794}', 894), ('\u{2798}', 748), ('\u{2799}', 924), ('\u{279A}', 748), ('\u{279B}', 918),
('\u{279C}', 927), ('\u{279D}', 928), ('\u{279E}', 928), ('\u{279F}', 834), ('\u{27A0}', 873), ('\u{27A1}', 828),
('\u{27A2}', 924), ('\u{27A3}', 924), ('\u{27A4}', 917), ('\u{27A5}', 930), ('\u{27A6}', 931), ('\u{27A7}', 463),
('\u{27A8}', 883), ('\u{27A9}', 836), ('\u{27AA}', 836), ('\u{27AB}', 867), ('\u{27AC}', 867), ('\u{27AD}', 696),
('\u{27AE}', 696), ('\u{27AF}', 874), ('\u{27B1}', 874), ('\u{27B2}', 760), ('\u{27B3}', 946), ('\u{27B4}', 771),
('\u{27B5}', 865), ('\u{27B6}', 771), ('\u{27B7}', 888), ('\u{27B8}', 967), ('\u{27B9}', 888), ('\u{27BA}', 831),
('\u{27BB}', 873), ('\u{27BC}', 927), ('\u{27BD}', 970), ('\u{27BE}', 918),
];
#[rustfmt::skip]
static SYMBOL_ENCODING: &[(u8, char)] = &[
(0x20, ' '), (0x21, '!'), (0x22, '\u{2200}'), (0x23, '#'), (0x24, '\u{2203}'), (0x25, '%'),
(0x26, '&'), (0x27, '\u{220B}'), (0x28, '('), (0x29, ')'), (0x2A, '\u{2217}'), (0x2B, '+'),
(0x2C, ','), (0x2D, '\u{2212}'), (0x2E, '.'), (0x2F, '/'), (0x30, '0'), (0x31, '1'),
(0x32, '2'), (0x33, '3'), (0x34, '4'), (0x35, '5'), (0x36, '6'), (0x37, '7'),
(0x38, '8'), (0x39, '9'), (0x3A, ':'), (0x3B, ';'), (0x3C, '<'), (0x3D, '='),
(0x3E, '>'), (0x3F, '?'), (0x40, '\u{2245}'), (0x41, '\u{0391}'), (0x42, '\u{0392}'), (0x43, '\u{03A7}'),
(0x44, '\u{2206}'), (0x45, '\u{0395}'), (0x46, '\u{03A6}'), (0x47, '\u{0393}'), (0x48, '\u{0397}'), (0x49, '\u{0399}'),
(0x4A, '\u{03D1}'), (0x4B, '\u{039A}'), (0x4C, '\u{039B}'), (0x4D, '\u{039C}'), (0x4E, '\u{039D}'), (0x4F, '\u{039F}'),
(0x50, '\u{03A0}'), (0x51, '\u{0398}'), (0x52, '\u{03A1}'), (0x53, '\u{03A3}'), (0x54, '\u{03A4}'), (0x55, '\u{03A5}'),
(0x56, '\u{03C2}'), (0x57, '\u{2126}'), (0x58, '\u{039E}'), (0x59, '\u{03A8}'), (0x5A, '\u{0396}'), (0x5B, '['),
(0x5C, '\u{2234}'), (0x5D, ']'), (0x5E, '\u{22A5}'), (0x5F, '_'), (0x60, '\u{F8E5}'), (0x61, '\u{03B1}'),
(0x62, '\u{03B2}'), (0x63, '\u{03C7}'), (0x64, '\u{03B4}'), (0x65, '\u{03B5}'), (0x66, '\u{03C6}'), (0x67, '\u{03B3}'),
(0x68, '\u{03B7}'), (0x69, '\u{03B9}'), (0x6A, '\u{03D5}'), (0x6B, '\u{03BA}'), (0x6C, '\u{03BB}'), (0x6D, '\u{00B5}'),
(0x6E, '\u{03BD}'), (0x6F, '\u{03BF}'), (0x70, '\u{03C0}'), (0x71, '\u{03B8}'), (0x72, '\u{03C1}'), (0x73, '\u{03C3}'),
(0x74, '\u{03C4}'), (0x75, '\u{03C5}'), (0x76, '\u{03D6}'), (0x77, '\u{03C9}'), (0x78, '\u{03BE}'), (0x79, '\u{03C8}'),
(0x7A, '\u{03B6}'), (0x7B, '{'), (0x7C, '|'), (0x7D, '}'), (0x7E, '\u{223C}'), (0xA0, '\u{20AC}'),
(0xA1, '\u{03D2}'), (0xA2, '\u{2032}'), (0xA3, '\u{2264}'), (0xA4, '\u{2044}'), (0xA5, '\u{221E}'), (0xA6, '\u{0192}'),
(0xA7, '\u{2663}'), (0xA8, '\u{2666}'), (0xA9, '\u{2665}'), (0xAA, '\u{2660}'), (0xAB, '\u{2194}'), (0xAC, '\u{2190}'),
(0xAD, '\u{2191}'), (0xAE, '\u{2192}'), (0xAF, '\u{2193}'), (0xB0, '\u{00B0}'), (0xB1, '\u{00B1}'), (0xB2, '\u{2033}'),
(0xB3, '\u{2265}'), (0xB4, '\u{00D7}'), (0xB5, '\u{221D}'), (0xB6, '\u{2202}'), (0xB7, '\u{2022}'), (0xB8, '\u{00F7}'),
(0xB9, '\u{2260}'), (0xBA, '\u{2261}'), (0xBB, '\u{2248}'), (0xBC, '\u{2026}'), (0xBD, '\u{F8E6}'), (0xBE, '\u{F8E7}'),
(0xBF, '\u{21B5}'), (0xC0, '\u{2135}'), (0xC1, '\u{2111}'), (0xC2, '\u{211C}'), (0xC3, '\u{2118}'), (0xC4, '\u{2297}'),
(0xC5, '\u{2295}'), (0xC6, '\u{2205}'), (0xC7, '\u{2229}'), (0xC8, '\u{222A}'), (0xC9, '\u{2283}'), (0xCA, '\u{2287}'),
(0xCB, '\u{2284}'), (0xCC, '\u{2282}'), (0xCD, '\u{2286}'), (0xCE, '\u{2208}'), (0xCF, '\u{2209}'), (0xD0, '\u{2220}'),
(0xD1, '\u{2207}'), (0xD2, '\u{F6DA}'), (0xD3, '\u{F6D9}'), (0xD4, '\u{F6DB}'), (0xD5, '\u{220F}'), (0xD6, '\u{221A}'),
(0xD7, '\u{22C5}'), (0xD8, '\u{00AC}'), (0xD9, '\u{2227}'), (0xDA, '\u{2228}'), (0xDB, '\u{21D4}'), (0xDC, '\u{21D0}'),
(0xDD, '\u{21D1}'), (0xDE, '\u{21D2}'), (0xDF, '\u{21D3}'), (0xE0, '\u{25CA}'), (0xE1, '\u{2329}'), (0xE2, '\u{F8E8}'),
(0xE3, '\u{F8E9}'), (0xE4, '\u{F8EA}'), (0xE5, '\u{2211}'), (0xE6, '\u{F8EB}'), (0xE7, '\u{F8EC}'), (0xE8, '\u{F8ED}'),
(0xE9, '\u{F8EE}'), (0xEA, '\u{F8EF}'), (0xEB, '\u{F8F0}'), (0xEC, '\u{F8F1}'), (0xED, '\u{F8F2}'), (0xEE, '\u{F8F3}'),
(0xEF, '\u{F8F4}'), (0xF1, '\u{232A}'), (0xF2, '\u{222B}'), (0xF3, '\u{2320}'), (0xF4, '\u{F8F5}'), (0xF5, '\u{2321}'),
(0xF6, '\u{F8F6}'), (0xF7, '\u{F8F7}'), (0xF8, '\u{F8F8}'), (0xF9, '\u{F8F9}'), (0xFA, '\u{F8FA}'), (0xFB, '\u{F8FB}'),
(0xFC, '\u{F8FC}'), (0xFD, '\u{F8FD}'), (0xFE, '\u{F8FE}'),
];
#[rustfmt::skip]
static ZAPFDINGBATS_ENCODING: &[(u8, char)] = &[
(0x20, ' '), (0x21, '\u{2701}'), (0x22, '\u{2702}'), (0x23, '\u{2703}'), (0x24, '\u{2704}'), (0x25, '\u{260E}'),
(0x26, '\u{2706}'), (0x27, '\u{2707}'), (0x28, '\u{2708}'), (0x29, '\u{2709}'), (0x2A, '\u{261B}'), (0x2B, '\u{261E}'),
(0x2C, '\u{270C}'), (0x2D, '\u{270D}'), (0x2E, '\u{270E}'), (0x2F, '\u{270F}'), (0x30, '\u{2710}'), (0x31, '\u{2711}'),
(0x32, '\u{2712}'), (0x33, '\u{2713}'), (0x34, '\u{2714}'), (0x35, '\u{2715}'), (0x36, '\u{2716}'), (0x37, '\u{2717}'),
(0x38, '\u{2718}'), (0x39, '\u{2719}'), (0x3A, '\u{271A}'), (0x3B, '\u{271B}'), (0x3C, '\u{271C}'), (0x3D, '\u{271D}'),
(0x3E, '\u{271E}'), (0x3F, '\u{271F}'), (0x40, '\u{2720}'), (0x41, '\u{2721}'), (0x42, '\u{2722}'), (0x43, '\u{2723}'),
(0x44, '\u{2724}'), (0x45, '\u{2725}'), (0x46, '\u{2726}'), (0x47, '\u{2727}'), (0x48, '\u{2605}'), (0x49, '\u{2729}'),
(0x4A, '\u{272A}'), (0x4B, '\u{272B}'), (0x4C, '\u{272C}'), (0x4D, '\u{272D}'), (0x4E, '\u{272E}'), (0x4F, '\u{272F}'),
(0x50, '\u{2730}'), (0x51, '\u{2731}'), (0x52, '\u{2732}'), (0x53, '\u{2733}'), (0x54, '\u{2734}'), (0x55, '\u{2735}'),
(0x56, '\u{2736}'), (0x57, '\u{2737}'), (0x58, '\u{2738}'), (0x59, '\u{2739}'), (0x5A, '\u{273A}'), (0x5B, '\u{273B}'),
(0x5C, '\u{273C}'), (0x5D, '\u{273D}'), (0x5E, '\u{273E}'), (0x5F, '\u{273F}'), (0x60, '\u{2740}'), (0x61, '\u{2741}'),
(0x62, '\u{2742}'), (0x63, '\u{2743}'), (0x64, '\u{2744}'), (0x65, '\u{2745}'), (0x66, '\u{2746}'), (0x67, '\u{2747}'),
(0x68, '\u{2748}'), (0x69, '\u{2749}'), (0x6A, '\u{274A}'), (0x6B, '\u{274B}'), (0x6C, '\u{25CF}'), (0x6D, '\u{274D}'),
(0x6E, '\u{25A0}'), (0x6F, '\u{274F}'), (0x70, '\u{2750}'), (0x71, '\u{2751}'), (0x72, '\u{2752}'), (0x73, '\u{25B2}'),
(0x74, '\u{25BC}'), (0x75, '\u{25C6}'), (0x76, '\u{2756}'), (0x77, '\u{25D7}'), (0x78, '\u{2758}'), (0x79, '\u{2759}'),
(0x7A, '\u{275A}'), (0x7B, '\u{275B}'), (0x7C, '\u{275C}'), (0x7D, '\u{275D}'), (0x7E, '\u{275E}'), (0x80, '\u{2768}'),
(0x81, '\u{2769}'), (0x82, '\u{276A}'), (0x83, '\u{276B}'), (0x84, '\u{276C}'), (0x85, '\u{276D}'), (0x86, '\u{276E}'),
(0x87, '\u{276F}'), (0x88, '\u{2770}'), (0x89, '\u{2771}'), (0x8A, '\u{2772}'), (0x8B, '\u{2773}'), (0x8C, '\u{2774}'),
(0x8D, '\u{2775}'), (0xA1, '\u{2761}'), (0xA2, '\u{2762}'), (0xA3, '\u{2763}'), (0xA4, '\u{2764}'), (0xA5, '\u{2765}'),
(0xA6, '\u{2766}'), (0xA7, '\u{2767}'), (0xA8, '\u{2663}'), (0xA9, '\u{2666}'), (0xAA, '\u{2665}'), (0xAB, '\u{2660}'),
(0xAC, '\u{2460}'), (0xAD, '\u{2461}'), (0xAE, '\u{2462}'), (0xAF, '\u{2463}'), (0xB0, '\u{2464}'), (0xB1, '\u{2465}'),
(0xB2, '\u{2466}'), (0xB3, '\u{2467}'), (0xB4, '\u{2468}'), (0xB5, '\u{2469}'), (0xB6, '\u{2776}'), (0xB7, '\u{2777}'),
(0xB8, '\u{2778}'), (0xB9, '\u{2779}'), (0xBA, '\u{277A}'), (0xBB, '\u{277B}'), (0xBC, '\u{277C}'), (0xBD, '\u{277D}'),
(0xBE, '\u{277E}'), (0xBF, '\u{277F}'), (0xC0, '\u{2780}'), (0xC1, '\u{2781}'), (0xC2, '\u{2782}'), (0xC3, '\u{2783}'),
(0xC4, '\u{2784}'), (0xC5, '\u{2785}'), (0xC6, '\u{2786}'), (0xC7, '\u{2787}'), (0xC8, '\u{2788}'), (0xC9, '\u{2789}'),
(0xCA, '\u{278A}'), (0xCB, '\u{278B}'), (0xCC, '\u{278C}'), (0xCD, '\u{278D}'), (0xCE, '\u{278E}'), (0xCF, '\u{278F}'),
(0xD0, '\u{2790}'), (0xD1, '\u{2791}'), (0xD2, '\u{2792}'), (0xD3, '\u{2793}'), (0xD4, '\u{2794}'), (0xD5, '\u{2192}'),
(0xD6, '\u{2194}'), (0xD7, '\u{2195}'), (0xD8, '\u{2798}'), (0xD9, '\u{2799}'), (0xDA, '\u{279A}'), (0xDB, '\u{279B}'),
(0xDC, '\u{279C}'), (0xDD, '\u{279D}'), (0xDE, '\u{279E}'), (0xDF, '\u{279F}'), (0xE0, '\u{27A0}'), (0xE1, '\u{27A1}'),
(0xE2, '\u{27A2}'), (0xE3, '\u{27A3}'), (0xE4, '\u{27A4}'), (0xE5, '\u{27A5}'), (0xE6, '\u{27A6}'), (0xE7, '\u{27A7}'),
(0xE8, '\u{27A8}'), (0xE9, '\u{27A9}'), (0xEA, '\u{27AA}'), (0xEB, '\u{27AB}'), (0xEC, '\u{27AC}'), (0xED, '\u{27AD}'),
(0xEE, '\u{27AE}'), (0xEF, '\u{27AF}'), (0xF1, '\u{27B1}'), (0xF2, '\u{27B2}'), (0xF3, '\u{27B3}'), (0xF4, '\u{27B4}'),
(0xF5, '\u{27B5}'), (0xF6, '\u{27B6}'), (0xF7, '\u{27B7}'), (0xF8, '\u{27B8}'), (0xF9, '\u{27B9}'), (0xFA, '\u{27BA}'),
(0xFB, '\u{27BB}'), (0xFC, '\u{27BC}'), (0xFD, '\u{27BD}'), (0xFE, '\u{27BE}'),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn times_roman_ascii_widths() {
assert_eq!(base14_char_width("Times-Roman", ' '), Some(250));
assert_eq!(base14_char_width("Times-Roman", 'M'), Some(889));
assert_eq!(base14_char_width("Times-Roman", 'i'), Some(278));
}
#[test]
fn subset_prefix_and_aliases_normalize() {
assert_eq!(
base14_char_width("ABCDEF+Times-Bold", ' '),
base14_char_width("Times-Bold", ' ')
);
assert!(base14_char_width("ArialMT", 'a').is_some());
assert!(base14_char_width("TimesNewRomanPSMT", 'a').is_some());
}
#[test]
fn non_base14_returns_none() {
assert_eq!(base14_char_width("DejaVuSans", 'a'), None);
assert!(!is_base14_font("Garamond"));
}
#[test]
fn builtin_encoding_resolves_symbol_and_zapf_codes() {
// Symbol 0x61 renders alpha; Zapf 0x21 renders U+2701.
assert_eq!(builtin_encoding_char("Symbol", 0x61), Some('\u{03B1}'));
assert_eq!(builtin_encoding_char("Symbol", 0xA5), Some('\u{221E}'));
assert_eq!(
builtin_encoding_char("ZapfDingbats", 0x21),
Some('\u{2701}')
);
// Latin text fonts follow standard encodings — no builtin override.
assert_eq!(builtin_encoding_char("Times-Roman", 0x61), None);
// The resolved chars have real AFM widths.
let alpha_w = base14_char_width("Symbol", '\u{03B1}');
assert!(alpha_w.is_some() && alpha_w != Some(500));
}
#[test]
fn encoding_tables_are_sorted_for_binary_search() {
for table in [SYMBOL_ENCODING, ZAPFDINGBATS_ENCODING] {
assert!(table.windows(2).all(|w| w[0].0 < w[1].0));
}
}
#[test]
fn tables_are_sorted_for_binary_search() {
for table in [
TIMES_ROMAN,
TIMES_ITALIC,
HELVETICA,
COURIER,
SYMBOL,
ZAPFDINGBATS,
] {
assert!(table.windows(2).all(|w| w[0].0 < w[1].0));
}
}
}
+13 -8
View File
@@ -14,9 +14,9 @@ use lopdf::{Document, Encoding, Object, ObjectId};
use std::collections::HashMap;
use super::fonts::{
build_font_encodings, build_font_widths, compute_string_width_ts, descriptor_style_flags,
extract_text_from_operand, get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache,
FontStyleCache,
build_font_encodings, build_font_widths, build_type3_scales, compute_string_width_ts,
descriptor_style_flags, extract_text_from_operand, get_font_file2_obj_num, get_operand_bytes,
CMapDecisionCache, FontStyleCache,
};
use super::underline::UnderlineLine;
use super::xobjects::{extract_form_xobject_text, get_page_xobjects, XObjectType};
@@ -162,10 +162,11 @@ pub(crate) fn extract_page_text_items(
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
// Build font encoding maps from Differences arrays
let (font_encodings, has_gid_fonts) = build_font_encodings(doc, &fonts);
let (font_encodings, has_gid_fonts) = build_font_encodings(doc, &fonts, font_cmaps);
// Build font width info for accurate text positioning
let font_widths = build_font_widths(doc, &fonts);
let type3_scales = build_type3_scales(doc, &fonts);
// Build maps of font resource names to their base font names and ToUnicode object refs
let mut font_base_names: std::collections::HashMap<String, String> =
@@ -502,7 +503,8 @@ pub(crate) fn extract_page_text_items(
) {
let combined =
multiply_matrices(&rise_adjusted(&text_matrix, text_rise), &ctm);
let rendered_size = effective_font_size(current_font_size, &combined);
let rendered_size = effective_font_size(current_font_size, &combined)
* type3_scales.get(&current_font).copied().unwrap_or(1.0);
let (x, y) = (combined[4], combined[5]);
if combined[0].abs() >= combined[1].abs() {
rotation_votes.horizontal += 1;
@@ -673,7 +675,8 @@ pub(crate) fn extract_page_text_items(
} else {
rotation_votes.rotated += 1;
}
let rendered_size = effective_font_size(current_font_size, &combined);
let rendered_size = effective_font_size(current_font_size, &combined)
* type3_scales.get(&current_font).copied().unwrap_or(1.0);
let base_font = font_base_names
.get(&current_font)
.map(|s| s.as_str())
@@ -780,7 +783,8 @@ pub(crate) fn extract_page_text_items(
} else {
rotation_votes.rotated += 1;
}
let rendered_size = effective_font_size(current_font_size, &combined);
let rendered_size = effective_font_size(current_font_size, &combined)
* type3_scales.get(&current_font).copied().unwrap_or(1.0);
let (x, y) = (combined[4], combined[5]);
let width = w_ts_opt
.map(|w_ts| {
@@ -932,7 +936,8 @@ pub(crate) fn extract_page_text_items(
} else {
rotation_votes.rotated += 1;
}
let rendered_size = effective_font_size(current_font_size, &combined);
let rendered_size = effective_font_size(current_font_size, &combined)
* type3_scales.get(&current_font).copied().unwrap_or(1.0);
let (x, y) = (combined[4], combined[5]);
// Width in device space from text matrix delta
let delta_ts = text_matrix[4] - start_tm[4];
+321 -13
View File
@@ -138,6 +138,75 @@ pub(crate) fn build_font_widths(
widths
}
/// Visual-size scale factors for Type3 fonts, keyed by resource name.
///
/// A Type3 font's glyph space maps to text space through FontMatrix, so the
/// visual height of its glyphs is `nominal_size × |matrix_y| × FontBBox
/// height`. For a well-behaved font (matrix 0.001, bbox ≈ 1000 units) that
/// factor is ≈ 1.0 and the nominal size is already right. TeX PK bitmap
/// fonts (dvips → Distiller) instead use FontMatrix [1 0 0 -1 0 0] with
/// nominal sizes like 0.12, which makes every downstream font-size heuristic
/// (drop caps, sub/superscripts, small-font tables, line heights) see
/// nonsense. Fonts without a usable FontBBox are omitted (treated as 1.0).
pub(crate) fn build_type3_scales(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
) -> HashMap<String, f32> {
let mut scales = HashMap::new();
for (font_name, font_dict) in fonts {
let is_type3 = font_dict
.get(b"Subtype")
.ok()
.and_then(|o| o.as_name().ok())
.is_some_and(|n| n == b"Type3");
if !is_type3 {
continue;
}
// Array elements may themselves be indirect references per PDF
// syntax — resolve before reading the numeric value.
let num = |o: &Object| {
let resolved = match o {
Object::Reference(r) => match doc.get_object(*r) {
Ok(inner) => inner,
Err(_) => return 0.0,
},
other => other,
};
match resolved {
Object::Integer(i) => *i as f32,
Object::Real(r) => *r,
_ => 0.0,
}
};
let Some(matrix) = font_dict
.get(b"FontMatrix")
.ok()
.and_then(|o| resolve_array(doc, o))
else {
continue;
};
let Some(bbox) = font_dict
.get(b"FontBBox")
.ok()
.and_then(|o| resolve_array(doc, o))
else {
continue;
};
if matrix.len() < 4 || bbox.len() < 4 {
continue;
}
let scale_y = (num(&matrix[2]).powi(2) + num(&matrix[3]).powi(2)).sqrt();
let bbox_h = (num(&bbox[3]) - num(&bbox[1])).abs();
let scale = bbox_h * scale_y;
// Only store meaningful scales; degenerate bboxes ([0 0 0 0] is legal)
// and near-1.0 factors keep the nominal size.
if scale > 0.01 && (scale - 1.0).abs() > 0.05 {
scales.insert(String::from_utf8_lossy(font_name).to_string(), scale);
}
}
scales
}
/// Parse font widths from a font dictionary, dispatching by Subtype
pub(crate) fn parse_font_widths(
doc: &Document,
@@ -149,11 +218,71 @@ pub(crate) fn parse_font_widths(
match subtype_name {
b"Type0" => parse_type0_widths(doc, font_dict),
b"Type1" | b"TrueType" | b"MMType1" | b"Type3" => parse_simple_font_widths(doc, font_dict),
b"Type1" | b"TrueType" | b"MMType1" => parse_simple_font_widths(doc, font_dict)
.or_else(|| base14_fallback_widths(doc, font_dict)),
b"Type3" => parse_simple_font_widths(doc, font_dict),
_ => None,
}
}
/// Fallback metrics for non-embedded base-14 fonts whose dictionary omits
/// `/FirstChar`/`/Widths` (legal per the PDF spec — the reader must supply
/// standard-font metrics). Without this, every glyph advances 0 and all
/// downstream gap-based logic (space synthesis, script detection, table
/// columns) collapses — common in 1990s dvips/Distiller PDFs.
///
/// Widths are resolved per code through the font's Differences encoding when
/// present, falling back to the same single-byte decode the text extractor
/// uses (cp1252-style smart punctuation for 0x80..=0x9F, Latin-1 elsewhere) —
/// so the width of a code always matches the char we extract for it.
fn base14_fallback_widths(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<FontWidthInfo> {
let base_font = font_dict
.get(b"BaseFont")
.ok()
.and_then(|o| o.as_name().ok())
.map(|n| String::from_utf8_lossy(n).to_string())?;
if !crate::extractor::base14::is_base14_font(&base_font) {
return None;
}
let enc_map = parse_font_encoding(doc, font_dict)
.map(|r| r.map)
.unwrap_or_default();
let mut widths = HashMap::new();
for code in 0u16..=255 {
// Resolution order: Differences override, then the font's BUILT-IN
// encoding (Symbol/ZapfDingbats glyphs live at positions unrelated
// to cp1252 — the renderer draws α for Symbol 0x61 no matter how
// the text decoder transliterates it, so the advance must be α's),
// then the cp1252-style fallback used by the text decoder.
let ch = enc_map
.get(&(code as u8))
.copied()
.or_else(|| crate::extractor::base14::builtin_encoding_char(&base_font, code as u8))
.unwrap_or_else(|| decode_single_byte_fallback_char(code as u8, true));
if let Some(w) = crate::extractor::base14::base14_char_width(&base_font, ch) {
widths.insert(code, w);
}
}
let space_width = widths.get(&32).copied().unwrap_or(250);
debug!(
" base14 fallback widths for {} ({} codes mapped)",
base_font,
widths.len()
);
Some(FontWidthInfo {
widths,
default_width: 500,
space_width,
is_cid: false,
units_scale: 0.001,
wmode: 0,
})
}
/// Parse widths for simple fonts (Type1, TrueType, MMType1, Type3)
/// Reads FirstChar, LastChar, and Widths array.
/// For Type3 fonts, reads FontMatrix to determine the correct units_scale.
@@ -497,9 +626,13 @@ pub(crate) fn get_operand_bytes(obj: &Object) -> Option<&[u8]> {
/// Build encoding maps for all fonts on a page.
/// Returns `(encodings, has_gid_fonts)` where `has_gid_fonts` is true when
/// any font uses raw glyph ID names (gidNNNNN) that can't be decoded.
/// Gid names whose codes the font's own ToUnicode CMap maps are decodable
/// and do not set the flag (LibreOffice subsets write /gidNNNN Differences
/// names alongside a complete ToUnicode CMap).
pub(crate) fn build_font_encodings(
doc: &Document,
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
cmaps: &FontCMaps,
) -> (PageFontEncodings, bool) {
let mut encodings = PageFontEncodings::new();
let mut has_gid_fonts = false;
@@ -508,7 +641,9 @@ pub(crate) fn build_font_encodings(
let resource_name = String::from_utf8_lossy(font_name).to_string();
if let Some(result) = parse_font_encoding(doc, font_dict) {
if result.gid_glyph_count > 0 {
if !result.gid_codes.is_empty()
&& !tounicode_maps_codes(font_dict, cmaps, &result.gid_codes)
{
has_gid_fonts = true;
}
if !result.map.is_empty() {
@@ -520,6 +655,34 @@ pub(crate) fn build_font_encodings(
(encodings, has_gid_fonts)
}
/// True when the font's ToUnicode CMap maps the gid-named character codes,
/// so the Differences entries still decode through the CMap.
fn tounicode_maps_codes(font_dict: &lopdf::Dictionary, cmaps: &FontCMaps, codes: &[u8]) -> bool {
let Some(obj_ref) = font_dict
.get(b"ToUnicode")
.ok()
.and_then(|o| o.as_reference().ok())
else {
return false;
};
let Some(entry) = cmaps.get_by_obj(obj_ref.0) else {
return false;
};
// At least one gid code usably mapped means the CMap addresses these
// codes; remaining unmapped codes are subset leftovers (e.g. the
// component glyphs of an emoji ZWJ sequence mapped whole on its first
// code). A mapping is usable only when extraction would accept it —
// empty or U+FFFD results are rejected there as invalid. Fonts whose
// CMap ignores the gid codes entirely stay flagged, and the downstream
// garbage/encoding checks still catch partial damage.
codes.iter().any(|&code| {
entry
.primary
.lookup(code as u16)
.is_some_and(|s| !s.is_empty() && !s.contains('\u{FFFD}'))
})
}
/// Parse font encoding from a font dictionary
pub(crate) fn parse_font_encoding(
doc: &Document,
@@ -558,11 +721,10 @@ pub(crate) fn parse_font_encoding(
/// Result of parsing an encoding dictionary's Differences array.
pub(crate) struct EncodingResult {
pub map: FontEncodingMap,
/// Number of glyph names matching the `gidNNNNN` pattern (raw glyph IDs).
/// These indicate a font with unresolvable encoding — the glyph IDs
/// reference the original font's glyph table, but without the original
/// font's cmap there is no way to map them to Unicode.
pub gid_glyph_count: u32,
/// Character codes whose glyph names match the `gidNNNNN` pattern (raw
/// glyph IDs). These reference the original font's glyph table and are
/// only decodable when the font's ToUnicode CMap maps the code.
pub gid_codes: Vec<u8>,
}
/// Parse an encoding dictionary with Differences array
@@ -588,7 +750,7 @@ pub(crate) fn parse_encoding_dictionary(
let mut encoding_map = FontEncodingMap::new();
let mut current_code: u8 = 0;
let mut ligature_count = 0u32;
let mut gid_glyph_count = 0u32;
let mut gid_codes: Vec<u8> = Vec::new();
for item in diff_array {
match item {
@@ -614,7 +776,7 @@ pub(crate) fn parse_encoding_dictionary(
&& glyph_name.len() >= 4
&& glyph_name[3..].chars().all(|c| c.is_ascii_digit())
{
gid_glyph_count += 1;
gid_codes.push(current_code);
}
if let Some(ch) = mapped_char {
encoding_map.insert(current_code, ch);
@@ -638,16 +800,16 @@ pub(crate) fn parse_encoding_dictionary(
);
}
if gid_glyph_count > 0 {
if !gid_codes.is_empty() {
debug!(
" Differences: {} gid-encoded glyphs (unresolvable without original font)",
gid_glyph_count
" Differences: {} gid-encoded glyphs (decodable only via ToUnicode)",
gid_codes.len()
);
}
Some(EncodingResult {
map: encoding_map,
gid_glyph_count,
gid_codes,
})
}
@@ -1437,6 +1599,43 @@ fn score_text(text: &str) -> i32 {
#[cfg(test)]
mod tests {
#[test]
fn type3_scale_resolves_indirect_matrix_and_bbox_numbers() {
use lopdf::{dictionary, Document, Object};
// FontMatrix/FontBBox elements may be indirect references per PDF
// syntax; the scale must use their resolved values, not zero.
let mut doc = Document::with_version("1.4");
let matrix_d = doc.add_object(Object::Real(-1.0));
let bbox_top = doc.add_object(Object::Integer(3));
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type3",
"FontMatrix" => vec![
Object::Integer(1),
Object::Integer(0),
Object::Integer(0),
Object::Reference(matrix_d),
Object::Integer(0),
Object::Integer(0),
],
"FontBBox" => vec![
Object::Integer(1),
Object::Integer(-156),
Object::Integer(37),
Object::Reference(bbox_top),
],
};
let mut fonts = std::collections::BTreeMap::new();
fonts.insert(b"T2".to_vec(), &font_dict);
let scales = super::build_type3_scales(&doc, &fonts);
let scale = scales.get("T2").copied().unwrap_or(1.0);
// bbox height 159 x |matrix_y| 1.0
assert!(
(scale - 159.0).abs() < 0.5,
"scale should use resolved indirect values, got {scale}"
);
}
#[test]
fn texcm_math_symbols_remap() {
assert_eq!(
@@ -1938,4 +2137,113 @@ mod tests {
false
));
}
fn gid_font_doc(bfchar: Option<&str>) -> (Document, lopdf::ObjectId) {
use lopdf::Stream;
let mut doc = Document::with_version("1.4");
let cmap = format!(
"/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
1 begincodespacerange
<00> <FF>
endcodespacerange
1 beginbfchar
{}
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end",
bfchar.unwrap_or_default()
);
let tounicode_id = doc.add_object(Object::Stream(Stream::new(
dictionary! {},
cmap.into_bytes(),
)));
let enc_id = doc.add_object(dictionary! {
"Type" => "Encoding",
"Differences" => vec![
1.into(),
Object::Name(b"gid1283".to_vec()),
Object::Name(b"gid1464".to_vec()),
],
});
let mut font = dictionary! {
"Type" => "Font",
"Subtype" => "TrueType",
"BaseFont" => "ABCDEF+OpenSymbol",
"Encoding" => Object::Reference(enc_id),
};
if bfchar.is_some() {
font.set("ToUnicode", Object::Reference(tounicode_id));
}
let font_id = doc.add_object(font);
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Resources" => dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
},
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
});
let pages_id = doc.add_object(dictionary! {
"Type" => "Pages",
"Count" => Object::Integer(1),
"Kids" => vec![Object::Reference(page_id)],
});
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
});
doc.trailer.set("Root", Object::Reference(catalog_id));
(doc, page_id)
}
fn gid_flagged(bfchar: Option<&str>) -> bool {
let (doc, page_id) = gid_font_doc(bfchar);
let cmaps = FontCMaps::from_doc(&doc);
let fonts = doc.get_page_fonts(page_id).unwrap();
let (_, has_gid_fonts) = build_font_encodings(&doc, &fonts, &cmaps);
has_gid_fonts
}
#[test]
fn gid_differences_with_covering_tounicode_are_not_flagged() {
// LibreOffice subsets write /gidNNNN Differences names alongside a
// ToUnicode CMap that decodes those codes; the page must not be
// flagged as unresolvable (which would suppress the whole document's
// markdown when every page carries such a font).
assert!(!gid_flagged(Some("<01> <2022>\n<02> <25E6>")));
}
#[test]
fn gid_differences_with_partial_tounicode_are_not_flagged() {
// An emoji ZWJ sequence maps whole on its first code; the remaining
// component-glyph codes are subset leftovers, not damage.
assert!(!gid_flagged(Some(
"<01> <D83DDC68200DD83DDC69200DD83DDC67>"
)));
}
#[test]
fn gid_differences_without_tounicode_are_flagged() {
assert!(
gid_flagged(None),
"gid glyphs without ToUnicode are unresolvable"
);
}
#[test]
fn gid_differences_with_disjoint_tounicode_are_flagged() {
// A ToUnicode that never addresses the gid codes leaves them
// unresolvable.
assert!(gid_flagged(Some("<10> <0041>")));
}
#[test]
fn gid_differences_with_replacement_char_tounicode_are_flagged() {
// A mapping to U+FFFD is not usable — extraction rejects it as an
// invalid CMap result — so it must not clear the gid flag.
assert!(gid_flagged(Some("<01> <FFFD>\n<02> <FFFD>")));
}
}
+44 -5
View File
@@ -1153,6 +1153,22 @@ pub fn group_into_lines(items: Vec<TextItem>) -> Vec<TextLine> {
group_into_lines_with_thresholds(items, &HashMap::new(), &HashSet::new())
}
/// Group text items into lines without removing numeric page headers or footers.
///
/// Plain-text extraction uses this path because every extracted item is part of
/// the API result. Markdown conversion keeps using [`group_into_lines`], where
/// page-number suppression is an intentional presentation cleanup.
pub fn group_into_lines_preserving_all_text(items: Vec<TextItem>) -> Vec<TextLine> {
group_into_lines_with_thresholds_and_regions_impl(
items,
&HashMap::new(),
&HashSet::new(),
&HashMap::new(),
&HashMap::new(),
false,
)
}
/// Group text items into lines, using pre-computed per-page adaptive thresholds
/// from Canva-style letter-spacing detection. Falls back to computing the
/// threshold from item gaps when no pre-computed value is available.
@@ -1195,16 +1211,39 @@ pub(crate) fn group_into_lines_with_thresholds_and_regions(
table_pages: &HashSet<u32>,
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
image_regions: &HashMap<u32, Vec<super::reading_order::ImageRegion>>,
) -> Vec<TextLine> {
group_into_lines_with_thresholds_and_regions_impl(
items,
page_thresholds,
table_pages,
chart_regions,
image_regions,
true,
)
}
fn group_into_lines_with_thresholds_and_regions_impl(
items: Vec<TextItem>,
page_thresholds: &HashMap<u32, f32>,
table_pages: &HashSet<u32>,
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
image_regions: &HashMap<u32, Vec<super::reading_order::ImageRegion>>,
filter_page_numbers: bool,
) -> Vec<TextLine> {
if items.is_empty() {
return Vec::new();
}
// Filter out page numbers (standalone numbers at top/bottom of page)
let items: Vec<TextItem> = items
.into_iter()
.filter(|item| !is_page_number(item))
.collect();
// Markdown output omits standalone numeric headers/footers. Plain-text
// callers opt out because dropping extracted text violates that API.
let items = if filter_page_numbers {
items
.into_iter()
.filter(|item| !is_page_number(item))
.collect()
} else {
items
};
// Get unique pages
let mut pages: Vec<u32> = items.iter().map(|i| i.page).collect();
+14 -1
View File
@@ -2,6 +2,7 @@
//!
//! This module extracts text with position information for structure detection.
mod base14;
pub(crate) mod content_stream;
mod fonts;
mod layout;
@@ -27,12 +28,12 @@ pub use crate::text_utils::{is_bold_font, is_italic_font};
pub use crate::types::{ItemType, TextLine};
pub(crate) use fonts::FontStyleCache;
pub(crate) use layout::detect_columns;
pub use layout::group_into_lines;
pub(crate) use layout::group_into_lines_with_thresholds;
pub(crate) use layout::group_into_lines_with_thresholds_and_charts;
pub(crate) use layout::group_into_lines_with_thresholds_and_regions;
pub(crate) use layout::is_newspaper_layout;
pub(crate) use layout::ColumnRegion;
pub use layout::{group_into_lines, group_into_lines_preserving_all_text};
// ---------------------------------------------------------------------------
// Public API
@@ -1519,6 +1520,18 @@ mod tests {
assert_eq!(lines[1].text(), "Next line");
}
#[test]
fn preserving_all_text_keeps_numeric_page_footer() {
let mut page_number = make_merge_item("42", 100.0, 12.0);
page_number.y = 50.0;
assert!(group_into_lines(vec![page_number.clone()]).is_empty());
let lines = group_into_lines_preserving_all_text(vec![page_number]);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text(), "42");
}
#[test]
fn test_bold_italic_detection() {
// Test bold detection
+9 -5
View File
@@ -8,8 +8,9 @@ use lopdf::{Document, Encoding, Object, ObjectId};
use std::collections::HashMap;
use super::fonts::{
build_font_encodings, build_font_widths, compute_string_width_ts, extract_text_from_operand,
get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache, FontStyleCache,
build_font_encodings, build_font_widths, build_type3_scales, compute_string_width_ts,
extract_text_from_operand, get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache,
FontStyleCache,
};
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
@@ -162,10 +163,11 @@ fn extract_form_xobject_text_inner(
// Get fonts from the Form's Resources
let form_fonts = get_form_fonts(doc, &stream.dict);
let (font_encodings, _has_gid_fonts) = build_font_encodings(doc, &form_fonts);
let (font_encodings, _has_gid_fonts) = build_font_encodings(doc, &form_fonts, font_cmaps);
// Build font width info for the form
let font_widths = build_font_widths(doc, &form_fonts);
let type3_scales = build_type3_scales(doc, &form_fonts);
// Build font base names and ToUnicode refs for the form
let mut font_base_names: HashMap<String, String> = HashMap::new();
@@ -413,7 +415,8 @@ fn extract_form_xobject_text_inner(
&font_widths,
) {
let combined = multiply_matrices(&text_matrix, &ctm);
let rendered_size = effective_font_size(current_font_size, &combined);
let rendered_size = effective_font_size(current_font_size, &combined)
* type3_scales.get(&current_font).copied().unwrap_or(1.0);
let (x, y) = (combined[4], combined[5]);
let width = if let Some(font_info) = font_widths.get(&current_font) {
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
@@ -572,7 +575,8 @@ fn extract_form_xobject_text_inner(
}
if !sub_items.is_empty() {
let combined = multiply_matrices(&text_matrix, &ctm);
let rendered_size = effective_font_size(current_font_size, &combined);
let rendered_size = effective_font_size(current_font_size, &combined)
* type3_scales.get(&current_font).copied().unwrap_or(1.0);
let base_font = font_base_names
.get(&current_font)
.map(|s| s.as_str())
+40 -6
View File
@@ -68,6 +68,40 @@ use text_quality::{
};
use tounicode::FontCMaps;
#[cfg(not(target_arch = "wasm32"))]
struct ProcessingTimer(std::time::Instant);
#[cfg(target_arch = "wasm32")]
struct ProcessingTimer;
impl ProcessingTimer {
fn start() -> Self {
#[cfg(not(target_arch = "wasm32"))]
{
Self(std::time::Instant::now())
}
#[cfg(target_arch = "wasm32")]
{
Self
}
}
fn elapsed_ms(&self) -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
self.0.elapsed().as_millis() as u64
}
#[cfg(target_arch = "wasm32")]
{
// The wasm32-unknown-unknown standard library has no clock.
// Browser bindings measure with JavaScript's host clock.
0
}
}
}
/// OCR reason emitted when the extracted text layer appears garbled due to
/// broken font decoding or mojibake.
pub const OCR_REASON_SUSPECTED_GARBLED_TEXT: &str = "suspected_garbled_text";
@@ -250,7 +284,7 @@ pub fn process_pdf_with_options<P: AsRef<Path>>(
path: P,
options: PdfOptions,
) -> Result<PdfProcessResult, PdfError> {
let start = std::time::Instant::now();
let start = ProcessingTimer::start();
validate_pdf_file(&path)?;
// Load the document once — shared by detection AND extraction.
@@ -277,7 +311,7 @@ pub fn process_pdf_mem_with_options(
buffer: &[u8],
options: PdfOptions,
) -> Result<PdfProcessResult, PdfError> {
let start = std::time::Instant::now();
let start = ProcessingTimer::start();
validate_pdf_bytes(buffer)?;
let (doc, page_count) =
@@ -3526,7 +3560,7 @@ fn process_document(
doc: Document,
page_count: u32,
options: PdfOptions,
start: std::time::Instant,
start: ProcessingTimer,
) -> Result<PdfProcessResult, PdfError> {
// Step 1 — Detection (cheap: scans content streams for text operators)
let detection = detector::detect_from_document(&doc, page_count, &options.detection)?;
@@ -3542,7 +3576,7 @@ fn process_document(
pdf_type,
markdown: None,
page_count,
processing_time_ms: start.elapsed().as_millis() as u64,
processing_time_ms: start.elapsed_ms(),
pages_needing_ocr,
ocr_reasons_by_page: page_ocr_reasons_vec(detection_ocr_reasons),
title,
@@ -3558,7 +3592,7 @@ fn process_document(
pdf_type,
markdown: None,
page_count,
processing_time_ms: start.elapsed().as_millis() as u64,
processing_time_ms: start.elapsed_ms(),
pages_needing_ocr,
ocr_reasons_by_page: page_ocr_reasons_vec(detection_ocr_reasons),
title,
@@ -3824,7 +3858,7 @@ fn process_document(
pdf_type,
markdown,
page_count,
processing_time_ms: start.elapsed().as_millis() as u64,
processing_time_ms: start.elapsed_ms(),
pages_needing_ocr,
ocr_reasons_by_page: {
// Detector reasons (scanned / no_text / vector_text / garbled) merged
+84 -12
View File
@@ -368,23 +368,95 @@ pub(crate) fn compute_paragraph_threshold(lines: &[TextLine], base_size: f32) ->
/// Discover distinct heading font-size tiers in the document.
/// Returns tiers sorted largest-first (tier 0 = H1, tier 1 = H2, …).
/// Sizes within 0.5pt are clustered into the same tier. Capped at 4 tiers.
/// Dominant font size of a line: the size cluster (0.5pt tolerance) covering
/// the most alphanumeric characters. A line led by an oversized ornament
/// (drop cap, Type3 display-math delimiter) must be classified by its body
/// text, not its first glyph — and operators/delimiters don't get a vote,
/// so a display equation whose parens and plus signs render larger than its
/// variables still classifies at the variables' size. Falls back to counting
/// all non-whitespace chars for lines with no alphanumerics. Returns `None`
/// for lines with no text.
pub(crate) fn line_dominant_font_size(line: &TextLine) -> Option<f32> {
fn dominant(line: &TextLine, count_chars: fn(&str) -> usize) -> Option<f32> {
let mut clusters: Vec<(f32, usize)> = Vec::new();
for item in &line.items {
let chars = count_chars(&item.text);
if chars == 0 {
continue;
}
if let Some((_, count)) = clusters
.iter_mut()
.find(|(s, _)| (*s - item.font_size).abs() < 0.5)
{
*count += chars;
} else {
clusters.push((item.font_size, chars));
}
}
clusters
.into_iter()
.max_by_key(|&(_, count)| count)
.map(|(size, _)| size)
}
dominant(line, |t| t.chars().filter(|c| c.is_alphanumeric()).count())
.or_else(|| dominant(line, |t| t.chars().filter(|c| !c.is_whitespace()).count()))
}
pub(crate) fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec<f32> {
let mut heading_sizes: Vec<f32> = Vec::new();
for line in lines {
if let Some(first) = line.items.first() {
if first.font_size / base_size >= 1.2 {
// Digit-only lines (page numbers, issue numbers) must not
// define heading tiers: a large bold folio claims tier 0 and
// blocks the bold-size fallback for the document's real
// same-size headings.
let text = line.text();
let t = text.trim();
if !t.is_empty() && t.chars().all(|c| !c.is_alphabetic()) {
continue;
}
heading_sizes.push(first.font_size);
// Use the dominant (alphanumeric-weighted) size — the same notion
// heading DETECTION matches against tiers — so a large leading
// ornament or section marker can't register a tier at a size no
// line will ever be classified with.
let Some(dominant) = line_dominant_font_size(line) else {
continue;
};
if dominant / base_size >= 1.2 {
// Digit-only lines (page numbers, issue numbers) must not
// define heading tiers: a large bold folio claims tier 0 and
// blocks the bold-size fallback for the document's real
// same-size headings.
let text = line.text();
let t = text.trim();
if !t.is_empty() && t.chars().all(|c| !c.is_alphabetic()) {
continue;
}
// Near-empty lines (oversized glyph runs — display-math
// operators like ∫∫, ⟨⟩ or √ that decode as "ZZ"/"h i"/"p p"
// in TeX bitmap fonts, drop caps) must not define tiers
// either. Real headings have at least three letters, two of
// them distinct.
let mut letters: Vec<char> = t
.chars()
.filter(|c| c.is_alphabetic())
.flat_map(|c| c.to_lowercase())
.collect();
let total_letters = letters.len();
letters.sort_unstable();
letters.dedup();
if total_letters < 3 || letters.len() < 2 {
continue;
}
// A heading line is uniformly sized: every item within ±20% of
// the dominant size. Mixed-size lines (a drop cap or oversized
// math glyph anywhere in the line — leading OR trailing) are
// body content, not headings.
let uniform = line
.items
.iter()
.all(|i| (i.font_size - dominant).abs() <= dominant * 0.2);
if !uniform {
continue;
}
log::debug!(
"heading tier candidate: fs={:.1} page={} {:?}",
dominant,
line.page,
t.chars().take(60).collect::<String>()
);
heading_sizes.push(dominant);
}
}
+173 -4
View File
@@ -458,6 +458,83 @@ fn is_body_size_all_bold_line(line: &TextLine, base_size: f32) -> bool {
.all(|item| item.is_bold && (item.font_size - first.font_size).abs() < 0.5)
}
/// First-line-indent paragraph break. Justified documents (TeX/dvips output)
/// often separate paragraphs only by indenting the first line — the vertical
/// gap stays at normal line spacing, so the Y-gap rule never fires and whole
/// sections merge into one wall of text.
///
/// Fires only when all of:
/// - the line advance is a normal in-paragraph step (`0 < y_gap <= para_threshold`)
/// - the left margin is established (2+ consecutive lines at the same X), so
/// list continuations and ragged layouts don't trigger
/// - this line starts 0.84 em deeper than that margin (a first-line indent,
/// not a column jump)
/// - the previous line ended short of the established right edge (a paragraph's
/// last line is ragged; hanging-indent wraps like bibliography continuation
/// lines follow a full-width line and must not split) — OR ended at a
/// sentence boundary, which covers paragraphs whose justified last line
/// happens to run flush (a citation's first line almost never wraps exactly
/// at a sentence end)
#[allow(clippy::too_many_arguments)]
fn is_indent_paragraph_break(
line_x: f32,
y_gap: f32,
para_threshold: f32,
base_size: f32,
margin_x: f32,
margin_run: u32,
prev_right: f32,
max_right: f32,
prev_ends_sentence: bool,
) -> bool {
let indent = line_x - margin_x;
margin_run >= 2
&& y_gap > 0.0
&& y_gap <= para_threshold
&& indent >= base_size * 0.8
&& indent <= base_size * 4.0
&& (prev_right <= max_right - base_size || prev_ends_sentence)
}
/// True when a line's text ends at a sentence boundary: terminal punctuation,
/// optionally followed by a closing quote/bracket.
fn line_ends_sentence(text: &str) -> bool {
let trimmed = text.trim_end();
let trimmed = trimmed.trim_end_matches(['"', '\u{201D}', '\u{2019}', ')', ']']);
trimmed.ends_with(['.', '!', '?'])
}
/// True when math/operator symbols rival the alphanumeric content of a line —
/// the signature of a display equation rather than prose or a heading.
fn is_symbol_dominated(text: &str) -> bool {
let alnum = text.chars().filter(|c| c.is_alphanumeric()).count();
// Grouping characters are deliberately excluded: parenthesized short
// headings like "(R-12)" or "[DRAFT]" are common and are not math.
let mathy = text
.chars()
.filter(|c| {
matches!(
c,
'+' | '\u{2212}'
| '='
| '/'
| '^'
| '|'
| '<'
| '>'
| '\u{00B1}'
| '\u{00D7}'
| '\u{00B7}'
| '\u{2211}'
| '\u{222B}'
| '\u{221A}'
| '\u{221E}'
)
})
.count();
mathy >= 2 && mathy * 2 >= alnum.max(1)
}
fn is_wrapped_same_style_line(prev: &TextLine, next: &TextLine, para_threshold: f32) -> bool {
if prev.page != next.page {
return false;
@@ -777,6 +854,12 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
let mut toc_suppress_page: Option<u32> = None;
let mut inserted_tables: HashSet<(u32, usize)> = HashSet::new();
let mut inserted_images: HashSet<(u32, usize)> = HashSet::new();
// First-line-indent paragraph detection state
let mut left_margin_x = f32::MAX;
let mut left_margin_run = 0u32;
let mut prev_right = 0.0f32;
let mut page_max_right = 0.0f32;
let mut prev_ends_sentence = false;
// Collect all pages that have tables or images (including image-only pages)
let mut all_content_pages: Vec<u32> = page_tables
@@ -851,6 +934,11 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
prev_y = f32::MAX;
prev_x = 0.0;
paragraph_in_wrapped_bold_run = false;
left_margin_x = f32::MAX;
left_margin_run = 0;
prev_right = 0.0;
page_max_right = 0.0;
prev_ends_sentence = false;
if options.include_page_numbers {
output.push_str(&format!("<!-- Page {} -->\n\n", current_page));
@@ -907,7 +995,22 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
&& !line_all_bold
&& y_gap > base_size * 1.2
&& y_gap <= para_threshold;
if (is_para_break || is_band_switch || is_bold_to_regular_break) && in_paragraph {
let is_indent_break = in_paragraph
&& !in_list
&& is_indent_paragraph_break(
line_x,
y_gap,
para_threshold,
base_size,
left_margin_x,
left_margin_run,
prev_right,
page_max_right,
prev_ends_sentence,
);
if (is_para_break || is_band_switch || is_bold_to_regular_break || is_indent_break)
&& in_paragraph
{
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
@@ -916,6 +1019,21 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
// Let the continuation check below decide if we're still in a list
prev_y = line.y;
prev_x = line_x;
// Track the stable left margin and line right edges for
// first-line-indent paragraph detection
if (line_x - left_margin_x).abs() <= 2.0 {
left_margin_run += 1;
} else {
left_margin_x = line_x;
left_margin_run = 1;
}
prev_right = line
.items
.iter()
.map(|i| i.x + i.width)
.fold(0.0f32, f32::max);
page_max_right = page_max_right.max(prev_right);
prev_ends_sentence = line_ends_sentence(&line.text());
// Get text with optional bold/italic formatting
let text = line.text_with_formatting(
@@ -1007,8 +1125,15 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
&& !is_toc_entry_line(plain_trimmed)
&& !is_heading_fragment(plain_trimmed)
&& toc_suppress_page != Some(line.page)
// Display equations must never become headings — through the
// font-tier path or the rarity fallback. They are standalone and
// isolated by construction, so without this gate they score
// straight past both.
&& !plain_trimmed.contains('=')
&& !is_symbol_dominated(plain_trimmed)
{
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
let line_font_size =
crate::markdown::analysis::line_dominant_font_size(line).unwrap_or(base_size);
detect_header_level(
line_font_size,
base_size,
@@ -1289,6 +1414,12 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
let mut prev_had_dot_leaders = false;
let mut paragraph_in_wrapped_bold_run = false;
let mut toc_suppress_page: Option<u32> = None;
// First-line-indent paragraph detection state
let mut left_margin_x = f32::MAX;
let mut left_margin_run = 0u32;
let mut prev_right = 0.0f32;
let mut page_max_right = 0.0f32;
let mut prev_ends_sentence = false;
for (line_idx, line) in lines.iter().enumerate() {
// Page break
@@ -1306,6 +1437,11 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
last_list_x = None;
prev_had_dot_leaders = false;
paragraph_in_wrapped_bold_run = false;
left_margin_x = f32::MAX;
left_margin_run = 0;
prev_right = 0.0;
page_max_right = 0.0;
prev_ends_sentence = false;
if options.include_page_numbers {
output.push_str(&format!("<!-- Page {} -->\n\n", current_page));
@@ -1315,6 +1451,7 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
// Paragraph break: large forward Y gap (normal) or large backward jump
// (newspaper columns emitted sequentially on the same page).
let y_gap = prev_y - line.y;
let line_x = line.items.first().map(|i| i.x).unwrap_or(0.0);
let is_para_break = y_gap.abs() > para_threshold;
let line_all_bold = !line.items.is_empty() && line.items.iter().all(|item| item.is_bold);
let line_in_wrapped_bold_run = wrapped_bold_paragraph_lines.contains(&line_idx);
@@ -1324,7 +1461,20 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
&& !line_all_bold
&& y_gap > base_size * 1.2
&& y_gap <= para_threshold;
if (is_para_break || is_bold_to_regular_break) && in_paragraph {
let is_indent_break = in_paragraph
&& !in_list
&& is_indent_paragraph_break(
line_x,
y_gap,
para_threshold,
base_size,
left_margin_x,
left_margin_run,
prev_right,
page_max_right,
prev_ends_sentence,
);
if (is_para_break || is_bold_to_regular_break || is_indent_break) && in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
@@ -1332,6 +1482,21 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
// Don't immediately end list on paragraph break
// Let the continuation check below decide if we're still in a list
prev_y = line.y;
// Track the stable left margin and line right edges for
// first-line-indent paragraph detection
if (line_x - left_margin_x).abs() <= 2.0 {
left_margin_run += 1;
} else {
left_margin_x = line_x;
left_margin_run = 1;
}
prev_right = line
.items
.iter()
.map(|i| i.x + i.width)
.fold(0.0f32, f32::max);
page_max_right = page_max_right.max(prev_right);
prev_ends_sentence = line_ends_sentence(&line.text());
// Get text with optional bold/italic formatting
let text = line.text_with_formatting(
@@ -1371,8 +1536,12 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
&& !is_heading_fragment(plain_trimmed)
&& toc_suppress_page != Some(line.page)
&& !(options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)))
// Display equations must never become headings (see main path).
&& !plain_trimmed.contains('=')
&& !is_symbol_dominated(plain_trimmed)
{
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
let line_font_size =
crate::markdown::analysis::line_dominant_font_size(line).unwrap_or(base_size);
if let Some(header_level) = detect_header_level(
line_font_size,
base_size,
+133 -3
View File
@@ -40,8 +40,9 @@ fn effective_heading_level(
}
}
// Fall back to font-size heuristic
let font = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
// Fall back to font-size heuristic (dominant size, so a leading drop cap
// or oversized math delimiter doesn't reclassify a body line)
let font = crate::markdown::analysis::line_dominant_font_size(line).unwrap_or(base_size);
detect_header_level(
font,
base_size,
@@ -72,7 +73,10 @@ pub(crate) fn merge_heading_lines(
for line in lines {
let line_level = effective_heading_level(&line, base_size, heading_tiers, struct_roles);
let line_font = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
// Dominant size, consistent with effective_heading_level: a large
// leading delimiter must not inflate the merge gap threshold.
let line_font =
crate::markdown::analysis::line_dominant_font_size(&line).unwrap_or(base_size);
// Check if the previous line is a heading at the same level on the same page
let should_merge = if let (Some(prev), Some(curr_level)) = (result.last(), line_level) {
@@ -169,6 +173,87 @@ pub(crate) fn merge_drop_caps(lines: Vec<TextLine>, base_size: f32) -> Vec<TextL
.map(|c| c.is_uppercase())
.unwrap_or(false);
// Embedded drop cap: a two-line drop cap's baseline aligns with the
// paragraph's SECOND line, so Y-grouping puts the glyph at the start
// of that line instead of on its own line. Left in place, the cap
// ends up mid-sentence after paragraph joining ("...which exchange T
// bandwidth..."). Detect it, prepend the char to the previous line
// (the paragraph's first line, typeset beside the cap), and drop it.
// The size gate is 1.8x (not 2.5x): bitmap (Type3) caps measure their
// glyph bbox, not the em box, so a two-line cap can be as small as
// ~1.9x the body size.
if line.items.len() > 1 {
let first = &line.items[0];
// The rest of the line must be a substantive body-text run —
// a lone label or math fragment beside a big glyph is not a
// drop-cap paragraph and must not be rewritten.
let rest_letters: usize = line.items[1..]
.iter()
.map(|i| i.text.chars().filter(|c| c.is_alphabetic()).count())
.sum();
let is_embedded_cap = first.font_size >= base_size * 1.8
&& first.text.trim().chars().count() == 1
&& first
.text
.trim()
.chars()
.next()
.is_some_and(|c| c.is_uppercase())
&& line.items[1..]
.iter()
.all(|i| i.font_size < base_size * 1.5)
&& line.items[1..].iter().any(|i| i.x > first.x)
&& rest_letters >= 8;
if is_embedded_cap {
let drop_char = first.text.trim().chars().next().unwrap();
let cap_x = first.x;
let cap_span = first.font_size * 1.5;
let line_y = line.y;
// Only the IMMEDIATELY preceding line qualifies: the
// paragraph's first line sits directly above, in the same
// column, indented past the cap glyph by roughly the cap's
// width. It must also read as a paragraph first line —
// substantive body text starting with a letter — so
// headings, labels, or table fragments that merely fall in
// the geometric window are never rewritten.
let target = result.last_mut().filter(|prev| {
let prev_text = prev.text();
let prev_trimmed = prev_text.trim();
prev.page == line.page
&& prev.y > line_y
&& prev.y - line_y <= cap_span
&& prev
.items
.first()
.is_some_and(|i| i.x > cap_x && i.x - cap_x <= first.font_size * 2.5)
&& prev_trimmed
.chars()
.next()
.is_some_and(|c| c.is_alphabetic())
&& prev_trimmed.chars().filter(|c| c.is_alphabetic()).count() >= 8
});
if let Some(prev_line) = target {
if let Some(first_item) = prev_line.items.first_mut() {
// A mid-word cap ("T" + "HE recent…") joins directly.
// Leading whitespace on the remainder means the cap
// is a standalone word ("A" + " long time ago…") —
// preserve exactly one separating space.
let had_leading_ws = first_item.text.starts_with(char::is_whitespace);
let rest = first_item.text.trim_start().to_string();
first_item.text = if had_leading_ws {
format!("{} {}", drop_char, rest)
} else {
format!("{}{}", drop_char, rest)
};
}
let mut line = line.clone();
line.items.remove(0);
result.push(line);
continue;
}
}
}
if is_drop_cap {
let drop_char = trimmed.chars().next().unwrap();
@@ -598,6 +683,51 @@ mod tests {
}
}
fn make_item_at(text: &str, font_size: f32, x: f32) -> TextItem {
let mut item = make_item(text, font_size, None);
item.x = x;
item.width = text.len() as f32 * font_size * 0.5;
item
}
#[test]
fn test_embedded_drop_cap_moves_to_paragraph_start() {
// Two-line drop cap: the big "T" baseline-aligns with the paragraph's
// second line, so it lands as that line's first item (Shannon
// entropy.pdf page 1 pattern).
let first_line = TextLine {
items: vec![make_item_at(
"HE recent development which exchange",
10.0,
90.0,
)],
y: 712.0,
page: 1,
adaptive_threshold: 0.10,
};
let second_line = TextLine {
items: vec![
make_item_at("T", 25.0, 72.0),
make_item_at("bandwidth for signal-to-noise ratio", 10.0, 90.0),
],
y: 700.0,
page: 1,
adaptive_threshold: 0.10,
};
let result = merge_drop_caps(vec![first_line, second_line], 10.0);
assert_eq!(result.len(), 2);
assert!(
result[0].text().starts_with("THE recent"),
"cap should prepend to paragraph start: {}",
result[0].text()
);
assert!(
result[1].text().starts_with("bandwidth"),
"cap item should be removed from second line: {}",
result[1].text()
);
}
#[test]
fn test_merge_struct_tree_headings() {
// Two consecutive lines tagged as H2 via struct tree, same font size as body
+231
View File
@@ -137,6 +137,71 @@ fn expand_consolidated_items(items: &[TextItem]) -> (Vec<TextItem>, Vec<usize>)
(expanded, index_map)
}
/// Index of candidate "body" items (larger-font attachment targets) sorted by
/// Y, so script-attachment checks scan a narrow Y window instead of the whole
/// page per candidate.
struct ScriptBodyIndex<'a> {
/// (y, item), sorted ascending by y
by_y: Vec<(f32, &'a TextItem)>,
/// widest vertical attachment window any body item can produce
max_window: f32,
}
impl<'a> ScriptBodyIndex<'a> {
fn new(items: &'a [TextItem]) -> Self {
// Smallest table-candidate font is 6pt, so any possible attachment
// target is at least 6 × 1.2 pt.
let mut by_y: Vec<(f32, &TextItem)> = items
.iter()
.filter(|i| i.font_size >= 6.0 * 1.2)
.map(|i| (i.y, i))
.collect();
by_y.sort_by(|a, b| a.0.total_cmp(&b.0));
let max_window = by_y
.iter()
.map(|(_, i)| i.font_size * 0.8)
.fold(0.0f32, f32::max);
Self { by_y, max_window }
}
/// True when a small-font item is horizontally attached to a larger-font
/// item at a script baseline offset — a sub/superscript in running text
/// or math (equation subscripts, footnote markers). Script attachments
/// are not table cells; without this filter, display equations with
/// sub/superscripts form phantom small-font table regions (e.g. TeX
/// papers where log₁₀ subscripts cluster with footnote lines into a fake
/// 3-column table). A genuine baseline offset is required so same-line
/// table neighbors (a small cell beside a larger label cell) are never
/// classified as scripts.
/// `min_anchor_size` additionally constrains what counts as an
/// attachment target: the small-font pass accepts any sufficiently
/// larger item (0.0), while the body-font pass requires a heading-sized
/// anchor so a body-size table cell beside a slightly larger label with
/// baseline jitter is never treated as a script.
fn is_script_attachment(&self, small: &TextItem, min_anchor_size: f32) -> bool {
let attach_gap = small.font_size.max(4.0) * 0.6;
let lo = self
.by_y
.partition_point(|(y, _)| *y < small.y - self.max_window);
self.by_y[lo..]
.iter()
.take_while(|(y, _)| *y <= small.y + self.max_window)
.any(|(_, body)| {
let dy = (small.y - body.y).abs();
body.font_size >= small.font_size * 1.2
&& body.font_size >= min_anchor_size
&& dy > body.font_size * 0.05
&& dy <= body.font_size * 0.8
&& {
let gap_after_body = small.x - (body.x + body.width);
let gap_before_body = body.x - (small.x + small.width);
(-attach_gap..=attach_gap).contains(&gap_after_body)
|| (-attach_gap..=attach_gap).contains(&gap_before_body)
}
})
}
}
/// Detect tables in a set of text items from a single page
pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bool) -> Vec<Table> {
if items.len() < 6 {
@@ -156,10 +221,12 @@ pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bo
// === Pass 1: Small-font tables (existing behavior) ===
let table_font_threshold = base_font_size * 0.90;
let script_index = ScriptBodyIndex::new(items);
let table_candidates: Vec<(usize, &TextItem)> = items
.iter()
.enumerate()
.filter(|(_, item)| item.font_size <= table_font_threshold && item.font_size >= 6.0)
.filter(|(_, item)| !script_index.is_script_attachment(item, 0.0))
.collect();
if table_candidates.len() >= 6 {
@@ -176,6 +243,20 @@ pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bo
continue;
}
for (_, it) in &region_items {
// Coordinates and metrics only — document text must not
// leak into logs.
log::debug!(
" region item: {} chars x={:.1} y={:.1} w={:.1} fs={:.1} font={}",
it.text.chars().count(),
it.x,
it.y,
it.width,
it.font_size,
it.font
);
}
if let Some(mut table) =
detect_table_in_region(&region_items, TableDetectionMode::SmallFont)
{
@@ -203,6 +284,11 @@ pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bo
let body_font_low = base_font_size * 0.85;
let body_font_high = base_font_size * 1.05;
// The script exclusion applies here too — a sub/superscript attached
// to a heading-size run can land in the body-font band and would
// otherwise re-enter table detection through this pass — but only
// for heading-sized anchors (>= 1.15x base): body-size table cells
// beside slightly larger labels must never be filtered.
let body_candidates: Vec<(usize, &TextItem)> = items
.iter()
.enumerate()
@@ -212,6 +298,7 @@ pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bo
&& item.font_size <= body_font_high
&& item.font_size >= 6.0
})
.filter(|(_, item)| !script_index.is_script_attachment(item, base_font_size * 1.15))
.collect();
log::debug!(
@@ -580,6 +667,30 @@ fn detect_table_in_region(items: &[(usize, &TextItem)], mode: TableDetectionMode
cells.push(row_cells);
}
// Validation 0 (small-font pass only): reject tiny all-numeric
// fragments. A ≤2-row grid whose every cell is a bare 1-2 digit number
// carries no tabular information — in practice these are
// exponent/subscript clusters from display math (e.g. the W^-2, W^-4
// determinant superscripts in TeX papers) that happen to align. Emitting
// them as plain text lines is strictly better. Body-font tables are not
// subject to this veto: their cells cannot be script glyphs.
if matches!(mode, TableDetectionMode::SmallFont) {
let nonempty_cells: Vec<&String> =
cells.iter().flatten().filter(|c| !c.is_empty()).collect();
if rows.len() <= 2
&& !nonempty_cells.is_empty()
&& nonempty_cells
.iter()
.all(|c| c.len() <= 2 && c.chars().all(|ch| ch.is_ascii_digit()))
{
log::debug!(
" validation 0 fail: tiny all-numeric fragment ({} cells)",
nonempty_cells.len()
);
return None;
}
}
// Validation 1: some rows should have content in first column.
// Use a lower threshold (25%) for tables with wrapped cells where
// continuation lines leave the first column empty.
@@ -1646,6 +1757,126 @@ fn try_add_label_column(
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ItemType;
fn make_item(text: &str, x: f32, y: f32, font_size: f32, width: f32) -> TextItem {
TextItem {
text: text.to_string(),
x,
y,
width,
height: font_size,
font: "TestFont".to_string(),
font_size,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
}
#[test]
fn script_attachment_detects_subscript_after_body_text() {
// "log" at 10pt followed immediately by subscript "10" at 7pt,
// slightly below the baseline (TeX display math pattern).
let body = make_item("log", 100.0, 500.0, 10.0, 15.0);
let sub = make_item("10", 115.5, 497.0, 7.0, 7.0);
let items = vec![body, sub.clone()];
assert!(ScriptBodyIndex::new(&items).is_script_attachment(&sub, 0.0));
}
#[test]
fn script_attachment_detects_superscript_footnote_marker() {
// "Hartley" at 10pt with superscript "2" raised above the baseline.
let body = make_item("Hartley", 200.0, 500.0, 10.0, 35.0);
let sup = make_item("2", 235.8, 504.0, 6.6, 3.5);
let items = vec![body, sup.clone()];
assert!(ScriptBodyIndex::new(&items).is_script_attachment(&sup, 0.0));
}
#[test]
fn script_attachment_ignores_small_cell_far_from_body_text() {
// A small-font table cell 40pt away from body text in another column
// must NOT be treated as a script attachment.
let body = make_item("Revenue", 100.0, 500.0, 10.0, 40.0);
let cell = make_item("1,234", 180.0, 500.0, 7.0, 20.0);
let items = vec![body, cell.clone()];
assert!(!ScriptBodyIndex::new(&items).is_script_attachment(&cell, 0.0));
}
#[test]
fn script_attachment_ignores_same_baseline_neighbor_cell() {
// A small cell immediately beside a larger label cell on the SAME
// baseline is a table layout, not a subscript — a genuine baseline
// offset is required.
let label = make_item("Total", 100.0, 500.0, 10.0, 25.0);
let cell = make_item("42", 127.0, 500.0, 7.5, 9.0);
let items = vec![label, cell.clone()];
assert!(!ScriptBodyIndex::new(&items).is_script_attachment(&cell, 0.0));
}
#[test]
fn script_attachment_ignores_neighbor_on_different_line() {
// Small item directly below a body item (next row) is not a script.
let body = make_item("Header", 100.0, 500.0, 10.0, 30.0);
let cell = make_item("42", 131.0, 486.0, 7.0, 10.0);
let items = vec![body, cell.clone()];
assert!(!ScriptBodyIndex::new(&items).is_script_attachment(&cell, 0.0));
}
/// The equation-subscript + footnote layout from Shannon entropy.pdf
/// page 1, with real coordinates. Without body anchors ("log" items) the
/// small-font items alone must form a phantom 3-column table — proving
/// this layout exercises the detection path — and adding the anchors
/// must suppress it via the script-attachment filter.
fn shannon_page1_small_items() -> Vec<TextItem> {
vec![
// Equation subscripts (7.4pt): log2 M = log10 M / log10 2
make_item("2", 267.4, 133.9, 7.4, 3.7),
make_item("10", 306.2, 133.9, 7.4, 7.4),
make_item("10", 342.7, 133.9, 7.4, 7.4),
make_item("10", 325.0, 118.9, 7.4, 7.4),
// Footnote block (8pt)
make_item("Bell System Technical Journal,", 295.7, 101.9, 8.0, 95.0),
make_item(
"April 1924, p. 324; Certain Topics in",
396.7,
101.9,
8.0,
130.0,
),
make_item("v. 47, April 1928, p. 617.", 250.9, 92.5, 8.0, 90.0),
make_item("Bell System Technical Journal,", 264.2, 82.6, 8.0, 95.0),
make_item("July 1928, p. 535.", 364.3, 82.6, 8.0, 65.0),
]
}
#[test]
fn equation_scripts_do_not_form_phantom_table() {
// Sanity: without the larger-font anchors the same items DO form a
// phantom table, so this layout genuinely reaches region detection.
let bare = shannon_page1_small_items();
assert!(
!detect_tables(&bare, 10.0, false).is_empty(),
"test layout must form a phantom table when the filter cannot fire"
);
// With the "log" anchors adjacent to each subscript, the script
// filter removes the subscripts and no table survives.
let mut items = shannon_page1_small_items();
items.push(make_item("log", 253.0, 137.0, 10.0, 13.5));
items.push(make_item("log", 291.5, 137.0, 10.0, 13.5));
items.push(make_item("log", 328.0, 137.0, 10.0, 13.5));
items.push(make_item("log", 310.3, 122.0, 10.0, 13.5));
let tables = detect_tables(&items, 10.0, false);
assert!(
tables.is_empty(),
"equation scripts + footnotes must not become a table: {tables:?}"
);
}
#[test]
fn is_table_of_contents_rejects_toc() {
+23 -10
View File
@@ -4,11 +4,17 @@
use log::{debug, warn};
use lopdf::{Document, Object, ObjectId};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
#[cfg(not(target_arch = "wasm32"))]
use std::path::{Path, PathBuf};
use crate::glyph_names::glyph_to_char;
#[cfg(target_arch = "wasm32")]
static BUILTIN_CMAPS: include_dir::Dir<'_> =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/external/bcmaps");
/// A parsed ToUnicode CMap mapping CIDs to Unicode strings
#[derive(Debug, Default, Clone)]
pub struct ToUnicodeCMap {
@@ -1149,9 +1155,7 @@ fn build_gid_to_unicode(face: &ttf_parser::Face<'_>) -> Option<HashMap<u16, char
/// Build a ToUnicodeCMap from pdf.js built-in binary CMaps (bcmaps).
fn build_cmap_from_builtin_cmap(ordering: &str) -> Option<ToUnicodeCMap> {
let name = format!("Adobe-{}-UCS2.bcmap", ordering);
let dir = find_bcmaps_dir()?;
let path = dir.join(name);
let data = std::fs::read(&path).ok()?;
let data = read_builtin_cmap_file(&name)?;
let mut cmap = parse_binary_cmap(&data).ok()?;
if cmap.char_map.is_empty() && cmap.ranges.is_empty() {
return None;
@@ -1159,13 +1163,14 @@ fn build_cmap_from_builtin_cmap(ordering: &str) -> Option<ToUnicodeCMap> {
cmap.code_byte_length = 2;
debug!(
"Built-in CMap {}: char_map={} ranges={}",
path.display(),
name,
cmap.char_map.len(),
cmap.ranges.len()
);
Some(cmap)
}
#[cfg(not(target_arch = "wasm32"))]
fn find_bcmaps_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("PDF_INSPECTOR_BCMAPS_DIR") {
let p = PathBuf::from(dir);
@@ -1182,6 +1187,18 @@ fn find_bcmaps_dir() -> Option<PathBuf> {
None
}
#[cfg(not(target_arch = "wasm32"))]
fn read_builtin_cmap_file(name: &str) -> Option<Cow<'static, [u8]>> {
let path = find_bcmaps_dir()?.join(name);
std::fs::read(path).ok().map(Cow::Owned)
}
#[cfg(target_arch = "wasm32")]
fn read_builtin_cmap_file(name: &str) -> Option<Cow<'static, [u8]>> {
let file = BUILTIN_CMAPS.get_file(name)?;
Some(Cow::Borrowed(file.contents()))
}
fn parse_binary_cmap(data: &[u8]) -> Result<ToUnicodeCMap, String> {
let mut stream = BinaryCMapStream::new(data);
let _header = stream.read_byte().ok_or("unexpected EOF in bcmap header")?;
@@ -1492,9 +1509,7 @@ fn parse_encoding_cmap_object(obj: &Object, doc: &Document) -> Option<EncodingCM
}
fn load_builtin_encoding_cmap(name: &str) -> Option<EncodingCMap> {
let dir = find_bcmaps_dir()?;
let path = dir.join(format!("{}.bcmap", name));
let data = std::fs::read(&path).ok()?;
let data = read_builtin_cmap_file(&format!("{}.bcmap", name))?;
parse_binary_cmap_encoding(&data).ok()
}
@@ -1779,9 +1794,7 @@ fn load_builtin_cmap_by_name(name: &str) -> Option<ToUnicodeCMap> {
if !name.ends_with("UCS2") {
return None;
}
let dir = find_bcmaps_dir()?;
let path = dir.join(format!("{}.bcmap", name));
let data = std::fs::read(&path).ok()?;
let data = read_builtin_cmap_file(&format!("{}.bcmap", name))?;
let mut cmap = parse_binary_cmap(&data).ok()?;
if cmap.char_map.is_empty() && cmap.ranges.is_empty() {
return None;
Binary file not shown.
+12
View File
@@ -1089,6 +1089,18 @@ fn test_snapshot_2013_app2() {
assert_snapshot("2013-app2");
}
/// First two pages of Shannon's "A Mathematical Theory of Communication"
/// (1998 dvips 5.58 → Distiller 3 retypesetting). Canonical legacy-TeX PDF:
/// non-embedded base-14 fonts with no /Widths (exercises the built-in AFM
/// metrics fallback), Type3 PK bitmap math fonts with FontMatrix
/// [1 0 0 -1 0 0] (exercises visual-size scaling), a two-line embedded drop
/// cap, indent-only paragraph breaks, and display math that must not be
/// detected as tables or headings.
#[test]
fn test_snapshot_shannon_entropy() {
assert_snapshot("shannon-entropy-p1-2");
}
// ============================================================================
// Pages Needing OCR Tests
// ============================================================================
+4 -1
View File
@@ -6,4 +6,7 @@
|Exclusive Special|83,500,000 75,909,091(7,590,909) with 3.5% individual consumption tax applied 82,232,000|79,275,000 with 3.5% individual consumption tax applied 79,275,000|▶ Standard equipment of Exclusive plus • Smart Safety Technology: Forward Collision-avoidance Assist(intersection crossing/changing lanes in oncoming traffic/approaching from either side/ evasive steering assist), Highway Driving Assist 2, Navigation-based Smart Cruise Control(access road) • Exterior: Roof rack • Interior: Metallic pedal, Driving mode-dependent ambient mood lighting(crash pad, 1st/2nd-row door trim) • Seat: Synthetic leather seats(patch applied), Power-adjustable driver's seat(8-way, lumbar support, and Integrated Memory System(driver's seat and outside mirror connected)), Power-adjustable front passengers seat(8-way), Ventilated 1st-row seats, Heated 2nd-row seats • Convenience: Hi-pass(e hi-pass), In-car fingerprint authentication system(personalization, startup, payment, and etc.), Smart power tailgate|▶ [600,000] Built-in Cam 2 Plus, Augmented reality navigation ▶ [850,000] Indoor/outdoor V2L ▶ [950,000] Parking Assist ▶ [1,150,000] Audio by BANG & OLUFSEN sound system ▶ [250,000] 19-inch alloy wheels & tires|
|Prestige|87,893,000 79,902,727(7,990,273) with 3.5% individual consumption tax applied 86,559,000|83,445,000 with 3.5% individual consumption tax applied 83,445,000|▶ Standard equipment of Exclusive Special plus • Smart Safety Technology: Remote Smart Parking Assist 2, Parking Collison- avoidance Assist(front/side/rear) • Exterior: Intelligent Front-Lighting System(IFS), Dynamic welcome/escort lighting(1 type), Sequential turn signals(front and rear), Ambient lighting auto flush door handles, Two-tone door garnish, Glossy black rear diffuser • Interior: Recycled PET suede interior materials(headlining/sunvisor), Fabric upholstered crash pad • Seat: BIO-processed natural leather seats(metal patch applied, embossed design punching), Passenger's seat walk-in device, 1st-row relaxation comfort seats(leg rest included), Ventilated 2nd-row seats • Convenience: Parking Distance Warning-Side, Head-Up Display, Digital key 2, Wireless phone charger(dual), Surround View Monitor, Blind-spot View Monitor, LED reverse light guide • Infotainment: Audio by BANG & OLUFSEN sound system(14 speakers, including external amp), Active road noise control, Active Sound Design|▶ [600,000] Built-in Cam 2 Plus, Augmented reality navigation ▶ [850,000] Indoor/outdoor V2L ▶ [900,000] Vision roof ▶ [1,380,000] Digital side mirror ▶ [750,000] Camera package ▶ [250,000] 19-inch alloy wheels & tires|
**Classification Details** **Indoor/outdoor V2L** Indoor V2L, Outdoor V2L(connectorless type) **Parking Assist** Surround View Monitor, Blind-spot View Monitor, Parking Distance Warning-Side, Parking Collison-avoidance Assist-Rear **Audio by BANG & OLUFSEN** Audio by BANG & OLUFSEN sound system(14 speakers, including external amp.), Active road noise control, Active Sound Design **sound system** **Camera package** Digital center mirror(with camera sensor cleaning system), Driver monitoring system THE ALL-NEW NEXO /// ECO-FRIENDLY CAR
**Classification Details** **Indoor/outdoor V2L** Indoor V2L, Outdoor V2L(connectorless type) **Parking Assist** Surround View Monitor, Blind-spot View Monitor, Parking Distance Warning-Side, Parking Collison-avoidance Assist-Rear **Audio by BANG & OLUFSEN**
Audio by BANG & OLUFSEN sound system(14 speakers, including external amp.), Active road noise control, Active Sound Design **sound system** **Camera package** Digital center mirror(with camera sensor cleaning system), Driver monitoring system THE ALL-NEW NEXO /// ECO-FRIENDLY CAR
+4 -8
View File
@@ -8,7 +8,7 @@ Department of the Treasury **Internal Revenue Service**
# and Report to Employer
### This publication contains:
## This publication contains:
**Form 4070A,** Employees Daily Record of Tips **Form 4070,** Employees Report of Tips to Employer
@@ -22,7 +22,7 @@ Name and address of employee
**Publication 1244 (Rev. 7-96)** Cat. No. 44472W
### Instructions
## Instructions
You must keep sufficient proof to show the amount of your tip income for the year. A daily record of your tip income is considered sufficient proof. Keep a daily record for each workday showing the amount of cash and credit card tips received directly from customers or other employees. Also keep a record of the amount of tips, if any, you paid to other employees through tip sharing, tip pooling or other arrangements, and the names of employees to whom you paid tips. Show the date that each entry is made. This date should be on or near the date you received the tip income. You may use **Form 4070A**, Employees Daily Record of Tips, or any other daily record to record your tips. **Reporting Tips to Your Employer.—**If you receive tips that total $20 or more for any month while working for one employer, you must report the tips to your employer. Tips include cash left by customers, tips customers add to credit card charges, and tips you receive from other employees. You must report your tips for any one month by the 10th day of the next month. If the 10th day falls on a Saturday, Sunday, or legal holiday, you may give the report to your employer on the next business day that is not a Saturday, Sunday, or legal holiday. You must report tips that total $20 or more every month regardless of your total wages and tips for the year. You may use **Form 4070**, Employees Report of Tips to Employer, to report your tips to your employer. See the instructions on the back of Form 4070. You must include all tips, including tips not reported to your employer, as wages on your income tax return. You may use the last page of this publication to total your tips for the year. Your employer must withhold income, social security, and Medicare (or railroad retirement) taxes on tips you report. Your employer usually deducts the withholding due on tips from your regular wages.
@@ -56,11 +56,7 @@ tips of directly from customers received other employees paid tips recd. entr
**2.** Report total credit card tips (col. **b**) on Form 4070, line **2.**
**3.** Report total tips paid out (col. **c**) on Form 4070, line **3.** **Page 4**
Form Employees Report (Rev. July 1996)
## of Tips to EmployerOMB No. 1545-0065
Department of the Treasury Internal Revenue Service' **For Paperwork Reduction Act Notice, see back of form.** Employees name and address **Social security number**
Form Employees Report (Rev. July 1996) of Tips to EmployerOMB No. 1545-0065 Department of the Treasury Internal Revenue Service' **For Paperwork Reduction Act Notice, see back of form.** Employees name and address **Social security number**
Employers name and address (include establishment name, if different) **1** Cash tips received
@@ -78,7 +74,7 @@ forms simpler, we would be happy to hear from you. You can write to the Tax Form
**Unreported Tips.—**If you received tips of $20 or more for any month while working for one employer but did not report them to your employer, you must figure and pay social security and Medicare taxes on the unreported tips when you file your tax return. If you have unreported tips, you **must** use Form 1040 and **Form 4137,** Social Security and Medicare Tax on Unreported Tip Income, to report them. You may **not** use Form 1040A or 1040EZ. Employees subject to the Railroad Retirement Tax Act **cannot** use Form 4137 to pay railroad retirement tax on unreported tips. To get railroad retirement credit, you must report tips to your employer. If you do not report tips to your employer as required, you may be charged a penalty of 50% of the social security and Medicare taxes (or railroad retirement tax) due on the unreported tips unless there was reasonable cause for not reporting them. **Additional Information.—**Get **Pub. 531,** Reporting Tip Income, and Form 4137 for more information on tips. If you are an employee of certain large food or beverage establishments, see Pub. 531 for tip allocation rules. **Recordkeeping.—**If you do not keep a daily record of tips, you must keep other reliable proof of the tip income you received. This proof includes copies of restaurant bills and credit card charges that show amounts customers added as tips. Keep your tip income records for as long as the information on them may be needed in the administration of any Internal Revenue law.
### Instructions (continued)
## Instructions (continued)
Use this space to total your tips for the year
+12 -2
View File
@@ -46,9 +46,19 @@ more about investing in tax losses than burst, cap rates spreads steadily com- r
8 6 Z E L L / L U R I E R E A L E S T A T E C E N T E R
ingdebtspreadswerepartoforiginalpro formamodels.Thiscapratespreadcom- pressionoffsetweakcashflowsinapost- recessionary economy from 2002 to 2005, while continued compression, combined with improved cash flows, pushed property values skyward in 2006 throughmid-2007. Cap rate compression reduced the importance of the ability to add value. After all, if all you had to do to make moneywastoleveragetothehiltwhilecap ratesfell,whytakeontheextraworkand riskofattemptingtoaddvalue?Stateddif- ferently: Why print money if it is laying everywhereonthestreets? In Tables III and IV, we demonstrate thepowerofcapratecompressionviavery simple pro forma cash flow analyses that assume Year 1 NOI of $100; a going-in cap rate of 9 percent; an LTV of 70 per- cent; and an interest rate of 7 percent. Withineachfigure,wedisplaytwoscenar- ios, which vary based on NOI growth assumptions.ScenarioIassumesthatNOI growsby3percentperyear,whileScenario IIassumesavalue-addNOIgrowthof20 percentbetweenyearstwoandthree. The only other difference between TablesIIIandIVisinresidualcaprates, which are assumed to be 6 percent and 9 percent, respectively. Based on these assumptions, we calculate the equity IRRs. It is clear that cap rate compres- sion is a significant factor in driving
ingdebtspreadswerepartoforiginalpro formamodels.Thiscapratespreadcom- pressionoffsetweakcashflowsinapost- recessionary economy from 2002 to 2005, while continued compression, combined with improved cash flows, pushed property values skyward in 2006 throughmid-2007.
returns. That is, cap rate compression from 9 percent to 6 percent increased IRR on leveraged stabilized properties by 250 percent, to a staggering 57 per- cent. Who needs to take on value add riskatthisreturnforstabilizedassets? Intheearly1980s,moneywasmadein real estate by mastering the creation and syndication of tax gimmicks. In the late 1980s, one made money by mastering bank and S&L connections to over-lever- age.Intheearly1990s,onemademoneyin realestatebyhavingaccesstoequity—the morethebetter.Duringthelate1990s,one made money from real estate by realizing large spreads between cap rates and debt costs.And,overthepastfiveyears,theway to make money in real estate was to own realestateonahighlyleveragedbasisascap ratesplunged. Theclassicassetpricingmodelisthe capital asset pricing model (CAPM). CAPM is a simple, yet elegant, model that relates asset pricing to the risk-free rate(F),theabilityofanassettoreduce portfolio variance (B), and the expected rate of return on the market bundle of investableassets(M).CAPMisfarfrom perfect,butprovidesacrudebenchmark for asset pricing, around which discrep- ancies and novelties arise. Specifically, CAPM states that an assets price is set suchthattheexpectedreturnforanasset
Cap rate compression reduced the importance of the ability to add value. After all, if all you had to do to make moneywastoleveragetothehiltwhilecap ratesfell,whytakeontheextraworkand riskofattemptingtoaddvalue?Stateddif- ferently: Why print money if it is laying everywhereonthestreets?
In Tables III and IV, we demonstrate thepowerofcapratecompressionviavery simple pro forma cash flow analyses that assume Year 1 NOI of $100; a going-in cap rate of 9 percent; an LTV of 70 per- cent; and an interest rate of 7 percent. Withineachfigure,wedisplaytwoscenar- ios, which vary based on NOI growth assumptions.ScenarioIassumesthatNOI growsby3percentperyear,whileScenario IIassumesavalue-addNOIgrowthof20 percentbetweenyearstwoandthree.
The only other difference between TablesIIIandIVisinresidualcaprates, which are assumed to be 6 percent and 9 percent, respectively. Based on these assumptions, we calculate the equity IRRs. It is clear that cap rate compres- sion is a significant factor in driving
returns. That is, cap rate compression from 9 percent to 6 percent increased IRR on leveraged stabilized properties by 250 percent, to a staggering 57 per- cent. Who needs to take on value add riskatthisreturnforstabilizedassets?
Intheearly1980s,moneywasmadein real estate by mastering the creation and syndication of tax gimmicks. In the late 1980s, one made money by mastering bank and S&L connections to over-lever- age.Intheearly1990s,onemademoneyin realestatebyhavingaccesstoequity—the morethebetter.Duringthelate1990s,one made money from real estate by realizing large spreads between cap rates and debt costs.And,overthepastfiveyears,theway to make money in real estate was to own realestateonahighlyleveragedbasisascap ratesplunged.
Theclassicassetpricingmodelisthe capital asset pricing model (CAPM). CAPM is a simple, yet elegant, model that relates asset pricing to the risk-free rate(F),theabilityofanassettoreduce portfolio variance (B), and the expected rate of return on the market bundle of investableassets(M).CAPMisfarfrom perfect,butprovidesacrudebenchmark for asset pricing, around which discrep- ancies and novelties arise. Specifically, CAPM states that an assets price is set suchthattheexpectedreturnforanasset
(R)is R=F+ β(M-F).
R E V I E W 8 7
+47
View File
@@ -0,0 +1,47 @@
Reprinted with corrections from *The Bell System Technical Journal,* Vol. 27, pp. 379423, 623656, July, October, 1948.
# A Mathematical Theory of Communication
## By C. E. SHANNON
INTRODUCTION
THE recent development of various methods of modulation such as PCM and PPM which exchange bandwidth for signal-to-noise ratio has intensified the interest in a general theory of communication. A basis for such a theory is contained in the important papers of Nyquist¹ and Hartley² on this subject. In the present paper we will extend the theory to include a number of new factors, in particular the effect of noise in the channel, and the savings possible due to the statistical structure of the original message and due to the nature of the final destination of the information.
The fundamental problem of communication is that of reproducing at one point either exactly or ap- proximately a message selected at another point. Frequently the messages have *meaning*; that is they refer to or are correlated according to some system with certain physical or conceptual entities. These semantic aspects of communication are irrelevant to the engineering problem. The significant aspect is that the actual message is one *selected from a set* of possible messages. The system must be designed to operate for each possible selection, not just the one which will actually be chosen since this is unknown at the time of design.
If the number of messages in the set is finite then this number or any monotonic function of this number can be regarded as a measure of the information produced when one message is chosen from the set, all choices being equally likely. As was pointed out by Hartley the most natural choice is the logarithmic function. Although this definition must be generalized considerably when we consider the influence of the statistics of the message and when we have a continuous range of messages, we will in all cases use an essentially logarithmic measure.
The logarithmic measure is more convenient for various reasons:
1. It is practically more useful. Parameters of engineering importance such as time, bandwidth, number of relays, etc., tend to vary linearly with the logarithm of the number of possibilities. For example, adding one relay to a group doubles the number of possible states of the relays. It adds 1 to the base 2 logarithm of this number. Doubling the time roughly squares the number of possible messages, or doubles the logarithm, etc.
2. It is nearer to our intuitive feeling as to the proper measure. This is closely related to (1) since we in- tuitively measures entities by linear comparison with common standards. One feels, for example, that two punched cards should have twice the capacity of one for information storage, and two identical channels twice the capacity of one for transmitting information.
3. It is mathematically more suitable. Many of the limiting operations are simple in terms of the loga- rithm but would require clumsy restatement in terms of the number of possibilities. The choice of a logarithmic base corresponds to the choice of a unit for measuring information. If the
base 2 is used the resulting units may be called binary digits, or more briefly *bits,* a word suggested by
J. W. Tukey. A device with two stable positions, such as a relay or a flip-flop circuit, can store one bit of information. *N* such devices can store*N* bits, since the total number of possible states is 2
*N* and log₂2 *N* = *N*. If the base 10 is used the units may be called decimal digits. Since
log₂*M* = log₁₀*M*= log₁₀2 = 3:32 log₁₀*M*;
1 Nyquist, H., “Certain Factors Affecting Telegraph Speed,” *Bell System Technical Journal,* April 1924, p. 324; “Certain Topics in Telegraph Transmission Theory,” *A.I.E.E. Trans.,* v. 47, April 1928, p. 617. Hartley, R. V. L., “Transmission of Information,” *Bell System Technical Journal,* July 1928, p. 535.
INFORMATION SOURCE TRANSMITTER RECEIVER DESTINATION
SIGNAL RECEIVED SIGNAL MESSAGE MESSAGE
NOISE SOURCE
Fig. 1 — Schematic diagram of a general communication system.
a decimal digit is about 3 13 bits. A digit wheel on a desk computing machine has ten stable positions and therefore has a storage capacity of one decimal digit. In analytical work where integration and differentiation are involved the base *e* is sometimes useful. The resulting units of information will be called natural units. Change from the base *a* to base *b* merely requires multiplication by log*ba*.
By a communication system we will mean a system of the type indicated schematically in Fig. 1. It consists of essentially five parts:
1. An *information source* which produces a message or sequence of messages to be communicated to the receiving terminal. The message may be of various types: (a) A sequence of letters as in a telegraph of teletype system; (b) A single function of time *f* (*t*) as in radio or telephony; (c) A function of time and other variables as in black and white television — here the message may be thought of as a function *f* (*x*; *y*;*t*) of two space coordinates and time, the light intensity at point (*x*; *y*) and time *t* on a pickup tube plate; (d) Two or more functions of time, say *f* (*t*), *g*(*t*), *h*(*t*) — this is the case in “three- dimensional” sound transmission or if the system is intended to service several individual channels in multiplex; (e) Several functions of several variables — in color television the message consists of three functions *f* (*x*; *y*;*t*), *g*(*x*; *y*;*t*), *h*(*x*; *y*;*t*) defined in a three-dimensional continuum — we may also think of these three functions as components of a vector field defined in the region — similarly, several black and white television sources would produce “messages” consisting of a number of functions of three variables; (f) Various combinations also occur, for example in television with an associated audio channel.
2. A *transmitter* which operates on the message in some way to produce a signal suitable for trans- mission over the channel. In telephony this operation consists merely of changing sound pressure into a proportional electrical current. In telegraphy we have an encoding operation which produces a sequence of dots, dashes and spaces on the channel corresponding to the message. In a multiplex PCM system the different speech functions must be sampled, compressed, quantized and encoded, and finally interleaved properly to construct the signal. Vocoder systems, television and frequency modulation are other examples of complex operations applied to the message to obtain the signal.
3. The *channel* is merely the medium used to transmit the signal from transmitter to receiver. It may be a pair of wires, a coaxial cable, a band of radio frequencies, a beam of light, etc.
4. The *receiver* ordinarily performs the inverse operation of that done by the transmitter, reconstructing the message from the signal.
5. The *destination* is the person (or thing) for whom the message is intended. We wish to consider certain general problems involving communication systems. To do this it is first
necessary to represent the various elements involved as mathematical entities, suitably idealized from their
+12 -10
View File
@@ -1,18 +1,20 @@
##### Technical Information
### Technical Information
## l T-12 SI
#### l T-12 SI
##### DuPont Fluorochemicals
#### DuPont Fluorochemicals
#### Thermodynamic Properties
## Thermodynamic Properties
**of** ®
**of**
®
# Freon 12
##### (R-12)
### (R-12)
##### Technical Information Technical Information
#### Technical Information Technical Information
**®** **Thermodynamic Properties of Freon 12 Refrigerant** **(R-12)** **SI Units**
@@ -22,11 +24,11 @@ S.A., Lemmon, E.W., and Peskin, Vf = Fluid (liquid) specific volume
A.P., NIST Standard Reference in cubic meters per kilogram Database 23, NIST thermodynamic and transport properties of Vg = Vapour (gas) specific volume refrigerants and refrigerant in cubic meters per kilogram mixtures REFPROP version 6.01, Standard Reference Data Program, df and dg = Fluid and Vapour National Institute of Standards and (respectively) densities in Technology, 1998).
kilograms per cubic meter
##### H = Enthalpy (kJ/kg)
H = Enthalpy (kJ/kg)
##### S = Entropy (kJ/kg.K)
S = Entropy (kJ/kg.K)
##### Physical Properties
#### Physical Properties
|Chemical Formula|CCl₂F₂|
|---|---|
+1304
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "pdf-inspector-wasm"
version = "0.1.3"
edition = "2021"
authors = ["Firecrawl Team"]
description = "Browser WebAssembly bindings for pdf-inspector"
license = "MIT"
repository = "https://github.com/firecrawl/pdf-inspector"
homepage = "https://github.com/firecrawl/pdf-inspector"
readme = "README.md"
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1"
js-sys = "0.3"
pdf-inspector = { path = ".." }
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
wasm-bindgen = "0.2"
[dev-dependencies]
wasm-bindgen-test = "0.3"
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
strip = true
[package.metadata.wasm-pack.profile.release]
# Rust 1.95 emits bulk-memory instructions that the binaryen bundled with
# wasm-pack 0.15.0 does not yet validate. rustc still performs the release,
# size, and LTO optimizations above.
wasm-opt = false
+58
View File
@@ -0,0 +1,58 @@
MIT License
Copyright (c) 2026 Firecrawl
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Third-party notices
===================
Adobe CMaps
-----------
The WebAssembly binary embeds binary CMaps derived from Adobe CMap resources.
Copyright 1990-2009 Adobe Systems Incorporated.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
Neither the name of Adobe Systems Incorporated nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
+60
View File
@@ -0,0 +1,60 @@
# @firecrawl/pdf-inspector-wasm
Browser WebAssembly bindings for [pdf-inspector](https://github.com/firecrawl/pdf-inspector). Classify PDFs and extract structured Markdown locally from a `Uint8Array`, using the same Rust core as the native Node.js, Python, and Rust packages.
## Install
```bash
npm install @firecrawl/pdf-inspector-wasm
```
## Usage
```ts
import init, { processPdf } from "@firecrawl/pdf-inspector-wasm";
await init();
const response = await fetch("/annual-report.pdf");
const pdf = new Uint8Array(await response.arrayBuffer());
const result = processPdf(pdf);
console.log(result.pdfType);
console.log(result.markdown);
```
Pass options when you need selected pages or compact Markdown:
```ts
const result = processPdf(pdf, {
pages: [1, 3, 5],
profile: "compact",
includePageMarkers: true,
});
```
The package also exports:
- `detectPdf(pdf, options?)` for detection without extraction.
- `classifyPdf(pdf)` for the lightweight result shape shared with the native Node.js API.
- `extractText(pdf)` for plain text.
- `version()` for the WASM package version.
## Browser behavior
- Parsing runs locally. PDF bytes are not uploaded anywhere.
- The build is single-threaded and does not require cross-origin isolation.
- CMaps are embedded so CJK font decoding does not depend on a filesystem.
- Extraction is synchronous after `init()`. For large documents, call it from a Web Worker to keep the UI responsive.
- Image-only documents still require a separate OCR step.
## Build from source
```bash
cargo install wasm-pack --version 0.15.0 --locked
wasm-pack build wasm --target web --scope firecrawl --release
```
## License
MIT
+440
View File
@@ -0,0 +1,440 @@
use pdf_inspector::{
LayoutComplexity, MarkdownProfile, PageOcrReasons, PdfOptions, PdfProcessResult, PdfType,
ProcessMode,
};
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
#[wasm_bindgen(typescript_custom_section)]
const TYPESCRIPT_TYPES: &str = r#"
export type PdfType = "TextBased" | "Scanned" | "ImageBased" | "Mixed";
export type MarkdownProfile = "fidelity" | "compact";
export interface ProcessOptions {
/** Restrict extraction to these 1-indexed page numbers. */
pages?: number[];
/** Password for an encrypted PDF. */
password?: string;
/** Source-faithful output by default, or compact output for fewer tokens. */
profile?: MarkdownProfile;
/** Insert `<!-- Page N -->` markers between pages. */
includePageMarkers?: boolean;
/** Include image placeholders in Markdown output. */
includeImages?: boolean;
}
export interface PageOcrReasons {
/** 1-indexed page number. */
page: number;
reasons: string[];
}
export interface LayoutComplexity {
isComplex: boolean;
/** 1-indexed page numbers. */
pagesWithTables: number[];
/** 1-indexed page numbers. */
pagesWithColumns: number[];
}
export interface PdfProcessResult {
pdfType: PdfType;
markdown?: string;
pageCount: number;
processingTimeMs: number;
/** 1-indexed page numbers. */
pagesNeedingOcr: number[];
ocrReasonsByPage: PageOcrReasons[];
title?: string;
confidence: number;
layout: LayoutComplexity;
hasEncodingIssues: boolean;
}
export interface PdfClassification {
pdfType: PdfType;
pageCount: number;
/** 0-indexed page numbers, matching the native Node.js API. */
pagesNeedingOcr: number[];
confidence: number;
}
export function processPdf(data: Uint8Array, options?: ProcessOptions): PdfProcessResult;
export function detectPdf(data: Uint8Array, options?: Pick<ProcessOptions, "password">): PdfProcessResult;
export function classifyPdf(data: Uint8Array): PdfClassification;
export function extractText(data: Uint8Array): string;
export function version(): string;
"#;
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
struct WasmProcessOptions {
pages: Option<Vec<u32>>,
password: Option<String>,
profile: Option<WasmMarkdownProfile>,
include_page_markers: Option<bool>,
include_images: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum WasmMarkdownProfile {
Fidelity,
Compact,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WasmPageOcrReasons {
page: u32,
reasons: Vec<String>,
}
impl From<PageOcrReasons> for WasmPageOcrReasons {
fn from(value: PageOcrReasons) -> Self {
Self {
page: value.page,
reasons: value.reasons,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WasmLayoutComplexity {
is_complex: bool,
pages_with_tables: Vec<u32>,
pages_with_columns: Vec<u32>,
}
impl From<LayoutComplexity> for WasmLayoutComplexity {
fn from(value: LayoutComplexity) -> Self {
Self {
is_complex: value.is_complex,
pages_with_tables: value.pages_with_tables,
pages_with_columns: value.pages_with_columns,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WasmPdfProcessResult {
pdf_type: &'static str,
markdown: Option<String>,
page_count: u32,
processing_time_ms: f64,
pages_needing_ocr: Vec<u32>,
ocr_reasons_by_page: Vec<WasmPageOcrReasons>,
title: Option<String>,
confidence: f64,
layout: WasmLayoutComplexity,
has_encoding_issues: bool,
}
impl From<PdfProcessResult> for WasmPdfProcessResult {
fn from(value: PdfProcessResult) -> Self {
Self {
pdf_type: pdf_type_name(value.pdf_type),
markdown: value.markdown,
page_count: value.page_count,
processing_time_ms: value.processing_time_ms as f64,
pages_needing_ocr: value.pages_needing_ocr,
ocr_reasons_by_page: value
.ocr_reasons_by_page
.into_iter()
.map(Into::into)
.collect(),
title: value.title,
confidence: value.confidence as f64,
layout: value.layout.into(),
has_encoding_issues: value.has_encoding_issues,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WasmPdfClassification {
pdf_type: &'static str,
page_count: u32,
pages_needing_ocr: Vec<u32>,
confidence: f64,
}
fn pdf_type_name(pdf_type: PdfType) -> &'static str {
match pdf_type {
PdfType::TextBased => "TextBased",
PdfType::Scanned => "Scanned",
PdfType::ImageBased => "ImageBased",
PdfType::Mixed => "Mixed",
}
}
fn js_error(context: &str, error: impl std::fmt::Display) -> JsValue {
js_sys::Error::new(&format!("{context}: {error}")).into()
}
fn deserialize_options(value: JsValue) -> Result<WasmProcessOptions, JsValue> {
if value.is_undefined() || value.is_null() {
return Ok(WasmProcessOptions::default());
}
serde_wasm_bindgen::from_value(value).map_err(|error| js_error("invalid options", error))
}
fn build_options(value: JsValue, mode: ProcessMode) -> Result<PdfOptions, JsValue> {
let options = deserialize_options(value)?;
if options
.pages
.as_ref()
.is_some_and(|pages| pages.contains(&0))
{
return Err(js_error(
"invalid options",
"pages are 1-indexed; page 0 is invalid",
));
}
let mut result = PdfOptions::new().mode(mode);
if let Some(pages) = options.pages {
result = result.pages(pages);
}
if let Some(password) = options.password {
result = result.password(password);
}
if let Some(profile) = options.profile {
result.markdown.profile = match profile {
WasmMarkdownProfile::Fidelity => MarkdownProfile::Fidelity,
WasmMarkdownProfile::Compact => MarkdownProfile::Compact,
};
}
if let Some(include_page_markers) = options.include_page_markers {
result.markdown.include_page_numbers = include_page_markers;
}
if let Some(include_images) = options.include_images {
result.markdown.include_images = include_images;
}
Ok(result)
}
fn serialize<T: Serialize>(value: &T) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(value).map_err(|error| js_error("serialize result", error))
}
fn initialize() {
console_error_panic_hook::set_once();
}
/// Process PDF bytes entirely inside WebAssembly.
#[wasm_bindgen(js_name = processPdf, skip_typescript)]
pub fn process_pdf(data: &[u8], options: JsValue) -> Result<JsValue, JsValue> {
initialize();
let options = build_options(options, ProcessMode::Full)?;
let started = js_sys::Date::now();
let mut result = pdf_inspector::process_pdf_mem_with_options(data, options)
.map_err(|error| js_error("process PDF", error))?;
result.processing_time_ms = (js_sys::Date::now() - started).max(0.0) as u64;
serialize(&WasmPdfProcessResult::from(result))
}
/// Classify PDF bytes without extracting text or producing Markdown.
#[wasm_bindgen(js_name = detectPdf, skip_typescript)]
pub fn detect_pdf(data: &[u8], options: JsValue) -> Result<JsValue, JsValue> {
initialize();
let options = build_options(options, ProcessMode::DetectOnly)?;
let started = js_sys::Date::now();
let mut result = pdf_inspector::process_pdf_mem_with_options(data, options)
.map_err(|error| js_error("detect PDF", error))?;
result.processing_time_ms = (js_sys::Date::now() - started).max(0.0) as u64;
serialize(&WasmPdfProcessResult::from(result))
}
/// Return the lightweight classification shape used by the native Node API.
#[wasm_bindgen(js_name = classifyPdf, skip_typescript)]
pub fn classify_pdf(data: &[u8]) -> Result<JsValue, JsValue> {
initialize();
let result =
pdf_inspector::classify_pdf_mem(data).map_err(|error| js_error("classify PDF", error))?;
serialize(&WasmPdfClassification {
pdf_type: pdf_type_name(result.pdf_type),
page_count: result.page_count,
pages_needing_ocr: result.pages_needing_ocr,
confidence: result.confidence as f64,
})
}
/// Extract plain text from PDF bytes without Markdown conversion.
#[wasm_bindgen(js_name = extractText, skip_typescript)]
pub fn extract_text(data: &[u8]) -> Result<String, JsValue> {
initialize();
let items = pdf_inspector::extractor::extract_text_with_positions_mem(data)
.map_err(|error| js_error("extract text", error))?;
Ok(
pdf_inspector::extractor::group_into_lines_preserving_all_text(items)
.into_iter()
.map(|line| line.text())
.filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>()
.join("\n"),
)
}
/// Return the WebAssembly package version.
#[wasm_bindgen(skip_typescript)]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[cfg(all(test, target_arch = "wasm32"))]
mod tests {
use super::*;
use js_sys::Reflect;
use wasm_bindgen_test::*;
const TEXT_PDF: &[u8] = include_bytes!("../../tests/fixtures/thermo-freon12.pdf");
const ENCRYPTED_PDF: &[u8] = include_bytes!("../../tests/fixtures/encrypted-secret123.pdf");
fn synthetic_korea1_pdf() -> Vec<u8> {
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = vec![0usize];
fn add_object(pdf: &mut Vec<u8>, offsets: &mut Vec<usize>, id: usize, body: &str) {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
pdf.extend_from_slice(body.as_bytes());
pdf.extend_from_slice(b"\nendobj\n");
}
add_object(
&mut pdf,
&mut offsets,
1,
"<< /Type /Catalog /Pages 2 0 R >>",
);
add_object(
&mut pdf,
&mut offsets,
2,
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
);
add_object(
&mut pdf,
&mut offsets,
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
// Adobe-Korea1 CID 1086 (0x043E) maps to U+AC00 (Korean syllable GA).
// There is deliberately no ToUnicode stream: decoding must use the
// embedded predefined CMap rather than lopdf's plain-text fallback.
// Korea1 CIDs 21 and 19 map to ASCII "4" and "2". Place them near
// the bottom edge so they look exactly like a numeric page footer.
let content = "BT /F0 12 Tf 50 100 Td <043E> Tj 0 -60 Td <00150013> Tj ET";
add_object(
&mut pdf,
&mut offsets,
4,
&format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content.len(),
content
),
);
add_object(
&mut pdf,
&mut offsets,
5,
"<< /Type /Font /Subtype /Type0 /BaseFont /SyntheticKorea1 /Encoding /Identity-H /DescendantFonts [6 0 R] >>",
);
add_object(
&mut pdf,
&mut offsets,
6,
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /SyntheticKorea1 /CIDSystemInfo << /Registry (Adobe) /Ordering (Korea1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >>",
);
add_object(
&mut pdf,
&mut offsets,
7,
"<< /Type /FontDescriptor /FontName /SyntheticKorea1 /Flags 4 /FontBBox [-100 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >>",
);
let xref_start = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n", offsets.len()).as_bytes());
pdf.extend_from_slice(b"0000000000 65535 f \n");
for offset in offsets.iter().skip(1) {
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
offsets.len(),
xref_start
)
.as_bytes(),
);
pdf
}
#[wasm_bindgen_test]
fn processes_pdf_to_markdown() {
let result = process_pdf(TEXT_PDF, JsValue::UNDEFINED).expect("process PDF");
let pdf_type = Reflect::get(&result, &JsValue::from_str("pdfType"))
.expect("pdfType")
.as_string()
.expect("pdfType string");
let markdown = Reflect::get(&result, &JsValue::from_str("markdown"))
.expect("markdown")
.as_string()
.expect("markdown string");
assert_eq!(pdf_type, "TextBased");
assert!(!markdown.is_empty());
}
#[wasm_bindgen_test]
fn rejects_non_pdf_bytes() {
assert!(process_pdf(b"not a PDF", JsValue::UNDEFINED).is_err());
}
#[wasm_bindgen_test]
fn classifies_and_extracts_plain_text() {
let classification = classify_pdf(TEXT_PDF).expect("classify PDF");
let pdf_type = Reflect::get(&classification, &JsValue::from_str("pdfType"))
.expect("pdfType")
.as_string()
.expect("pdfType string");
let text = extract_text(TEXT_PDF).expect("extract text");
assert_eq!(pdf_type, "TextBased");
assert!(!text.is_empty());
}
#[wasm_bindgen_test]
fn extracts_cjk_and_preserves_numeric_page_footer() {
let text = extract_text(&synthetic_korea1_pdf()).expect("extract predefined CMap text");
assert_eq!(text, "\n42");
}
#[wasm_bindgen_test]
fn opens_encrypted_pdf_with_password() {
assert!(process_pdf(ENCRYPTED_PDF, JsValue::UNDEFINED).is_err());
let options = js_sys::Object::new();
Reflect::set(
&options,
&JsValue::from_str("password"),
&JsValue::from_str("secret123"),
)
.expect("set password");
let result = process_pdf(ENCRYPTED_PDF, options.into()).expect("process encrypted PDF");
let markdown = Reflect::get(&result, &JsValue::from_str("markdown"))
.expect("markdown")
.as_string()
.expect("markdown string");
assert!(!markdown.is_empty());
}
}