Compare commits

...
Author SHA1 Message Date
Abimael Martell 399e83caf2 fix(site): refresh benchmark results 2026-08-06 12:44:34 -07:00
Andrew Barnes 54a1e9ab74 Preserve page-prefixed Markdown content (#284) 2026-08-06 12:14:27 -07:00
m-naoki-mandClaude Opus 5 fabbb63521 fix(tounicode): skip subset GID remap for CIDFontType0 (CFF) descendants (#209)
* fix(tounicode): skip subset GID remap for CIDFontType0 (CFF) descendants

The sequential-GID repair in try_remap_subset_cmap assumes CIDs are glyph
indices that a subsetter can renumber. That holds for CIDFontType2
(TrueType) but not for CIDFontType0 (CFF), where CIDs are resolved through
the CFF charset, so a valid ToUnicode CMap stays valid after subsetting.

For CFF fonts the corrupting path was unavoidable: CIDToGIDMap is
CIDFontType2-only (PDF 32000-1:2008, 9.7.4.2), so the branch that repairs
the CMap correctly can never be taken, and any CFF font whose /W array
starts at a low CID fell through into remap_to_sequential. Japanese
Adobe-Japan1 documents extracted as long runs of a single unrelated kanji.

Guard both repair paths on a CIDFontType2 descendant, placed before the
CIDToGIDMap branch so a CIDToGIDMap wrongly attached to a CFF font by a
malformed producer is ignored too.

On a National Diet Library proceedings PDF: 1233 U+FFFD in 69099 chars
before, 0 in 68331 after; character 3-gram recall against a hand-written
ground truth 0.354 -> 0.605. The PDF from #118 (CIDFontType2) extracts
byte-identically before and after.

Two existing tests build descendant dicts without a /Subtype and set
CIDToGIDMap, which is CIDFontType2-only, so the fixtures now say what they
already meant. Without that, test_try_remap_skipped_when_w_covers_cmap
would keep passing while no longer exercising the W-coverage logic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tounicode): resolve indirect /Subtype, skip only explicit non-CIDFontType2

Addresses review feedback on the guard added in the previous commit.

- /Subtype may be an indirect reference, and as_name() does not dereference
  it. A genuine CIDFontType2 font storing /Subtype indirectly would have been
  read as "not CIDFontType2", returning early and losing the repair it needs —
  reintroducing the corruption this PR fixes, for those fonts. Resolve the
  reference through the document before comparing.

- Bail out only when /Subtype is explicitly a non-CIDFontType2 name. A missing
  or unresolvable /Subtype now keeps the pre-existing behaviour instead of
  silently disabling the repair. As a result the two existing tests no longer
  need fixture changes, and this commit reverts those; the diff against main
  is now additive only.

- The CFF regression test now attaches a real CIDToGIDMap stream rather than
  /Identity, which get_cid_to_gid_map treats as "no map". With the stream, the
  test also fails if the guard is moved back below the CIDToGIDMap branch —
  verified by moving it and watching it fail.

- Added test_try_remap_resolves_indirect_subtype.

cargo fmt --check, cargo clippy -- -D warnings and cargo test (862 tests) pass.
The Diet PDF still extracts with 0 U+FFFD and the #118 PDF is still unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:02:27 -07:00
585d36e6a6 fix: recover from a corrupted startxref pointer (#230)
* fix: recover from a corrupted startxref pointer

Fixes #228.

A PDF whose startxref pointer has been corrupted to point at the wrong
byte offset — a single flipped digit, which is what damaged writers
emit in the wild — was entirely unprocessable: every entry point
(classify_pdf, extract_pages_markdown, process_pdf) raised "Invalid
PDF structure", even though the file's object data, real xref table,
and trailer were all completely intact just past the wrong pointer.
Both pypdf and pdfium recover from this by locating the real table
directly instead of trusting the pointer; lopdf doesn't.

Added a new repair candidate (alongside the existing
missing-%%EOF-marker and stripped-leading-bytes repairs in
repair_pdf_container_candidates): scan the buffer for the real,
standalone `xref` keyword and append a corrected trailing
`startxref`/`%%EOF` block. lopdf's own get_xref_start always reads the
*last* `%%EOF` in the final 512 bytes of the buffer and the
`startxref` value immediately before it, so the appended block
transparently supersedes the corrupted one already in the file — no
in-place byte surgery on content the original writer produced.

Scoped to classic (non-stream) xref tables, matching the reported
repro and the common case; a corrupted pointer into a cross-reference
*stream* (`N 0 obj << /Type /XRef ...>>`, some PDF 1.5+ writers) would
need the containing object's number, not just a byte offset — out of
scope here.

Verified against the issue's exact repro (a valid one-page PDF with a
single corrupted byte in its startxref offset): before this fix,
process_pdf/classify_pdf/extract_pages_markdown all raised "Invalid
PDF structure"; after, both the page count and the real extracted text
("Order Detail Report by Account", "WIDGET ASSEMBLY", the dollar
amount) come back correctly. New regression test added.

Full suite (859 tests, 1 new) passes; cargo clippy --all-targets
-- -D warnings unchanged at 28 pre-existing/unrelated errors.

* fix: validate xref table shape and scan in a single reverse pass

Addresses cubic-dev-ai's review of #230.

- P2 (correctness/safety): the recovery candidate trusted the last
  standalone "xref" token unconditionally, without confirming it's
  actually a cross-reference table. A coincidental "xref" substring
  inside unrelated content — a stream, a string, uncompressed
  metadata — could get "repaired" against a bogus offset, letting
  lopdf load successfully against garbage instead of returning a
  clean error: a real failure turned into silent data corruption on
  the fallback path. Added looks_like_xref_subsection_header, which
  confirms a plausible classic xref subsection header (`<start-id>
  <count>`, e.g. "0 6" — the shape every real classic table starts
  with) actually follows the candidate token before accepting it.
  find_last_valid_xref_table_start now walks backward from the end of
  the buffer until it finds a token that both stands alone *and*
  validates, rather than accepting the first (rightmost) standalone
  match unconditionally.

- P2 (performance): the old scan re-invoked
  `buf[..search_end].windows(4).rposition(...)` on a shrinking prefix
  every time a candidate token failed the boundary check, which is
  quadratic on a pathological buffer with many non-standalone "xref"
  occurrences. Rewrote as a single reverse byte-index walk — O(n)
  regardless of how many false candidates it has to reject along the
  way.

Added direct unit tests on the byte-level scan (more precise than
constructing adversarial full PDFs, and the coincidental-match
scenario can't be represented in an integration-test fixture anyway
since reportlab compresses page content by default): a coincidental
standalone "xref" with no subsection header is rejected; a real
classic table is found; a coincidental match positioned *after* the
real table in the buffer doesn't shadow it; "xref" as a substring of
"startxref" still doesn't match. The original #228 repro (corrupted
startxref pointer, real table otherwise intact) is unaffected —
verified manually in addition to the existing integration test.

Full suite (863 tests, 5 new) passes; cargo clippy --all-targets
-- -D warnings unchanged at 28 pre-existing/unrelated errors.

* fix: reject xref subsection count runs with trailing garbage

looks_like_xref_subsection_header validated that a count run of digits
followed the whitespace separator, but never checked what came after
it. A coincidental "xref\n0 6garbage" in stream/literal content would
still validate as a real subsection header shape and get repaired
against a bogus offset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com>
2026-08-05 16:43:11 -07:00
371de80b14 fix: extract_pages_markdown's needs_ocr now agrees with classify_pdf (#231)
* fix: extract_pages_markdown's needs_ocr now agrees with classify_pdf

Fixes #227.

extract_pages_markdown_mem computed its per-page needs_ocr entirely
from text-quality signals: decoding/garble issues, empty markdown, GID
fonts, garbage-text ratio. It had no awareness of the page's image
content at all — so a page that is fundamentally a full-page scan
with a little genuine native text drawn over it (a header, a stamp, a
cover-sheet annotation) extracts that text cleanly, trips none of the
text-quality checks, and reports needs_ocr=false — while
classify_pdf/detect_pdf_type correctly see the dominant background
image and flag the same page as needing OCR. Two public APIs
answering the same question, silently disagreeing, in the unsafe
direction (skipping OCR on a page that needs it).

Exposed detector::analyze_page_images at crate visibility (was
private) and call it per page in extract_pages_markdown_mem's loop —
the same "large background image" signal (>50% page coverage) that
already powers has_template_image in classify_pdf/detect_pdf_type,
rather than reimplementing image-area detection a second time with
its own thresholds that could drift out of sync again. When it's
true, the page is flagged needs_ocr (with OCR_REASON_SCANNED added
to ocr_reasons_by_page, matching how the same signal is already
reported elsewhere) and its markdown is blanked, exactly like the
existing text-quality-triggered needs_ocr paths already do — no
special-casing added for "cleanly-extracted-but-still-a-scan" text.

Verified against the issue's exact repro (a full-page raster with one
native text line drawn over it, built via reportlab/pillow): before
this fix, extract_pages_markdown_bytes reported page 0
needs_ocr=False with the header line as markdown while
classify_pdf_bytes correctly flagged pages_needing_ocr=[0]; after,
both agree needs_ocr=True and the page's markdown is empty. Confirmed
no regression on a normal text-based fixture (nexo-price-en.pdf:
needs_ocr stays False, full markdown returned). New Rust regression
test added exercising both APIs against the same fixture.

Full suite (860 tests, 1 new) passes; cargo clippy --all-targets
-- -D warnings unchanged at 28 pre-existing/unrelated errors.

* fix: gate has_template_image behind the same OCR signals classify_pdf uses

extract_pages_markdown_mem was treating has_template_image alone as
sufficient to force needs_ocr=true and discard the page's markdown, but
classify_pdf/detect_pdf_type never treats that raw signal alone as
needing OCR. A text page with a full-bleed watermark, letterhead, or
large figure would get its clean markdown wrongly blanked and routed
to OCR.

Added page_template_image_needs_ocr(), mirroring the two distinct
signals classify_pdf actually uses to decide a template-image page
needs OCR: the looks_like_scan gate (image_count <= 1, few text ops,
low alphanumeric diversity) used for Mixed-type routing, and the
insufficient-text-volume signal (text_operator_count < 10) that routes
a page with a dominant background image and only a couple of native
text calls to PdfType::ImageBased independent of looks_like_scan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: match per-page OCR threshold and add missing vector-text signal

Two follow-up findings on the has_template_image gate added in the
previous commit:

1. insufficient_text used a hard-coded threshold of 10 text operators,
   but Mixed-type per-page routing (the actual per-page decision this
   function tries to agree with) uses config.min_text_ops_per_page
   (default 3). The higher 10 threshold was borrowed from a *different*
   classify_pdf code path — the effective_min_ops floor used only for
   whole-document ImageBased/Scanned classification, a cross-page
   aggregate this per-page function can't replicate anyway. Using the
   lower per-page threshold removes a real disagreement window
   (3-9 text ops with high alphanumeric diversity) without breaking
   the #227 regression fixture (text_ops=1, still well under 3).

2. extract_pages_markdown_mem never checked has_vector_text at all,
   even though Mixed-type per-page routing always sends
   vector-outlined-text pages to OCR (outlined glyphs can't be
   extracted as text). A page with massive path ops plus a short
   genuine caption could extract that caption cleanly, slipping past
   the existing empty/garbage-text checks. Added
   page_has_vector_text() and wired it into needs_ocr the same way
   has_template_image is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* perf: compute template-image and vector-text OCR signals in one pass

page_template_image_needs_ocr and page_has_vector_text each called
analyze_page_content independently, so every requested page's content
streams (page + XObjects) and image coverage were decompressed and
scanned twice per page with one result discarded each time.
detect_from_document avoids this by caching its per-page PageAnalysis;
extract_pages_markdown_mem had no such cache.

Merged both into page_ocr_signals(), a single analyze_page_content
call returning both signals as a tuple.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Abimael Martell <1450169+abimaelmartell@users.noreply.github.com>
2026-08-05 15:41:44 -07:00
Abimael Martell ede48099c0 fix(layout): preserve ruled tables and chart prose order (#262)
* fix(layout): preserve ruled tables and chart prose order

* fix(layout): harden chart region detection

* fix(layout): tighten chart geometry guards

* fix(layout): bound chart inference

* fix(layout): tighten chart claim bounds

* fix(layout): tighten chart evidence

* fix(layout): preserve edge-adjacent chart labels

* fix(layout): require external chart label overlap
2026-08-05 10:22:13 -07:00
Abimael Martell 12e9a655e3 fix(extractor): supply built-in metrics for non-embedded base-14 fonts (#241)
* 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.

* review: narrow Type3 rescaling to self-inconsistent fonts; dedup + test all width tables

Addresses cubic review on #241, plus a follow-up from a local cubic run.

- Type3 visual scaling was applied to every Type3 font whose FontBBox
  height x |matrix_y| deviated >5% from 1.0. FontBBox is the glyph box,
  not the em box, so a conventional 1/1000-matrix font with a
  descender..ascender bbox (~700 units) computed 0.7 and had every
  reported size shrunk by 30% — corrupting the drop-cap, heading-tier,
  sub/superscript and table heuristics this is meant to fix.

  First attempt gated on the matrix being unit-scale, but a local cubic
  run pointed out that wrongly excludes valid non-standard matrices (a
  0.005 matrix with a full-em bbox legitimately needs a 5x scale). The
  product is the right discriminator, not the matrix: a self-consistent
  font lands near 1.0 because the matrix is the reciprocal of the
  glyph-space em, so only a wildly inconsistent one (dvips/PK bitmap
  fonts sit at ~159) is renormalized. Band widened to [0.25, 4.0].

  Corpus effect: 12 -> 7 documents change. The 5 that drop out were
  being wrongly rescaled — including Data-Processing-Agreement, whose
  phantom-table fix turned out to come from this bug rather than from
  the width fallback, so it is correctly given up.

- base14: all 14 width tables now covered by the sort-invariant test via
  an ALL_TABLES registry, not a hand-picked subset.
- base14: identical tables share one static (all four Courier variants
  are monospace 600; the oblique Helvetica variants match their upright
  forms), removing 5 duplicate copies.

* test: refresh Shannon snapshot after merging main

CI checks out a merge of the PR head with main, and main advanced 8
commits since this branch was cut — including #201 (contextual digit
runs), #240 and #253 (markdown fixes). Those change extraction output,
so a snapshot generated on the unmerged branch could not match; the
Test job failed on the merge commit while passing on the branch itself.

The merged behaviour is better: the footnote marker '2' before
'Hartley, R. V. L.' is now recovered instead of dropped.

950 tests pass on the merged tree, clippy clean.
2026-08-04 18:00:43 -07:00
Abimael MartellandClaude Fable 5 1d134e26aa docs: sync AGENTS.md with CLAUDE.md, refresh eval workflow guidance (#243)
AGENTS.md was stale (179+ PDFs, missing the semantic-quality bullet).
Both files now match: ~200-PDF corpus, and iteration guidance to prefer
subset runs (bench.py test -q / -s <name>) with the full suite as the
final pre-commit check.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:26:04 -07:00
Shubham Mathur bfd6c3eabb fix(extractor): make the comment stripper escape-aware (#259)
strip_pdf_comments tracked parenthesis nesting to protect string
literals, but ignored backslash escapes. An escaped \) desynced the
depth counter, after which a % glyph inside a string was stripped as a
top-level comment, corrupting the stream for Content::decode and
silently truncating the page's text.

Treat \ inside a string literal as escaping the next byte, so \(,
\), and \\ never touch the nesting depth.
2026-08-04 16:22:25 -07:00
25 changed files with 4013 additions and 57 deletions
+1
View File
@@ -37,6 +37,7 @@ scripts/
# Test output
test_output/
.firecrawl/
# Python
__pycache__/
+2 -1
View File
@@ -61,7 +61,8 @@ src/
- **Unit tests**: inline `#[cfg(test)] mod tests` in each module with synthetic data.
- **Integration tests**: `tests/integration_tests.rs` with fixture PDFs in `tests/fixtures/`.
- **Regression suite**: sibling repo `pdf-evals` with 179+ snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing.
- **Regression suite**: sibling repo `pdf-evals` with ~200 snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing. While iterating, prefer a subset run (`bench.py test -q` for the quick set, or `-s <name>` for a named test set) and save the full `bench.py test` for the final pre-commit check.
- **Semantic quality**: run `bench.py score` in `pdf-evals` for the semantic verdict (TEDS + MHS + reading order + char/word + list preservation, composited). Character-level diff alone misclassifies structural improvements (e.g., column-detection rewrites) as regressions — `score` is the tie-breaker. See `pdf-evals/CLAUDE.md` "Semantic scoring".
## Debugging
+1 -1
View File
@@ -61,7 +61,7 @@ src/
- **Unit tests**: inline `#[cfg(test)] mod tests` in each module with synthetic data.
- **Integration tests**: `tests/integration_tests.rs` with fixture PDFs in `tests/fixtures/`.
- **Regression suite**: sibling repo `pdf-evals` with 187+ snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing.
- **Regression suite**: sibling repo `pdf-evals` with ~200 snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing. While iterating, prefer a subset run (`bench.py test -q` for the quick set, or `-s <name>` for a named test set) and save the full `bench.py test` for the final pre-commit check.
- **Semantic quality**: run `bench.py score` in `pdf-evals` for the semantic verdict (TEDS + MHS + reading order + char/word + list preservation, composited). Character-level diff alone misclassifies structural improvements (e.g., column-detection rewrites) as regressions — `score` is the tie-breaker. See `pdf-evals/CLAUDE.md` "Semantic scoring".
## Debugging
+7 -7
View File
@@ -858,22 +858,22 @@
<p>Evaluated on the <a class="text-link" href="https://github.com/opendataloader-project/opendataloader-bench">opendataloader-bench</a> corpus of 200 PDFs. This comparison covers local engines without model-based PDF parsing, with OCR disabled. Higher scores are better.</p>
</div>
<div class="benchmark-card">
<div class="benchmark-top"><span><strong>200 PDFs</strong> · OpenDataLoader benchmark</span><span>Apple M4 Pro · median of 3 runs</span></div>
<div class="benchmark-top"><span><strong>200 PDFs</strong> · OpenDataLoader benchmark</span><span>Apple M4 Pro · median of 5 runs</span></div>
<div class="table-scroll">
<table aria-label="PDF extraction benchmark results">
<thead>
<tr><th>Engine</th><th>Overall</th><th>Reading order</th><th>Tables</th><th>Headings</th><th>Complete run</th></tr>
</thead>
<tbody>
<tr class="highlight"><td>pdf-inspector</td><td>0.875</td><td>0.915</td><td>0.814</td><td>0.788</td><td>2.8s</td></tr>
<tr><td>LiteParse</td><td>0.870</td><td>0.908</td><td>0.693</td><td>0.811</td><td>13.9s</td></tr>
<tr><td>OpenDataLoader</td><td>0.843</td><td>0.912</td><td>0.489</td><td>0.760</td><td>9.8s</td></tr>
<tr><td>PyMuPDF4LLM</td><td>0.735</td><td>0.886</td><td>0.401</td><td>0.424</td><td>15.5s</td></tr>
<tr><td>MarkItDown</td><td>0.583</td><td>0.879</td><td>0.000</td><td>0.000</td><td>6.7s</td></tr>
<tr class="highlight"><td>pdf-inspector</td><td>0.875</td><td>0.915</td><td>0.814</td><td>0.788</td><td>0.470s</td></tr>
<tr><td>LiteParse</td><td>0.873</td><td>0.913</td><td>0.693</td><td>0.811</td><td>0.750s</td></tr>
<tr><td>OpenDataLoader</td><td>0.831</td><td>0.902</td><td>0.489</td><td>0.739</td><td>2.569s</td></tr>
<tr><td>PyMuPDF4LLM</td><td>0.735</td><td>0.886</td><td>0.401</td><td>0.424</td><td>17.117s</td></tr>
<tr><td>MarkItDown</td><td>0.589</td><td>0.844</td><td>0.273</td><td>0.000</td><td>16.165s</td></tr>
</tbody>
</table>
</div>
<div class="benchmark-note">Refreshed July 16, 2026. Scores use the benchmarks NID, TEDS, and MHS evaluators.</div>
<div class="benchmark-note">Refreshed July 31, 2026. Scores use the benchmarks NID, TEDS, and MHS evaluators; speed is the median of five alternating or rotating complete corpus runs after an excluded warm-up. <a class="text-link" href="https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results">Versions and raw artifacts</a>.</div>
</div>
<div class="best-fit">
<strong>Best fit</strong>
+65 -1
View File
@@ -1659,7 +1659,15 @@ fn hex_val(b: u8) -> Option<u8> {
/// Standard page: 612x792 points (US Letter) = ~485,000 sq points
/// At 2x resolution that's ~1.9M pixels, so we use 250K pixels as threshold
/// (accounting for varying DPI and page sizes)
fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
/// Returns `(has_images, total_image_area, has_template_image)` for a page.
/// `has_template_image` means a single large (>50% page coverage)
/// background image — the signal `classify_pdf`/`detect_pdf_type` uses to
/// route a page to OCR regardless of any incidental native text drawn over
/// it. Exposed at crate visibility so extraction-side per-page `needs_ocr`
/// computation (`extract_pages_markdown_mem`) can consult the same signal
/// instead of maintaining its own, independent notion of "needs OCR" that
/// can silently disagree with detection — see #227.
pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
// Threshold: image covering roughly half a page at 150+ DPI
// 612 * 792 / 2 * (150/72)^2 ≈ 1M pixels, but we'll be conservative
const TEMPLATE_IMAGE_THRESHOLD: u64 = 500_000; // 500K pixels
@@ -1741,6 +1749,62 @@ fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
(has_images, total_area, has_template_image)
}
/// Computes both `(needs_ocr_for_template_image, has_vector_text)` for a
/// page from a single shared `analyze_page_content` pass — that call
/// decompresses and scans every content stream (page + XObjects) plus
/// image coverage, so `extract_pages_markdown_mem` must not invoke it
/// twice per page (once per signal) the way `detect_from_document` avoids
/// by caching its per-page `PageAnalysis`.
///
/// `needs_ocr_for_template_image` is true when a page's template image
/// should be treated as a scan needing OCR — a single full-page background
/// image with little/no real text — rather than a text page that happens
/// to carry a watermark, letterhead, or figure. Mirrors the two distinct
/// signals classification uses to route a template-image page to OCR:
///
/// 1. `looks_like_scan`: image_count <= 1, few text operators (<50), and
/// low alphanumeric diversity in raw string operands (unless decodable
/// CID/ToUnicode fonts explain that away) — the gate used for
/// `pages_with_template_images` and Mixed-type per-page routing.
/// 2. Insufficient real text volume, using `DetectionConfig::default()`'s
/// `min_text_ops_per_page` (3) — the same threshold Mixed-type per-page
/// routing applies via `text_operator_count < config.min_text_ops_per_page
/// && has_images` (simplified here since a template image implies
/// `has_images`). Deliberately *not* the higher `effective_min_ops`
/// floor (`min_text_ops_per_page.max(10)`) that whole-document
/// `PdfType::ImageBased`/`Scanned` classification uses for
/// `pages_with_text` — that's a cross-page aggregate decision this
/// per-page function has no way to replicate exactly, and the lower
/// per-page threshold is the one a single page's own signals can
/// actually agree with.
///
/// `has_vector_text` is true when a page has vector-outlined text (glyphs
/// drawn as paths rather than shown via text-showing operators) —
/// `detect_from_document`'s Mixed-type per-page routing always sends
/// these pages to OCR, independent of any template-image check, since
/// outlined glyphs can't be extracted as text at all.
///
/// Exposed at crate visibility so `extract_pages_markdown_mem` can apply
/// the same gates classification needs elsewhere instead of treating the
/// raw signals alone as sufficient — see #227/#231.
pub(crate) fn page_ocr_signals(doc: &Document, page_id: ObjectId) -> (bool, bool) {
let analysis = analyze_page_content(doc, page_id);
let needs_ocr_for_template_image = if !analysis.has_template_image {
false
} else {
let alphanum_low = analysis.unique_alphanum_chars < 10
&& !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
let looks_like_scan =
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
let insufficient_text =
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page;
looks_like_scan || insufficient_text
};
(needs_ocr_for_template_image, analysis.has_vector_text)
}
/// Recursively collect image dimensions from XObject resources,
/// including images nested inside Form XObjects.
fn collect_images_from_resources(
+649
View File
@@ -0,0 +1,649 @@
//! 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),
];
static COURIER_BOLD: &[(char, u16)] = COURIER;
static COURIER_OBLIQUE: &[(char, u16)] = COURIER;
static COURIER_BOLDOBLIQUE: &[(char, u16)] = COURIER;
#[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),
];
static HELVETICA_OBLIQUE: &[(char, u16)] = HELVETICA;
static HELVETICA_BOLDOBLIQUE: &[(char, u16)] = HELVETICA_BOLD;
#[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}'),
];
/// Every width table, for exhaustive testing.
#[cfg(test)]
static ALL_TABLES: &[(&str, &[(char, u16)])] = &[
("COURIER", COURIER),
("COURIER_BOLD", COURIER_BOLD),
("COURIER_OBLIQUE", COURIER_OBLIQUE),
("COURIER_BOLDOBLIQUE", COURIER_BOLDOBLIQUE),
("HELVETICA", HELVETICA),
("HELVETICA_BOLD", HELVETICA_BOLD),
("HELVETICA_OBLIQUE", HELVETICA_OBLIQUE),
("HELVETICA_BOLDOBLIQUE", HELVETICA_BOLDOBLIQUE),
("TIMES_ROMAN", TIMES_ROMAN),
("TIMES_BOLD", TIMES_BOLD),
("TIMES_ITALIC", TIMES_ITALIC),
("TIMES_BOLDITALIC", TIMES_BOLDITALIC),
("SYMBOL", SYMBOL),
("ZAPFDINGBATS", ZAPFDINGBATS),
];
#[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() {
// Every table is queried by binary search, so all of them must be
// sorted — not just a sample.
for (name, table) in ALL_TABLES {
assert!(
table.windows(2).all(|w| w[0].0 < w[1].0),
"{name} is not sorted"
);
}
}
}
+45 -7
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};
@@ -41,6 +41,17 @@ fn strip_pdf_comments(data: &[u8]) -> Vec<u8> {
while i < data.len() {
let b = data[i];
match b {
// Inside a string literal, a backslash escapes the next byte —
// `\(`, `\)`, and `\\` must not touch the nesting depth, or a
// later `%` glyph inside a string gets stripped as a comment,
// corrupting the stream.
b'\\' if in_string > 0 => {
result.push(b);
if let Some(&next) = data.get(i + 1) {
result.push(next);
i += 1;
}
}
b'(' if !in_hex_string => {
in_string += 1;
result.push(b);
@@ -166,6 +177,7 @@ pub(crate) fn extract_page_text_items(
// 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 +514,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 +686,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 +794,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 +947,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];
@@ -1849,4 +1865,26 @@ BT 30 700 Tm <41> Tj ET";
"ET should be preserved after comment stripping"
);
}
#[test]
fn test_strip_pdf_comments_escaped_parens() {
// An escaped `\)` must not close the string: the `%` after it is
// still string content, not a comment (subset fonts routinely map
// glyphs to `%` and to escaped parens in the same TJ array).
let input = b"[ (a\\)b) 1 (%) 1 (c) ] TJ\n";
let output = strip_pdf_comments(input);
assert_eq!(output, input.to_vec());
// Same for an escaped `\(` — must not open a phantom string that
// shields a real comment.
let input = b"(x\\(y) Tj % real comment\nET\n";
let output = strip_pdf_comments(input);
assert_eq!(output, b"(x\\(y) Tj \nET\n");
// Escaped backslash before a real close-paren: `\\` ends the escape,
// the `)` does close the string, and the comment is stripped.
let input = b"(x\\\\) Tj % comment\nET\n";
let output = strip_pdf_comments(input);
assert_eq!(output, b"(x\\\\) Tj \nET\n");
}
}
+234 -1
View File
@@ -138,6 +138,93 @@ 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;
// `scale` is the glyph box measured in text-space units. For a
// self-consistent font it lands near 1.0 — the FontMatrix is the
// reciprocal of the glyph-space em by construction — so the Tf
// operand is already the rendered size and must be left alone.
// A modest deviation is normal and must NOT trigger rescaling:
// FontBBox is the glyph bounding box, not the em box, so it is
// routinely somewhat smaller (descender..ascender ≈ 0.7) or larger
// (tall accents > 1.0).
//
// Only a wildly inconsistent font gets renormalized. dvips/PK
// bitmap fonts declare [1 0 0 -1 0 0] with glyphs spanning
// hundreds of units, giving scale ≈ 159 against a nominal size of
// 0.12pt — there the declared size carries no information. The
// band is deliberately wide so that only that class qualifies,
// while any matrix scale (including non-standard ones like 0.005
// with a full-em bbox, scale = 5.0) is judged on the product
// rather than on the matrix alone.
const CONSISTENT_LO: f32 = 0.25;
const CONSISTENT_HI: f32 = 4.0;
if scale.is_finite() && scale > 0.0 && !(CONSISTENT_LO..=CONSISTENT_HI).contains(&scale) {
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 +236,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.
@@ -1470,6 +1617,92 @@ 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}"
);
}
/// Build a one-font Type3 document and return its computed scale, if any.
#[cfg(test)]
fn type3_scale_for(matrix_y: f32, bbox_lo: i64, bbox_hi: i64) -> Option<f32> {
use lopdf::{dictionary, Document, Object};
let doc = Document::with_version("1.4");
let font_dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type3",
"FontMatrix" => vec![
Object::Real(matrix_y), Object::Integer(0), Object::Integer(0),
Object::Real(matrix_y), Object::Integer(0), Object::Integer(0),
],
"FontBBox" => vec![
Object::Integer(0), Object::Integer(bbox_lo),
Object::Integer(600), Object::Integer(bbox_hi),
],
};
let mut fonts = std::collections::BTreeMap::new();
fonts.insert(b"T9".to_vec(), &font_dict);
super::build_type3_scales(&doc, &fonts).get("T9").copied()
}
#[test]
fn type3_scale_skips_self_consistent_fonts() {
// Conventional 1/1000 matrix with a descender..ascender bbox of 700
// units: scale 0.7. The Tf operand is already the rendered size, so
// renormalizing would report every size at 0.7x.
assert_eq!(type3_scale_for(0.001, -200, 500), None);
// Tall-accent bbox slightly over the em (1100 units, scale 1.1).
assert_eq!(type3_scale_for(0.001, -100, 1000), None);
}
#[test]
fn type3_scale_applies_to_inconsistent_fonts_at_any_matrix_scale() {
// Non-standard but valid matrix (0.005) with a full-em bbox:
// scale 5.0, so the declared size is off by 5x and must be fixed.
let s = type3_scale_for(0.005, 0, 1000).expect("0.005 matrix should rescale");
assert!((s - 5.0).abs() < 0.01, "got {s}");
// dvips/PK bitmap pattern: unit matrix, glyphs spanning ~159 units.
let s = type3_scale_for(1.0, -156, 3).expect("PK pattern should rescale");
assert!((s - 159.0).abs() < 0.5, "got {s}");
}
#[test]
fn type3_scale_ignores_degenerate_bbox() {
// [0 0 0 0] is legal and carries no size information.
assert_eq!(type3_scale_for(0.001, 0, 0), None);
}
#[test]
fn texcm_math_symbols_remap() {
assert_eq!(
+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;
+8 -4
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};
@@ -166,6 +167,7 @@ fn extract_form_xobject_text_inner(
// 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())
+311 -7
View File
@@ -491,7 +491,14 @@ pub fn extract_pages_markdown_mem(
// Tables need the original numeric cells; columns use folio-cleaned
// evidence so removed page numbers cannot create false layout metadata.
let complexity = compute_layout_complexity(&all_items, &filtered_items, &all_rects, &all_lines);
let chart_regions = markdown::chart_regions_by_page(&all_items, &all_rects, &all_lines);
let complexity = compute_layout_complexity_with_chart_regions(
&all_items,
&filtered_items,
&all_rects,
&all_lines,
&chart_regions,
);
// Compute font stats from full document (cross-page consistency).
let font_stats = markdown::analysis::calculate_font_stats_from_items(&filtered_items);
@@ -509,6 +516,7 @@ pub fn extract_pages_markdown_mem(
let mut results = Vec::with_capacity(pages_slice.len());
let mut pages_needing_ocr = Vec::new();
let mut ocr_reasons_by_page = BTreeMap::new();
let lopdf_pages = doc.get_pages();
for &page_0idx in pages_slice {
// Out-of-range pages → empty + needs_ocr
@@ -542,6 +550,25 @@ pub fn extract_pages_markdown_mem(
let has_gid = gid_pages.contains(&page_1idx);
let has_text_quality_issue = text_quality.pages_needing_ocr.contains(&page_1idx);
// A page can extract cleanly (no decoding issues, non-empty text)
// while still being fundamentally a scan: a full-page raster with
// a little genuine native text drawn over it (a header, a stamp, a
// cover-sheet annotation). Text-quality signals alone can't see
// that — consult the same "large background image" signal
// classify_pdf/detect_pdf_type already uses, so the two APIs can't
// silently disagree on whether a page needs OCR. See #227.
// Also covers vector-outlined text (glyphs drawn as paths, not
// shown via a text-showing operator): a hybrid page with real
// embedded-font body text elsewhere would otherwise still extract
// non-empty, non-garbled markdown and miss OCR routing entirely.
// detect_from_document's Mixed-type per-page routing always sends
// these pages to OCR; mirror that here too. Both signals share one
// analyze_page_content pass — see page_ocr_signals's doc comment.
let (has_template_image, has_vector_text) = lopdf_pages
.get(&page_1idx)
.map(|&page_id| detector::page_ocr_signals(&doc, page_id))
.unwrap_or((false, false));
// Build markdown with document-wide font stats
let options = MarkdownOptions {
base_font_size: Some(font_stats.most_common_size),
@@ -565,6 +592,7 @@ pub fn extract_pages_markdown_mem(
page_count,
prefiltered_page_number_pages: Some(&removed_page_number_pages),
prefiltered_page_number_mask: Some(&page_number_removal_mask),
precomputed_chart_regions: Some(&chart_regions),
},
)
};
@@ -578,10 +606,20 @@ pub fn extract_pages_markdown_mem(
OCR_REASON_SUSPECTED_GARBLED_TEXT,
);
}
if has_template_image {
add_ocr_reason(&mut ocr_reasons_by_page, page_1idx, OCR_REASON_SCANNED);
}
if has_vector_text {
add_ocr_reason(&mut ocr_reasons_by_page, page_1idx, OCR_REASON_VECTOR_TEXT);
}
let ocr_reason = page_ocr_reason(&ocr_reasons_by_page, page_1idx);
let needs_ocr =
ocr_reason.is_some() || md.trim().is_empty() || has_gid || is_garbage_text(&md);
let needs_ocr = ocr_reason.is_some()
|| md.trim().is_empty()
|| has_gid
|| is_garbage_text(&md)
|| has_template_image
|| has_vector_text;
if needs_ocr {
pages_needing_ocr.push(page_1idx);
@@ -3515,6 +3553,7 @@ fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
let mut candidates = Vec::new();
add_repair_candidate(&mut candidates, append_missing_eof_marker(buf), buf);
add_repair_candidate(&mut candidates, recover_startxref_pointer(buf), buf);
let stripped = strip_leading_pdf_container_bytes(buf);
if let Some(stripped_buf) = stripped.as_deref() {
@@ -3524,11 +3563,112 @@ fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
append_missing_eof_marker(stripped_buf),
buf,
);
add_repair_candidate(
&mut candidates,
recover_startxref_pointer(stripped_buf),
buf,
);
}
candidates
}
/// Some PDF writers emit a `startxref` pointer that doesn't actually point
/// at the cross-reference table — a single corrupted byte in the offset is
/// enough. lopdf trusts that pointer outright and fails to load rather than
/// searching for the real table, unlike pypdf/pdfium which both recover by
/// locating it directly. This finds the real (classic, non-stream) `xref`
/// table by scanning for the keyword — validating that a plausible
/// subsection header follows, not just any standalone "xref" token, since
/// this crate processes untrusted input and a coincidental match inside
/// unrelated stream/string content must not get "repaired" against a bogus
/// offset (lopdf would then load successfully against garbage instead of
/// returning a clean error) — and appends a corrected trailing
/// `startxref`/`%%EOF` block. lopdf's own `get_xref_start` always uses the
/// *last* `%%EOF` in the final 512 bytes of the buffer, so ours
/// transparently supersedes the broken one without needing to touch
/// anything already in the file.
///
/// Doesn't cover cross-reference *streams* (`N 0 obj << /Type /XRef ...`,
/// used by some PDF 1.5+ writers instead of a classic table) — recovering
/// those needs the containing object's number, not just a byte offset.
fn recover_startxref_pointer(buf: &[u8]) -> Option<Vec<u8>> {
let xref_pos = find_last_valid_xref_table_start(buf)?;
let mut repaired = Vec::with_capacity(buf.len() + 32);
repaired.extend_from_slice(buf);
if !repaired.ends_with(b"\n") {
repaired.push(b'\n');
}
repaired.extend_from_slice(format!("startxref\n{xref_pos}\n%%EOF\n").as_bytes());
Some(repaired)
}
/// Finds the last standalone `xref` token in `buf` that is immediately
/// followed by a plausible classic cross-reference subsection header
/// (`<start-id> <count>`, e.g. "0 6") — the shape every real classic xref
/// table starts with. A single reverse byte scan: O(n) even on a
/// pathological buffer with many non-matching or non-standalone "xref"
/// occurrences, unlike repeatedly re-searching a shrinking prefix.
fn find_last_valid_xref_table_start(buf: &[u8]) -> Option<usize> {
const KEYWORD: &[u8] = b"xref";
if buf.len() < KEYWORD.len() {
return None;
}
let mut pos = buf.len() - KEYWORD.len();
loop {
if &buf[pos..pos + KEYWORD.len()] == KEYWORD {
let before_ok = pos == 0 || buf[pos - 1].is_ascii_whitespace();
let after_ok = buf
.get(pos + KEYWORD.len())
.is_none_or(|c| c.is_ascii_whitespace());
if before_ok && after_ok && looks_like_xref_subsection_header(buf, pos + KEYWORD.len())
{
return Some(pos);
}
}
if pos == 0 {
return None;
}
pos -= 1;
}
}
/// Checks that `buf[pos..]` starts (after whitespace) with two
/// whitespace-separated runs of ASCII digits — `<start-id> <count>`, the
/// first subsection header of a classic PDF cross-reference table.
fn looks_like_xref_subsection_header(buf: &[u8], pos: usize) -> bool {
fn skip_ws(buf: &[u8], mut pos: usize) -> usize {
while buf.get(pos).is_some_and(u8::is_ascii_whitespace) {
pos += 1;
}
pos
}
fn skip_digits(buf: &[u8], mut pos: usize) -> usize {
while buf.get(pos).is_some_and(u8::is_ascii_digit) {
pos += 1;
}
pos
}
let pos = skip_ws(buf, pos);
let after_first_digits = skip_digits(buf, pos);
if after_first_digits == pos {
return false; // no start-id
}
let sep = skip_ws(buf, after_first_digits);
if sep == after_first_digits {
return false; // start-id and count must be whitespace-separated
}
let after_count = skip_digits(buf, sep);
if after_count == sep {
return false; // no count
}
// The count run must end at whitespace/buffer-end, not run into trailing
// garbage (e.g. a coincidental "xref\n0 6garbage" in stream content).
buf.get(after_count).is_none_or(u8::is_ascii_whitespace)
}
fn add_repair_candidate(
candidates: &mut Vec<Vec<u8>>,
candidate: Option<Vec<u8>>,
@@ -3816,7 +3956,14 @@ fn process_document(
let text_quality = analyze_text_quality(&items);
merge_ocr_reasons(&mut ocr_reasons_by_page, text_quality.reasons_by_page);
let layout = compute_layout_complexity(&items, &layout_items, &rects, &lines);
let chart_regions = markdown::chart_regions_by_page(&items, &rects, &lines);
let layout = compute_layout_complexity_with_chart_regions(
&items,
&layout_items,
&rects,
&lines,
&chart_regions,
);
let md = if options.mode == ProcessMode::Analyze {
None
@@ -3833,6 +3980,7 @@ fn process_document(
page_count,
prefiltered_page_number_pages: Some(&removed_pages),
prefiltered_page_number_mask: Some(removal_mask.as_slice()),
precomputed_chart_regions: Some(&chart_regions),
},
))
};
@@ -5633,11 +5781,29 @@ fn select_items_with_document_folio_context(
}
/// Analyse extracted items and rects for layout complexity.
#[cfg(test)]
fn compute_layout_complexity(
items: &[types::TextItem],
column_items: &[types::TextItem],
rects: &[types::PdfRect],
lines: &[types::PdfLine],
) -> LayoutComplexity {
let page_chart_regions = markdown::chart_regions_by_page(items, rects, lines);
compute_layout_complexity_with_chart_regions(
items,
column_items,
rects,
lines,
&page_chart_regions,
)
}
fn compute_layout_complexity_with_chart_regions(
items: &[types::TextItem],
column_items: &[types::TextItem],
rects: &[types::PdfRect],
lines: &[types::PdfLine],
page_chart_regions: &markdown::PageChartRegions,
) -> LayoutComplexity {
use markdown::analysis::calculate_font_stats_from_items;
@@ -5659,6 +5825,10 @@ fn compute_layout_complexity(
let owned_items: Vec<types::TextItem> = page_items.iter().map(|i| (*i).clone()).collect();
let page_content_width = tables::content_width(&owned_items);
let bands = markdown::split_side_by_side(&owned_items);
let chart_regions = page_chart_regions
.get(&page)
.map(Vec::as_slice)
.unwrap_or_default();
let band_ranges: Vec<(f32, f32)> = if bands.is_empty() {
// Single region — use sentinel range that includes everything
@@ -5673,7 +5843,8 @@ fn compute_layout_complexity(
let band_items: Vec<types::TextItem> = owned_items
.iter()
.filter(|item| {
x_lo == f32::MIN || (item.x >= x_lo - margin && item.x < x_hi + margin)
(x_lo == f32::MIN || (item.x >= x_lo - margin && item.x < x_hi + margin))
&& !markdown::item_is_in_chart_region(item, chart_regions)
})
.cloned()
.collect();
@@ -5725,8 +5896,20 @@ fn compute_layout_complexity(
}
let mut pages_with_columns: Vec<u32> = Vec::new();
for page in seen_pages {
let cols = extractor::detect_columns(column_items, page, pages_with_tables.contains(&page));
for &page in &seen_pages {
let chart_regions = page_chart_regions
.get(&page)
.map(Vec::as_slice)
.unwrap_or_default();
let page_column_items: Vec<types::TextItem> = column_items
.iter()
.filter(|item| {
item.page == page && !markdown::item_is_in_chart_region(item, chart_regions)
})
.cloned()
.collect();
let cols =
extractor::detect_columns(&page_column_items, page, pages_with_tables.contains(&page));
if cols.len() >= 2 {
pages_with_columns.push(page);
}
@@ -5955,6 +6138,66 @@ mod tests {
assert!(filtered.pages_with_columns.is_empty());
}
#[test]
fn dense_chart_panel_is_not_reported_as_a_table() {
let mut items: Vec<TextItem> = (0..8)
.flat_map(|row| {
(0..6).map(move |column| {
test_item(
&format!("{}", row * 10 + column),
105.0 + column as f32 * 35.0,
525.0 - row as f32 * 15.0,
24.0,
10.0,
)
})
})
.collect();
for row in 0..6 {
items.push(test_item(
"Left column prose continues here",
80.0,
320.0 - row as f32 * 15.0,
160.0,
10.0,
));
items.push(test_item(
"Right column prose continues here",
300.0,
320.0 - row as f32 * 15.0,
160.0,
10.0,
));
}
let mut lines: Vec<PdfLine> = (0..30)
.map(|column| PdfLine {
x1: 100.0 + column as f32 * 8.0,
y1: 400.0,
x2: 100.0 + column as f32 * 8.0,
y2: 550.0,
page: 1,
})
.collect();
lines.extend((0..6).map(|row| PdfLine {
x1: 100.0,
y1: 400.0 + row as f32 * 30.0,
x2: 332.0,
y2: 400.0 + row as f32 * 30.0,
page: 1,
}));
let rects = vec![PdfRect {
x: 80.0,
y: 350.0,
width: 280.0,
height: 240.0,
page: 1,
}];
let complexity = compute_layout_complexity(&items, &items, &rects, &lines);
assert!(complexity.pages_with_tables.is_empty());
}
#[test]
fn page_selection_keeps_document_wide_folio_layout_decisions() {
let mut items = Vec::new();
@@ -6958,4 +7201,65 @@ mod tests {
// Pre-filled cell was not touched.
assert_eq!(cells[1].text, "Pre-filled");
}
// -- recover_startxref_pointer / find_last_valid_xref_table_start ------
//
// Direct unit tests on the byte-level scan, addressing review feedback
// on #230: a coincidental standalone "xref" token that isn't actually
// followed by a subsection header (start-id + count) must not be
// treated as a real table — accepting it would let lopdf "succeed"
// against a bogus offset and silently return garbled/empty content
// instead of a clean error.
#[test]
fn find_xref_rejects_standalone_token_without_subsection_header() {
// "xref" appears as a real standalone word, but nothing that looks
// like "<start-id> <count>" follows it.
let buf = b"Please refer to the xref appendix for details.";
assert_eq!(find_last_valid_xref_table_start(buf), None);
}
#[test]
fn find_xref_accepts_real_classic_table_header() {
let buf = b"garbage\nxref\n0 6\n0000000000 65535 f \n%%EOF";
let pos = find_last_valid_xref_table_start(buf).expect("should find the real table");
assert_eq!(&buf[pos..pos + 4], b"xref");
assert_eq!(&buf[pos..], b"xref\n0 6\n0000000000 65535 f \n%%EOF");
}
#[test]
fn find_xref_skips_coincidental_match_and_finds_real_table_before_it() {
// A coincidental "xref" (no subsection header) appears *after* the
// real table in the buffer — the scan must not stop at the first
// (rightmost) standalone token it finds; it must keep looking
// backward until one actually validates.
let buf = b"xref\n0 3\n0000000000 65535 f \ntrailer\nsee the xref\n";
let pos = find_last_valid_xref_table_start(buf).expect("should find the real table");
assert_eq!(pos, 0);
}
#[test]
fn find_xref_rejects_substring_of_startxref() {
// "xref" is a substring of "startxref" but isn't a standalone
// token there (not preceded by whitespace) — must not match, even
// though a number immediately follows it.
let buf = b"startxref\n1234\n%%EOF";
assert_eq!(find_last_valid_xref_table_start(buf), None);
}
#[test]
fn find_xref_rejects_count_run_with_trailing_garbage() {
// "xref\n0 6garbage" has the right shape (digits, whitespace,
// digits) but the count run doesn't end at whitespace/EOF — it
// runs straight into non-digit garbage, so this must not be
// accepted as a real subsection header.
let buf = b"xref\n0 6garbage\n%%EOF";
assert_eq!(find_last_valid_xref_table_start(buf), None);
}
#[test]
fn recover_startxref_pointer_returns_none_without_a_valid_table() {
let buf = b"Please refer to the xref appendix for details.";
assert!(recover_startxref_pointer(buf).is_none());
}
}
+85 -12
View File
@@ -69,7 +69,7 @@ fn is_chart_adjacent_label(item: &TextItem, region: (f32, f32, f32, f32)) -> boo
|| (mostly_inside_chart_width && close_to_chart_edge && category_sized))
}
fn item_is_in_chart_region(item: &TextItem, regions: &[(f32, f32, f32, f32)]) -> bool {
pub(crate) fn item_is_in_chart_region(item: &TextItem, regions: &[(f32, f32, f32, f32)]) -> bool {
regions.iter().any(|&(x0, y0, x1, y1)| {
let cx = item.x + item.width / 2.0;
let within_padded_x = cx >= x0 - CHART_REGION_PAD && cx <= x1 + CHART_REGION_PAD;
@@ -92,6 +92,72 @@ fn items_outside_chart_regions(
.collect()
}
pub(crate) fn merge_chart_regions(
regions: impl IntoIterator<Item = (f32, f32, f32, f32)>,
) -> Vec<(f32, f32, f32, f32)> {
const MERGE_TOLERANCE: f32 = 3.0;
let mut merged: Vec<(f32, f32, f32, f32)> = Vec::new();
for (x0, y0, x1, y1) in regions {
let mut current = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
let mut index = 0;
while index < merged.len() {
let candidate = merged[index];
let overlaps = current.2 + MERGE_TOLERANCE >= candidate.0
&& candidate.2 + MERGE_TOLERANCE >= current.0
&& current.3 + MERGE_TOLERANCE >= candidate.1
&& candidate.3 + MERGE_TOLERANCE >= current.1;
if overlaps {
current = (
current.0.min(candidate.0),
current.1.min(candidate.1),
current.2.max(candidate.2),
current.3.max(candidate.3),
);
merged.swap_remove(index);
} else {
index += 1;
}
}
merged.push(current);
}
merged
}
pub(crate) type PageChartRegions = HashMap<u32, Vec<(f32, f32, f32, f32)>>;
/// Compute the chart masks used by both layout analysis and Markdown output.
///
/// Keeping the rect-backed and dense-line heuristics behind one entry point
/// ensures metadata and extraction cannot drift when either detector changes.
pub(crate) fn chart_regions_by_page(
items: &[TextItem],
rects: &[PdfRect],
lines: &[PdfLine],
) -> PageChartRegions {
let mut page_items: HashMap<u32, Vec<TextItem>> = HashMap::new();
for item in items.iter().filter(|item| {
matches!(
&item.item_type,
crate::types::ItemType::Text | crate::types::ItemType::FormField
)
}) {
page_items.entry(item.page).or_default().push(item.clone());
}
page_items
.into_iter()
.filter_map(|(page, items)| {
let rect_regions = crate::tables::detect_chart_regions(&items, rects, page);
let line_regions = crate::tables::detect_dense_line_chart_regions(lines, rects, page)
.into_iter()
.filter(|&region| chart_region_separates_prose_columns(&items, region));
let regions = merge_chart_regions(rect_regions.into_iter().chain(line_regions));
(!regions.is_empty()).then_some((page, regions))
})
.collect()
}
/// Detect side-by-side table layout by finding a significant X-position gap.
///
/// Returns X-band boundaries `[(x_min, split_x), (split_x, x_max)]` when a
@@ -329,6 +395,15 @@ fn chart_spans_prose_split(region: (f32, f32, f32, f32), split_x: f32) -> bool {
split_x - left >= MIN_CHART_WIDTH_PER_SIDE && right - split_x >= MIN_CHART_WIDTH_PER_SIDE
}
pub(crate) fn chart_region_separates_prose_columns(
items: &[TextItem],
region: (f32, f32, f32, f32),
) -> bool {
let outside = items_outside_chart_regions(items, &[region]);
chart_page_prose_column_split(&outside)
.is_some_and(|split_x| chart_spans_prose_split(region, split_x))
}
/// True when adjacent physical rows form an unterminated, lowercase prose
/// continuation in the same projected column.
fn is_cross_row_prose_continuation(previous: &str, current: &str) -> bool {
@@ -1004,6 +1079,7 @@ pub fn to_markdown_from_items_with_rects_and_page_count(
page_count: document_page_count,
prefiltered_page_number_pages: None,
prefiltered_page_number_mask: None,
precomputed_chart_regions: None,
},
)
}
@@ -1021,6 +1097,9 @@ pub(crate) struct MarkdownDocumentContext<'a> {
/// Table detection consumes the original items; the mask is applied only
/// after table claims have been established.
pub(crate) prefiltered_page_number_mask: Option<&'a [bool]>,
/// Optional chart masks shared with layout analysis so the geometry is
/// detected once and interpreted identically by both pipelines.
pub(crate) precomputed_chart_regions: Option<&'a PageChartRegions>,
}
/// Convert positioned text items to markdown, using rectangles and line segments for table detection.
@@ -1047,6 +1126,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
page_count: document_page_count,
prefiltered_page_number_pages,
prefiltered_page_number_mask,
precomputed_chart_regions,
} = context;
if items.is_empty() {
@@ -1119,17 +1199,9 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
// Chart regions per page: their text must not steer column detection
// during line grouping (it fills the gutter and fuses two-column lines).
let mut page_chart_map: HashMap<u32, Vec<(f32, f32, f32, f32)>> = HashMap::new();
for &page in page_groups.keys() {
let page_items_ref: Vec<TextItem> = page_groups[&page]
.iter()
.map(|(_, item)| (*item).clone())
.collect();
let regions = crate::tables::detect_chart_regions(&page_items_ref, rects, page);
if !regions.is_empty() {
page_chart_map.insert(page, regions);
}
}
let page_chart_map = precomputed_chart_regions
.cloned()
.unwrap_or_else(|| chart_regions_by_page(&text_items, rects, pdf_lines));
let mut pages: Vec<u32> = page_groups.keys().copied().collect();
pages.sort();
@@ -2051,6 +2123,7 @@ mod tests {
page_count: 1,
prefiltered_page_number_pages: Some(&removed_pages),
prefiltered_page_number_mask: Some(&removal_mask),
precomputed_chart_regions: None,
},
);
+9
View File
@@ -464,6 +464,7 @@ mod tests {
assert!(!is_page_number_line("Hello World"));
assert!(!is_page_number_line("Chapter 1"));
assert!(!is_page_number_line("Total: 500"));
assert!(!is_page_number_line("PAGE0-PARA2-END-MARKER-0"));
}
#[test]
@@ -507,6 +508,14 @@ mod tests {
assert!(result.contains("End"));
}
#[test]
fn test_remove_page_numbers_preserves_page_prefixed_content() {
let input = "PAGE0-PARA2-START substantive report text PAGE0-PARA2-END-MARKER-0";
let result = remove_page_numbers(input);
assert_eq!(result, input);
}
#[test]
fn test_remove_page_numbers_multiple_patterns() {
let input = "\n1\n\nContent\n\n2\n\n---\nMore\n\n3\n";
+450 -2
View File
@@ -4,10 +4,10 @@
//! gridlines. Many IRS forms and government PDFs use these instead of
//! `re` (rectangle) operators.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use crate::tables::Table;
use crate::types::{PdfLine, TextItem};
use crate::types::{PdfLine, PdfRect, TextItem};
use super::detect_rects::{assign_items_to_grid, snap_edges};
@@ -15,11 +15,33 @@ const RULE_Y_TOLERANCE: f32 = 2.0;
const RULE_JOIN_GAP: f32 = 6.0;
const RULE_SPAN_TOLERANCE: f32 = 8.0;
const TEXT_ROW_TOLERANCE: f32 = 2.5;
const DENSE_CHART_MIN_VERTICAL_EDGES: usize = 27;
const DENSE_CHART_LABEL_PAD: f32 = 20.0;
const DENSE_CHART_MAX_SHARED_PANEL_GRIDS: usize = 4;
type HorizontalRule = (f32, f32, f32); // (y, x_min, x_max)
type VerticalRule = (f32, f32, f32); // (x, y_min, y_max)
type AnchoredRow<'a> = (f32, Vec<(usize, &'a TextItem)>);
fn dense_chart_grids_are_co_located(
left: (f32, f32, f32, f32),
right: (f32, f32, f32, f32),
) -> bool {
let left_width = left.2 - left.0;
let right_width = right.2 - right.0;
let left_height = left.3 - left.1;
let right_height = right.3 - right.1;
let horizontal_overlap = (left.2.min(right.2) - left.0.max(right.0)).max(0.0);
let vertical_overlap = (left.3.min(right.3) - left.1.max(right.1)).max(0.0);
let horizontal_gap = (left.0.max(right.0) - left.2.min(right.2)).max(0.0);
let vertical_gap = (left.1.max(right.1) - left.3.min(right.3)).max(0.0);
(vertical_overlap >= left_height.min(right_height) * 0.5
&& horizontal_gap <= left_width.min(right_width) * 0.5)
|| (horizontal_overlap >= left_width.min(right_width) * 0.5
&& vertical_gap <= left_height.min(right_height) * 0.5)
}
#[derive(Debug)]
struct TextAnchorTable {
table: Table,
@@ -1187,6 +1209,279 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
detect_tables_from_lines_inner(items, lines, page, true, true)
}
/// Bounding boxes of chart panels backed by a very dense vector grid.
///
/// Tables support at most 25 columns, so a panel with at least 27 distinct,
/// long vertical coordinates plus repeated horizontal rules is treated as
/// chart geometry. When the grid is enclosed by a painted panel rectangle,
/// the region expands to that rectangle so axis labels, legends, and source
/// notes remain part of the figure instead of forming a heuristic table.
pub(crate) fn detect_dense_line_chart_regions(
lines: &[PdfLine],
rects: &[PdfRect],
page: u32,
) -> Vec<(f32, f32, f32, f32)> {
const ANGLE_TOLERANCE: f32 = 0.035;
const MIN_GRID_LINE_LENGTH: f32 = 40.0;
const EXTENT_TOLERANCE: f32 = 6.0;
let mut verticals = Vec::new();
let mut horizontals = Vec::new();
for line in lines.iter().filter(|line| line.page == page) {
let dx = (line.x2 - line.x1).abs();
let dy = (line.y2 - line.y1).abs();
let length = dx.hypot(dy);
if length < MIN_GRID_LINE_LENGTH {
continue;
}
if dy > 0.01 && dx / dy <= ANGLE_TOLERANCE {
verticals.push((
(line.x1 + line.x2) / 2.0,
line.y1.min(line.y2),
line.y1.max(line.y2),
));
} else if dx > 0.01 && dy / dx <= ANGLE_TOLERANCE {
horizontals.push((
(line.y1 + line.y2) / 2.0,
line.x1.min(line.x2),
line.x1.max(line.x2),
));
}
}
if verticals.len() < DENSE_CHART_MIN_VERTICAL_EDGES || horizontals.len() < 3 {
return Vec::new();
}
// Group similar vertical extents once. Neighboring buckets are consulted
// below so coordinates that straddle a bucket boundary still form one
// family, while each line participates in only a constant number of
// candidates instead of being re-scanned for every vertical anchor.
let extent_key = |value: f32| (value / EXTENT_TOLERANCE).round() as i32;
let mut extent_buckets: HashMap<(i32, i32), Vec<VerticalRule>> = HashMap::new();
for vertical in verticals {
extent_buckets
.entry((extent_key(vertical.1), extent_key(vertical.2)))
.or_default()
.push(vertical);
}
let mut grid_regions = Vec::new();
let extent_keys: Vec<(i32, i32)> = extent_buckets.keys().copied().collect();
for key in extent_keys {
let anchor_family = &extent_buckets[&key];
let anchor_bottom = anchor_family.iter().map(|vertical| vertical.1).sum::<f32>()
/ anchor_family.len() as f32;
let anchor_top = anchor_family.iter().map(|vertical| vertical.2).sum::<f32>()
/ anchor_family.len() as f32;
let mut family = Vec::new();
for bottom_offset in -1..=1 {
for top_offset in -1..=1 {
if let Some(bucket) =
extent_buckets.get(&(key.0 + bottom_offset, key.1 + top_offset))
{
family.extend(bucket.iter().copied().filter(|vertical| {
(vertical.1 - anchor_bottom).abs() <= EXTENT_TOLERANCE
&& (vertical.2 - anchor_top).abs() <= EXTENT_TOLERANCE
}));
}
}
}
let xs = snap_edges(&family.iter().map(|&(x, _, _)| x).collect::<Vec<_>>(), 3.0);
if xs.len() < DENSE_CHART_MIN_VERTICAL_EDGES {
continue;
}
let grid_bottom =
family.iter().map(|vertical| vertical.1).sum::<f32>() / family.len() as f32;
let grid_top = family.iter().map(|vertical| vertical.2).sum::<f32>() / family.len() as f32;
if grid_top - grid_bottom < 60.0 {
continue;
}
// A horizontal rule must support the same contiguous dense run of
// vertical coordinates. Splitting at sparse X gaps prevents a shared
// rule from joining a chart to a neighboring ruled table. Keying by
// the covered X-index range also lets multiple chart panels sharing
// the same Y extents produce independent regions.
let mut supported_spans: HashMap<(usize, usize), Vec<f32>> = HashMap::new();
for &(y, line_left, line_right) in &horizontals {
if y < grid_bottom - EXTENT_TOLERANCE || y > grid_top + EXTENT_TOLERANCE {
continue;
}
let start = xs.partition_point(|&x| x < line_left - EXTENT_TOLERANCE);
let end = xs.partition_point(|&x| x <= line_right + EXTENT_TOLERANCE);
if end - start < DENSE_CHART_MIN_VERTICAL_EDGES {
continue;
}
let mut gaps: Vec<f32> = xs[start..end]
.windows(2)
.map(|pair| pair[1] - pair[0])
.collect();
gaps.sort_by(f32::total_cmp);
let dense_gap = gaps[gaps.len() / 4];
let run_break = (dense_gap * 3.0).max(12.0);
let locally_dense_gap_limit = (dense_gap * 1.5).max(6.0);
let locally_dense_gaps = gaps
.iter()
.filter(|&&gap| gap <= locally_dense_gap_limit)
.count();
let mut run_start = start;
let mut retained_dense_run = false;
for index in start..end - 1 {
if xs[index + 1] - xs[index] <= run_break {
continue;
}
let run_end = index + 1;
if run_end - run_start >= DENSE_CHART_MIN_VERTICAL_EDGES
&& xs[run_end - 1] - xs[run_start] >= 120.0
{
supported_spans
.entry((run_start, run_end))
.or_default()
.push(y);
retained_dense_run = true;
}
run_start = run_end;
}
if end - run_start >= DENSE_CHART_MIN_VERTICAL_EDGES
&& xs[end - 1] - xs[run_start] >= 120.0
{
supported_spans.entry((run_start, end)).or_default().push(y);
retained_dense_run = true;
}
// One or two wider category gaps may split an otherwise dense
// chart into sub-threshold runs. Keep the full family only when
// its total width remains close to the expected dense spacing;
// a neighboring sparse table makes this ratio much larger.
let span_width = xs[end - 1] - xs[start];
let expected_dense_width = dense_gap * (end - start - 1) as f32;
if !retained_dense_run
&& span_width >= 120.0
&& span_width <= expected_dense_width * 1.35
&& gaps.len().saturating_sub(locally_dense_gaps) <= 2
{
supported_spans.entry((start, end)).or_default().push(y);
}
}
for ((start, end), ys) in supported_spans {
if snap_edges(&ys, 3.0).len() >= 3 {
grid_regions.push((xs[start], grid_bottom, xs[end - 1], grid_top));
}
}
}
// Prefer the smallest qualifying region when a broad rule happens to
// cover a denser nested panel, and retain every non-overlapping panel.
grid_regions.sort_by(|left, right| {
let left_area = (left.2 - left.0) * (left.3 - left.1);
let right_area = (right.2 - right.0) * (right.3 - right.1);
left_area.total_cmp(&right_area)
});
let mut selected_regions: Vec<(f32, f32, f32, f32)> = Vec::new();
for region in grid_regions {
let area = (region.2 - region.0) * (region.3 - region.1);
let duplicates_existing = selected_regions.iter().any(|existing| {
let overlap_width = (region.2.min(existing.2) - region.0.max(existing.0)).max(0.0);
let overlap_height = (region.3.min(existing.3) - region.1.max(existing.1)).max(0.0);
let overlap_area = overlap_width * overlap_height;
let existing_area = (existing.2 - existing.0) * (existing.3 - existing.1);
overlap_area >= area.min(existing_area) * 0.8
});
if !duplicates_existing {
selected_regions.push(region);
}
}
let all_grid_regions = selected_regions.clone();
let mut regions: Vec<_> = selected_regions
.into_iter()
.map(|grid_region| {
let (grid_left, grid_bottom, grid_right, grid_top) = grid_region;
let enclosing_panel = rects
.iter()
.filter(|rect| rect.page == page)
.filter_map(|rect| {
let (left, width) = if rect.width < 0.0 {
(rect.x + rect.width, -rect.width)
} else {
(rect.x, rect.width)
};
let (bottom, height) = if rect.height < 0.0 {
(rect.y + rect.height, -rect.height)
} else {
(rect.y, rect.height)
};
let right = left + width;
let top = bottom + height;
let enclosed_grids: Vec<_> = all_grid_regions
.iter()
.filter(|&&(other_left, other_bottom, other_right, other_top)| {
left <= other_left + EXTENT_TOLERANCE
&& right >= other_right - EXTENT_TOLERANCE
&& bottom <= other_bottom + EXTENT_TOLERANCE
&& top >= other_top - EXTENT_TOLERANCE
})
.copied()
.collect();
if enclosed_grids.len() > DENSE_CHART_MAX_SHARED_PANEL_GRIDS
|| enclosed_grids.iter().any(|&other| {
other != grid_region
&& !dense_chart_grids_are_co_located(grid_region, other)
})
{
return None;
}
let enclosed_grid_bounds =
enclosed_grids.into_iter().reduce(|bounds, other| {
(
bounds.0.min(other.0),
bounds.1.min(other.1),
bounds.2.max(other.2),
bounds.3.max(other.3),
)
})?;
let enclosed_width = enclosed_grid_bounds.2 - enclosed_grid_bounds.0;
let enclosed_height = enclosed_grid_bounds.3 - enclosed_grid_bounds.1;
(left <= grid_left + EXTENT_TOLERANCE
&& right >= grid_right - EXTENT_TOLERANCE
&& bottom <= grid_bottom + EXTENT_TOLERANCE
&& top >= grid_top - EXTENT_TOLERANCE
&& width <= enclosed_width * 2.0
&& height <= enclosed_height * 4.0
&& !(left < 5.0 && bottom < 5.0))
.then_some(((left, bottom, right, top), width * height))
})
.min_by(|left, right| left.1.total_cmp(&right.1))
.map(|(region, _)| region);
enclosing_panel.unwrap_or((
grid_left - DENSE_CHART_LABEL_PAD,
grid_bottom - DENSE_CHART_LABEL_PAD,
grid_right + DENSE_CHART_LABEL_PAD,
grid_top + DENSE_CHART_LABEL_PAD,
))
})
.collect();
regions.sort_by(|left, right| {
left.0
.total_cmp(&right.0)
.then_with(|| left.1.total_cmp(&right.1))
});
regions.dedup_by(|left, right| {
(left.0 - right.0).abs() <= EXTENT_TOLERANCE
&& (left.1 - right.1).abs() <= EXTENT_TOLERANCE
&& (left.2 - right.2).abs() <= EXTENT_TOLERANCE
&& (left.3 - right.3).abs() <= EXTENT_TOLERANCE
});
regions
}
/// Detect only tables whose cell grid is backed by explicit vector geometry.
///
/// Region-level TSR callers need physical cell boundaries for crop bboxes, so
@@ -1621,6 +1916,159 @@ mod tests {
}
}
#[test]
fn dense_vector_grid_expands_to_enclosing_chart_panel() {
let mut lines: Vec<PdfLine> = (0..30)
.map(|column| make_vline(100.0 + column as f32 * 8.0, 400.0, 550.0, 1))
.collect();
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 100.0, 332.0, 1)));
let rects = vec![PdfRect {
x: 80.0,
y: 350.0,
width: 280.0,
height: 240.0,
page: 1,
}];
assert_eq!(
detect_dense_line_chart_regions(&lines, &rects, 1),
vec![(80.0, 350.0, 360.0, 590.0)]
);
}
#[test]
fn frameless_dense_vector_grid_includes_label_padding() {
let mut lines: Vec<PdfLine> = (0..30)
.map(|column| make_vline(100.0 + column as f32 * 8.0, 400.0, 550.0, 1))
.collect();
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 100.0, 332.0, 1)));
assert_eq!(
detect_dense_line_chart_regions(&lines, &[], 1),
vec![(80.0, 380.0, 352.0, 570.0)]
);
}
#[test]
fn multiple_dense_vector_panels_are_retained() {
let mut lines = Vec::new();
for panel_left in [60.0, 380.0] {
lines.extend(
(0..30).map(|column| make_vline(panel_left + column as f32 * 8.0, 400.0, 550.0, 1)),
);
lines.extend((0..6).map(|row| {
make_hline(400.0 + row as f32 * 30.0, panel_left, panel_left + 232.0, 1)
}));
}
assert_eq!(
detect_dense_line_chart_regions(&lines, &[], 1),
vec![(40.0, 380.0, 312.0, 570.0), (360.0, 380.0, 632.0, 570.0),]
);
}
#[test]
fn multiple_dense_vector_panels_use_shared_enclosing_panel() {
let mut lines = Vec::new();
for panel_left in [60.0, 380.0] {
lines.extend(
(0..30).map(|column| make_vline(panel_left + column as f32 * 8.0, 400.0, 550.0, 1)),
);
lines.extend((0..6).map(|row| {
make_hline(400.0 + row as f32 * 30.0, panel_left, panel_left + 232.0, 1)
}));
}
let rects = vec![PdfRect {
x: 40.0,
y: 350.0,
width: 592.0,
height: 240.0,
page: 1,
}];
assert_eq!(
detect_dense_line_chart_regions(&lines, &rects, 1),
vec![(40.0, 350.0, 632.0, 590.0)]
);
}
#[test]
fn shared_rules_do_not_join_dense_chart_to_adjacent_table() {
let mut lines: Vec<PdfLine> = (0..30)
.map(|column| make_vline(60.0 + column as f32 * 8.0, 400.0, 550.0, 1))
.collect();
lines.extend(
[330.0, 390.0, 450.0, 510.0, 570.0, 630.0]
.into_iter()
.map(|x| make_vline(x, 400.0, 550.0, 1)),
);
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 60.0, 630.0, 1)));
assert_eq!(
detect_dense_line_chart_regions(&lines, &[], 1),
vec![(40.0, 380.0, 312.0, 570.0)]
);
}
#[test]
fn uneven_dense_spacing_keeps_the_complete_chart_region() {
let mut xs: Vec<f32> = (0..15).map(|column| 60.0 + column as f32 * 8.0).collect();
xs.extend((0..15).map(|column| 212.0 + column as f32 * 8.0));
let mut lines: Vec<PdfLine> = xs.iter().map(|&x| make_vline(x, 400.0, 550.0, 1)).collect();
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 60.0, 324.0, 1)));
assert_eq!(
detect_dense_line_chart_regions(&lines, &[], 1),
vec![(40.0, 380.0, 344.0, 570.0)]
);
}
#[test]
fn subthreshold_dense_run_does_not_absorb_adjacent_sparse_grid() {
let mut xs: Vec<f32> = (0..21).map(|column| 60.0 + column as f32 * 8.0).collect();
xs.extend((0..6).map(|column| 248.0 + column as f32 * 18.0));
let mut lines: Vec<PdfLine> = xs.iter().map(|&x| make_vline(x, 400.0, 550.0, 1)).collect();
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 60.0, 338.0, 1)));
assert!(detect_dense_line_chart_regions(&lines, &[], 1).is_empty());
}
#[test]
fn broad_frame_does_not_merge_distant_dense_grids() {
let mut lines = Vec::new();
for panel_left in [60.0, 700.0] {
lines.extend(
(0..30).map(|column| make_vline(panel_left + column as f32 * 8.0, 400.0, 550.0, 1)),
);
lines.extend((0..6).map(|row| {
make_hline(400.0 + row as f32 * 30.0, panel_left, panel_left + 232.0, 1)
}));
}
let rects = vec![PdfRect {
x: 40.0,
y: 350.0,
width: 912.0,
height: 240.0,
page: 1,
}];
assert_eq!(
detect_dense_line_chart_regions(&lines, &rects, 1),
vec![(40.0, 380.0, 312.0, 570.0), (680.0, 380.0, 952.0, 570.0)]
);
}
#[test]
fn supported_width_vector_table_is_not_a_dense_chart() {
let mut lines: Vec<PdfLine> = (0..26)
.map(|column| make_vline(100.0 + column as f32 * 10.0, 400.0, 550.0, 1))
.collect();
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 100.0, 350.0, 1)));
assert!(detect_dense_line_chart_regions(&lines, &[], 1).is_empty());
}
#[test]
fn test_basic_grid_detection() {
// 3x2 grid with horizontal lines at y=500, 480, 460 and vertical at x=100, 200, 300
+454 -10
View File
@@ -1996,17 +1996,249 @@ fn without_dominant_page_backgrounds(rects: &[(f32, f32, f32, f32)]) -> Vec<(f32
.collect()
}
/// Detect a table from cell-background rects that failed grid detection.
/// Repeated rows of touching cell rectangles are stronger table evidence
/// than the bar-length variation used by the chart detector.
///
/// Uses rect Y-edges for row boundaries and text X-position clustering for
/// columns. Handles tables with cell backgrounds that don't form a clean
/// X-edge grid (variable column widths, decorative fills).
/// Chart-bar signature: ≥3 rects sharing an aligned bottom edge (the axis),
/// with similar widths (bars) but strongly varying heights (data-driven),
/// holding at most a single numeric data label each. Bar charts drawn as
/// filled rects otherwise read as cell rects and grid their axis labels
/// into a phantom table. The mirrored check catches horizontal bar charts.
fn is_chart_bar_cluster(
/// Ruled tables with wrapped labels naturally have variable row heights, and
/// numeric-heavy cells can otherwise resemble horizontal or vertical bars.
/// Require several rows to repeat a shared edge schema before overriding the
/// chart hypothesis so sparse plots and independent bars remain unaffected.
fn is_repeated_cell_grid(group_rects: &[(f32, f32, f32, f32)]) -> bool {
type RowGroup = (f32, f32, Vec<(f32, f32)>);
const ROW_EDGE_TOLERANCE: f32 = 3.0;
const MIN_GRID_ROWS: usize = 4;
const MIN_CELLS_PER_ROW: usize = 3;
if group_rects.len() < MIN_GRID_ROWS * MIN_CELLS_PER_ROW {
return false;
}
let mut row_groups: Vec<RowGroup> = Vec::new();
for &(x, y, width, height) in group_rects {
if width < 5.0 || height < 5.0 {
continue;
}
let top = y + height;
if let Some((_, _, cells)) = row_groups.iter_mut().find(|(bottom, row_top, _)| {
(y - *bottom).abs() <= ROW_EDGE_TOLERANCE
&& (top - *row_top).abs() <= ROW_EDGE_TOLERANCE
}) {
cells.push((x, x + width));
} else {
row_groups.push((y, top, vec![(x, x + width)]));
}
}
let mut row_schemas = Vec::new();
for (_, _, mut cells) in row_groups {
if cells.len() < MIN_CELLS_PER_ROW {
continue;
}
let mut widths: Vec<f32> = cells.iter().map(|&(left, right)| right - left).collect();
widths.sort_by(f32::total_cmp);
let median_width = widths[widths.len() / 2];
cells.retain(|&(left, right)| right - left <= median_width * 2.5);
cells.sort_by(|left, right| {
left.0
.total_cmp(&right.0)
.then_with(|| left.1.total_cmp(&right.1))
});
cells.dedup_by(|left, right| {
(left.0 - right.0).abs() <= ROW_EDGE_TOLERANCE
&& (left.1 - right.1).abs() <= ROW_EDGE_TOLERANCE
});
if cells.len() < MIN_CELLS_PER_ROW
|| cells
.windows(2)
.any(|pair| pair[1].0 > pair[0].1 + ROW_EDGE_TOLERANCE)
{
continue;
}
let edges: Vec<f32> = cells
.iter()
.flat_map(|&(left, right)| [left, right])
.collect();
let schema = snap_edges(&edges, ROW_EDGE_TOLERANCE);
if schema.len() > MIN_CELLS_PER_ROW {
row_schemas.push(schema);
}
}
if row_schemas.len() < MIN_GRID_ROWS {
return false;
}
let reference = row_schemas
.iter()
.max_by_key(|schema| schema.len())
.expect("grid rows are non-empty");
row_schemas
.iter()
.filter(|schema| {
let comparable_edges = reference.len().min(schema.len());
let matched_edges = schema
.iter()
.filter(|edge| {
reference
.iter()
.any(|reference_edge| (*edge - *reference_edge).abs() <= ROW_EDGE_TOLERANCE)
})
.count();
matched_edges > MIN_CELLS_PER_ROW && matched_edges * 4 >= comparable_edges * 3
})
.count()
>= MIN_GRID_ROWS
}
fn repeated_cell_grid_overrides_bar_hypothesis(group_rects: &[(f32, f32, f32, f32)]) -> bool {
is_repeated_cell_grid(group_rects)
&& without_dominant_page_backgrounds(group_rects).len() == group_rects.len()
}
/// Detect horizontal segmented stacks from aligned rows of touching rects.
///
/// Category rows must have visible gutters and data-varying internal segment
/// boundaries, unlike the stable boundaries of a ruled table.
struct SegmentedBarGeometry {
bounds: (f32, f32, f32, f32),
row_bands: Vec<(f32, f32)>,
}
fn segmented_stacked_bar_geometry(
group_rects: &[(f32, f32, f32, f32)],
) -> Option<SegmentedBarGeometry> {
type BarRow = (f32, f32, Vec<(f32, f32)>);
const EDGE_TOLERANCE: f32 = 3.0;
const MIN_ROWS: usize = 4;
const MIN_SEGMENTS: usize = 3;
let mut rows: Vec<BarRow> = Vec::new();
for &(x, y, width, height) in group_rects {
if width < 5.0 || height < 5.0 {
continue;
}
let top = y + height;
if let Some((_, _, segments)) = rows.iter_mut().find(|(bottom, row_top, _)| {
(y - *bottom).abs() <= EDGE_TOLERANCE && (top - *row_top).abs() <= EDGE_TOLERANCE
}) {
segments.push((x, x + width));
} else {
rows.push((y, top, vec![(x, x + width)]));
}
}
rows.retain_mut(|(_, _, segments)| {
segments.sort_by(|left, right| left.0.total_cmp(&right.0));
segments.len() >= MIN_SEGMENTS
&& segments
.windows(2)
.all(|pair| (pair[1].0 - pair[0].1).abs() <= EDGE_TOLERANCE)
});
if rows.len() < MIN_ROWS {
return None;
}
rows.sort_by(|left, right| left.0.total_cmp(&right.0));
// Table rows normally share borders. Horizontal stacked bars instead
// leave a visible gutter between category rows.
if rows.windows(2).any(|pair| {
let shorter_height = (pair[0].1 - pair[0].0).min(pair[1].1 - pair[1].0);
pair[1].0 - pair[0].1 < (shorter_height * 0.25).max(2.0)
}) {
return None;
}
// At least two rows must move an internal segment boundary. Stable
// boundaries across every row are stronger evidence for a ruled table.
let reference_edges: Vec<f32> = rows[0]
.2
.iter()
.take(rows[0].2.len() - 1)
.map(|segment| segment.1)
.collect();
let drifting_rows = rows
.iter()
.skip(1)
.filter(|(_, _, segments)| {
let edges: Vec<f32> = segments
.iter()
.take(segments.len() - 1)
.map(|segment| segment.1)
.collect();
edges.len() == reference_edges.len()
&& edges
.iter()
.zip(&reference_edges)
.any(|(edge, reference)| (edge - reference).abs() > EDGE_TOLERANCE)
})
.count();
if drifting_rows < 2 {
return None;
}
let left = rows
.iter()
.flat_map(|row| &row.2)
.map(|segment| segment.0)
.reduce(f32::min)?;
let right = rows
.iter()
.flat_map(|row| &row.2)
.map(|segment| segment.1)
.reduce(f32::max)?;
let bottom = rows.iter().map(|row| row.0).reduce(f32::min)?;
let top = rows.iter().map(|row| row.1).reduce(f32::max)?;
let row_bands = rows.iter().map(|row| (row.0, row.1)).collect();
Some(SegmentedBarGeometry {
bounds: (left, bottom, right, top),
row_bands,
})
}
/// Category labels beside multiple bar rows are independent chart evidence:
/// numeric table text stays inside its cells, regardless of whether the table
/// has an outer border or extra padding.
fn has_external_segmented_bar_labels(
items: &[TextItem],
page: u32,
geometry: &SegmentedBarGeometry,
) -> bool {
const LABEL_EDGE_TOLERANCE: f32 = 3.0;
const LABEL_CLAIM_PAD: f32 = 20.0;
let (content_left, _, content_right, _) = geometry.bounds;
let labeled_rows = geometry
.row_bands
.iter()
.filter(|&&(row_bottom, row_top)| {
items.iter().any(|item| {
if item.page != page || item.text.trim().is_empty() {
return false;
}
let item_left = item.x.min(item.x + item.width);
let item_right = item.x.max(item.x + item.width);
let item_center_x = (item_left + item_right) / 2.0;
let item_center_y = item.y + item.height / 2.0;
let beside_stack = (item_center_x <= content_left + LABEL_EDGE_TOLERANCE
&& item_center_x >= content_left - LABEL_CLAIM_PAD
&& item_left < content_left)
|| (item_center_x >= content_right - LABEL_EDGE_TOLERANCE
&& item_center_x <= content_right + LABEL_CLAIM_PAD
&& item_right > content_right);
beside_stack
&& item_center_y >= row_bottom - LABEL_EDGE_TOLERANCE
&& item_center_y <= row_top + LABEL_EDGE_TOLERANCE
})
})
.count();
labeled_rows >= 2 && labeled_rows * 2 >= geometry.row_bands.len()
}
/// Recognize filled vertical or horizontal bars whose geometry and labels are
/// data-driven rather than uniform table cells.
fn has_chart_bar_signature(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
@@ -2113,6 +2345,30 @@ fn is_chart_bar_cluster(
|| bar_family(|r| r.1, |r| r.3, |r| r.2, |r| r.0)
}
fn is_chart_bar_cluster(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> bool {
let has_bar_signature = has_chart_bar_signature(items, group_rects, page);
// A segmented horizontal chart can share most of its edges across rows.
// Row-aligned category labels outside the stack distinguish it from a
// numeric table without depending on whether either shape has a frame.
if has_bar_signature {
if let Some(geometry) = segmented_stacked_bar_geometry(group_rects) {
if has_external_segmented_bar_labels(items, page, &geometry) {
return true;
}
}
}
if repeated_cell_grid_overrides_bar_hypothesis(group_rects) {
return false;
}
has_bar_signature
}
fn detect_row_stripe_table_from_cell_rects(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
@@ -3083,6 +3339,194 @@ mod tests {
assert!(detect_chart_regions(&items, &rects, 1).is_empty());
}
#[test]
fn variable_height_ruled_grid_overrides_bar_hypothesis() {
let edge_sets = [
[80.0, 140.0, 200.0, 260.0, 320.0, 380.0, 440.0, 500.0, 560.0],
[80.0, 140.0, 210.0, 260.0, 320.0, 380.0, 450.0, 500.0, 560.0],
];
let heights = [20.0, 34.0, 26.0, 42.0, 20.0, 34.0];
let edge_variants = [0, 0, 0, 0, 1, 1];
let mut rects = Vec::new();
let mut y = 650.0;
for (row, height) in heights.into_iter().enumerate() {
let edges = edge_sets[edge_variants[row]];
rects.extend(
edges
.windows(2)
.map(|edge| (edge[0], y, edge[1] - edge[0], height)),
);
y -= height;
}
assert!(is_repeated_cell_grid(&rects));
assert!(has_chart_bar_signature(&[], &rects, 1));
assert!(repeated_cell_grid_overrides_bar_hypothesis(&rects));
assert!(segmented_stacked_bar_geometry(&rects).is_none());
assert!(!is_chart_bar_cluster(&[], &rects, 1));
let mut with_page_fills =
vec![(0.0, 0.0, 600.0, 800.0); DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS];
with_page_fills.extend(rects);
assert!(!repeated_cell_grid_overrides_bar_hypothesis(
&with_page_fills
));
}
#[test]
fn touching_segments_with_spaced_rows_remain_a_chart() {
let row_edges = [
[100.0, 140.0, 180.0, 220.0, 260.0],
[100.0, 140.0, 180.0, 228.0, 260.0],
[100.0, 140.0, 180.0, 214.0, 260.0],
[100.0, 140.0, 180.0, 232.0, 260.0],
];
let mut raw_rects = vec![(90.0, 530.0, 190.0, 100.0)];
for (row, edges) in row_edges.into_iter().enumerate() {
let y = 540.0 + row as f32 * 20.0;
raw_rects.extend(
edges
.windows(2)
.map(|edge| (edge[0], y, edge[1] - edge[0], 12.0)),
);
}
let items: Vec<TextItem> = (0..4)
.map(|row| make_item("Category", 62.0, 541.0 + row as f32 * 20.0, 9.0))
.collect();
assert!(is_repeated_cell_grid(&raw_rects));
assert!(has_chart_bar_signature(&items, &raw_rects, 1));
let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented stack");
assert!(has_external_segmented_bar_labels(&items, 1, &geometry));
assert!(is_chart_bar_cluster(&items, &raw_rects, 1));
let numeric_items: Vec<TextItem> = (0..4)
.map(|row| make_item("2024", 80.0, 541.0 + row as f32 * 18.0, 9.0))
.collect();
assert!(has_external_segmented_bar_labels(
&numeric_items,
1,
&geometry
));
assert!(is_chart_bar_cluster(&numeric_items, &raw_rects, 1));
let edge_adjacent_items: Vec<TextItem> = (0..4)
.map(|row| make_item("2024", 92.0, 541.0 + row as f32 * 18.0, 9.0))
.collect();
assert!(has_external_segmented_bar_labels(
&edge_adjacent_items,
1,
&geometry
));
assert!(is_chart_bar_cluster(&edge_adjacent_items, &raw_rects, 1));
let far_items: Vec<TextItem> = (0..4)
.map(|row| make_item("Category", 20.0, 541.0 + row as f32 * 18.0, 9.0))
.collect();
assert!(!has_external_segmented_bar_labels(&far_items, 1, &geometry));
assert!(!is_chart_bar_cluster(&far_items, &raw_rects, 1));
let rects: Vec<PdfRect> = raw_rects
.into_iter()
.map(|(x, y, width, height)| PdfRect {
x,
y,
width,
height,
page: 1,
})
.collect();
assert_eq!(detect_chart_regions(&items, &rects, 1).len(), 1);
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
assert!(tables.is_empty());
assert!(hints.is_empty());
}
#[test]
fn padded_numeric_grid_frame_remains_a_table() {
let row_edges = [
[100.0, 140.0, 180.0, 220.0, 260.0],
[100.0, 140.0, 180.0, 228.0, 260.0],
[100.0, 140.0, 180.0, 214.0, 260.0],
[100.0, 140.0, 180.0, 232.0, 260.0],
];
let mut raw_rects = vec![(96.0, 536.0, 168.0, 80.0)];
let mut items = Vec::new();
for (row, edges) in row_edges.into_iter().enumerate() {
let y = 540.0 + row as f32 * 20.0;
for edge in edges.windows(2) {
raw_rects.push((edge[0], y, edge[1] - edge[0], 12.0));
items.push(make_item("42", edge[0] + 8.0, y + 1.0, 9.0));
}
}
assert!(is_repeated_cell_grid(&raw_rects));
assert!(has_chart_bar_signature(&items, &raw_rects, 1));
let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented rows");
assert!(!has_external_segmented_bar_labels(&items, 1, &geometry));
assert!(!is_chart_bar_cluster(&items, &raw_rects, 1));
let flush_items: Vec<TextItem> = (0..4)
.map(|row| make_item("1", 100.0, 541.0 + row as f32 * 20.0, 9.0))
.collect();
assert!(!has_external_segmented_bar_labels(
&flush_items,
1,
&geometry
));
assert!(!is_chart_bar_cluster(&flush_items, &raw_rects, 1));
let rects: Vec<PdfRect> = raw_rects
.into_iter()
.map(|(x, y, width, height)| PdfRect {
x,
y,
width,
height,
page: 1,
})
.collect();
assert!(detect_chart_regions(&items, &rects, 1).is_empty());
assert!(!detect_tables_from_rects(&items, &rects, 1).0.is_empty());
}
#[test]
fn frameless_segmented_chart_with_category_labels_remains_a_chart() {
let row_edges = [
[100.0, 140.0, 180.0, 220.0, 260.0],
[100.0, 140.0, 180.0, 228.0, 260.0],
[100.0, 140.0, 180.0, 214.0, 260.0],
[100.0, 140.0, 180.0, 232.0, 260.0],
];
let mut raw_rects = Vec::new();
let mut items = Vec::new();
for (row, edges) in row_edges.into_iter().enumerate() {
let y = 540.0 + row as f32 * 18.0;
raw_rects.extend(
edges
.windows(2)
.map(|edge| (edge[0], y, edge[1] - edge[0], 12.0)),
);
items.push(make_item("Category", 62.0, y + 1.0, 9.0));
}
let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented stack");
assert!(has_external_segmented_bar_labels(&items, 1, &geometry));
assert!(is_chart_bar_cluster(&items, &raw_rects, 1));
let rects: Vec<PdfRect> = raw_rects
.into_iter()
.map(|(x, y, width, height)| PdfRect {
x,
y,
width,
height,
page: 1,
})
.collect();
assert_eq!(detect_chart_regions(&items, &rects, 1).len(), 1);
}
// --- detect_stacked_box_table ---
/// N stacked boxes at x=100, w=300, h=22, top-to-bottom from y=600.
+3 -1
View File
@@ -16,7 +16,9 @@ pub(crate) use detect_heuristic::{
content_width, detect_tables_with_page_width, is_table_of_contents,
};
pub use detect_lines::detect_tables_from_lines;
pub(crate) use detect_lines::detect_vector_grid_tables_from_lines;
pub(crate) use detect_lines::{
detect_dense_line_chart_regions, detect_vector_grid_tables_from_lines,
};
pub(crate) use detect_rects::cluster_rects;
pub use detect_rects::{detect_chart_regions, detect_tables_from_rects, RectHintRegion};
pub use detect_struct::detect_tables_from_struct_tree;
+10 -3
View File
@@ -70,10 +70,17 @@ pub(crate) fn is_page_number_line(text: &str) -> bool {
let lowercase = text.trim().to_ascii_lowercase();
lowercase.strip_prefix("page").is_some_and(|rest| {
rest.trim_start()
.chars()
.next()
let mut characters = rest.trim_start().chars().peekable();
let mut has_page_number = false;
while characters
.peek()
.is_some_and(|character| character.is_ascii_digit())
{
has_page_number = true;
characters.next();
}
has_page_number && characters.next().is_none_or(char::is_whitespace)
})
}
+121
View File
@@ -880,6 +880,25 @@ fn try_remap_subset_cmap(
None => return (cmap, None),
};
// Both repair paths below assume CIDs are glyph indices that a subsetter can
// renumber, which is only true for CIDFontType2 (TrueType). For CIDFontType0
// (CFF), CIDs are resolved through the CFF charset, so a valid CMap stays valid
// after subsetting and renumbering it corrupts otherwise-correct text.
// CIDToGIDMap is likewise CIDFontType2-only (PDF 32000-1:2008, 9.7.4.2), so this
// also ignores a CIDToGIDMap that a malformed producer attached to a CFF font.
// /Subtype may be an indirect reference, so resolve it through the document.
// Only bail out when the descendant is *explicitly* something other than
// CIDFontType2: a missing or unresolvable /Subtype keeps the previous
// behaviour rather than silently disabling the repair.
let subtype = cid_font_dict.get(b"Subtype").ok().and_then(|o| match o {
Object::Reference(r) => doc.get_object(*r).ok().and_then(|o| o.as_name().ok()),
other => other.as_name().ok(),
});
if subtype.is_some_and(|name| name != b"CIDFontType2") {
debug!("Subset remap skipped for obj={obj_num}: descendant is not CIDFontType2");
return (cmap, None);
}
// If there's an explicit CIDToGIDMap, build a repaired CMap using it.
if let Some(cid_to_gid) = get_cid_to_gid_map(cid_font_dict, doc) {
if let Some(repaired) = build_cmap_with_cid_to_gid_map(&cmap, &cid_to_gid) {
@@ -3159,4 +3178,106 @@ endbfrange
"Remap must fire when CMap's CIDs are outside W array coverage"
);
}
#[test]
fn test_try_remap_skipped_for_cid_font_type0() {
// Same W/CMap mismatch as the CIDFontType2 case above, but the descendant is
// CIDFontType0 (CFF). There CIDs are resolved through the CFF charset, so the
// ToUnicode CIDs stay valid after subsetting and must not be renumbered.
// Real-world case: Japanese Adobe-Japan1 PDFs (e.g. National Diet Library
// minutes) where remapping turned correct text into unrelated glyphs.
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
1 beginbfrange
<0200> <0220> <0410>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
let mut doc = Document::new();
// CIDToGIDMap is CIDFontType2-only, but a malformed producer can still emit
// one on a CFF font. Use a real stream (not /Identity, which is treated as
// "no map") so this also fails if the guard is moved back below the
// CIDToGIDMap branch: cid 1 -> gid 0x0200, which the CMap resolves.
let mut cid_to_gid = vec![0u8; 68];
cid_to_gid[2] = 0x02;
cid_to_gid[3] = 0x00;
let cid_to_gid_id =
doc.add_object(lopdf::Stream::new(lopdf::Dictionary::new(), cid_to_gid));
let mut cid_font = lopdf::Dictionary::new();
cid_font.set("Subtype", lopdf::Object::Name(b"CIDFontType0".to_vec()));
cid_font.set("CIDToGIDMap", lopdf::Object::Reference(cid_to_gid_id));
cid_font.set(
"W",
lopdf::Object::Array(vec![
lopdf::Object::Integer(1),
lopdf::Object::Array(vec![lopdf::Object::Integer(500); 34]),
]),
);
let cid_font_id = doc.add_object(cid_font);
let mut font_dict = lopdf::Dictionary::new();
font_dict.set("Encoding", lopdf::Object::Name(b"Identity-H".to_vec()));
font_dict.set(
"DescendantFonts",
lopdf::Object::Array(vec![lopdf::Object::Reference(cid_font_id)]),
);
let (primary, remapped) = try_remap_subset_cmap(cmap, &font_dict, &doc, 789);
assert!(
remapped.is_none(),
"Remap must be skipped for CIDFontType0 (CFF) descendants, including a \
CIDToGIDMap a malformed producer attached to one"
);
// The original CMap must still resolve its own CIDs.
assert_eq!(primary.lookup(0x0200), Some("\u{0410}".to_string()));
}
#[test]
fn test_try_remap_resolves_indirect_subtype() {
// /Subtype may be stored as an indirect reference. A genuine CIDFontType2
// font must still get the repair, so the guard has to dereference it rather
// than treat the unresolved value as "not CIDFontType2".
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
1 beginbfrange
<0200> <0220> <0410>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
let mut doc = Document::new();
let subtype_id = doc.add_object(lopdf::Object::Name(b"CIDFontType2".to_vec()));
let mut cid_font = lopdf::Dictionary::new();
cid_font.set("Subtype", lopdf::Object::Reference(subtype_id));
cid_font.set("CIDToGIDMap", lopdf::Object::Name(b"Identity".to_vec()));
cid_font.set(
"W",
lopdf::Object::Array(vec![
lopdf::Object::Integer(0),
lopdf::Object::Array(vec![lopdf::Object::Integer(500); 34]),
]),
);
let cid_font_id = doc.add_object(cid_font);
let mut font_dict = lopdf::Dictionary::new();
font_dict.set("Encoding", lopdf::Object::Name(b"Identity-H".to_vec()));
font_dict.set(
"DescendantFonts",
lopdf::Object::Array(vec![lopdf::Object::Reference(cid_font_id)]),
);
let (_primary, remapped) = try_remap_subset_cmap(cmap, &font_dict, &doc, 790);
assert!(
remapped.is_some(),
"An indirect /Subtype naming CIDFontType2 must still reach the remap"
);
}
}
+68
View File
@@ -0,0 +1,68 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/Contents 7 0 R /MediaBox [ 0 0 612 792 ] /Parent 6 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
4 0 obj
<<
/PageMode /UseNone /Pages 6 0 R /Type /Catalog
>>
endobj
5 0 obj
<<
/Author (anonymous) /CreationDate (D:20260803112923+00'00') /Creator (anonymous) /Keywords () /ModDate (D:20260803112923+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
6 0 obj
<<
/Count 1 /Kids [ 3 0 R ] /Type /Pages
>>
endobj
7 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 202
>>
stream
GarW05mr9@&;9NOME,dW.,B;'jAjYq0S4Z`*D9aMA;]$5J)A/$3lESen1?F)ZJsa4$4&N%-%cs)#qW5EVhhbPiRDrAV>MC%.spto@CU"ZdipR'TtFiMR_%m*Hm$N%qL7a"ckkp9T/s[N2"Og377mP*M^akb2XQZ@'l*qT(9bVtDb5+)S&Q.#%E)<]Ao`TSk2AE'/E\fn~>endstream
endobj
xref
0 8
0000000000 65535 f
0000000061 00000 n
0000000092 00000 n
0000000199 00000 n
0000000392 00000 n
0000000460 00000 n
0000000721 00000 n
0000000780 00000 n
trailer
<<
/ID
[<6d7ea1213c5974c78613d5d2a08423b5><6d7ea1213c5974c78613d5d2a08423b5>]
% ReportLab generated PDF document -- digest (opensource)
/Info 5 0 R
/Root 4 0 R
/Size 8
>>
startxref
9072
%%EOF
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+128
View File
@@ -1310,6 +1310,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
// ============================================================================
@@ -3914,6 +3926,65 @@ fn encrypted_pdf_decrypts_with_correct_password() {
);
}
/// Regression for the #231 review finding: `extract_pages_markdown`'s
/// `has_template_image` check must be gated the same way
/// `classify_pdf`/`detect_pdf_type` gates it (image_count <= 1, few text
/// ops, low alphanumeric diversity) — not treated as sufficient on its
/// own. The fixture is a real text page with substantial, richly varied
/// body text (>=50 Tj ops) drawn over a full-bleed background image
/// (e.g. letterhead/watermark). Before the fix, has_template_image alone
/// forced needs_ocr=true and discarded the page's clean markdown; now the
/// page must extract normally.
#[test]
fn test_extract_pages_markdown_does_not_ocr_text_page_with_watermark_image() {
let buf = std::fs::read("tests/fixtures/text_page_with_watermark_image.pdf").unwrap();
let ext = extract_pages_markdown_mem(&buf, None).expect("fixture should extract");
let page = &ext.pages[0];
assert!(
!page.needs_ocr,
"a text page with substantial real text should not be routed to OCR \
just because it has a background image"
);
assert!(
page.markdown.contains("watermark"),
"expected the page's real body text to be preserved, got: {:?}",
page.markdown
);
}
/// Regression for the #231 review finding: `extract_pages_markdown` never
/// checked `has_vector_text` at all, even though `detect_from_document`'s
/// Mixed-type per-page routing always sends vector-outlined-text pages to
/// OCR (outlined glyphs can't be extracted as text). A page with massive
/// path ops (outlined decorative text) plus a short genuine caption would
/// extract that caption cleanly — non-empty, non-garbled — so the
/// existing empty/garbage-text checks alone couldn't catch it.
#[test]
fn test_extract_pages_markdown_ocrs_page_with_vector_outlined_text() {
let buf = std::fs::read("tests/fixtures/vector_outlined_text_with_caption.pdf").unwrap();
let cls = pdf_inspector::detector::detect_pdf_type_mem(&buf).expect("fixture should classify");
assert!(
cls.pages_needing_ocr.contains(&1),
"classify_pdf should flag page 1 as needing OCR (vector-outlined text), got: {:?}",
cls.pages_needing_ocr
);
let ext = extract_pages_markdown_mem(&buf, None).expect("fixture should extract");
let page = &ext.pages[0];
assert!(
page.needs_ocr,
"extract_pages_markdown must agree with classify_pdf that this page needs OCR"
);
assert!(
page.markdown.is_empty(),
"a page flagged needs_ocr must not return markdown as if extraction were \
trustworthy, got: {:?}",
page.markdown
);
}
#[test]
fn pdf_options_debug_redacts_password() {
let opts = PdfOptions::new().password("secret123");
@@ -3924,3 +3995,60 @@ fn pdf_options_debug_redacts_password() {
);
assert!(dbg.contains("REDACTED"), "expected redaction marker: {dbg}");
}
/// Regression for #228: a `startxref` pointer corrupted to point at the
/// wrong byte offset (a single flipped digit — a real, common writer bug)
/// must not make the whole file unprocessable. The real classic xref table
/// is still present and findable by scanning for the `xref` keyword; both
/// pypdf and pdfium recover the same way. Before this fix, every entry
/// point raised "Invalid PDF structure" on a file whose object data was
/// otherwise completely intact.
#[test]
fn test_process_pdf_recovers_corrupted_startxref_pointer() {
let result = process_pdf_with_options(
"tests/fixtures/broken_startxref_pointer.pdf",
PdfOptions::new(),
)
.expect("a corrupted startxref pointer should be recoverable, like pypdf/pdfium");
assert_eq!(result.page_count, 1);
let md = result.markdown.unwrap_or_default();
assert!(
md.contains("Order Detail Report by Account") && md.contains("WIDGET ASSEMBLY"),
"recovered document should extract its real text, got: {md:?}"
);
}
/// Regression for #227: `extract_pages_markdown`'s per-page `needs_ocr`
/// must agree with `classify_pdf`/`detect_pdf_type` on the same page. The
/// fixture is a full-page raster "scan" with a single line of genuine
/// native text drawn over it (a header) — the native text extracts
/// perfectly cleanly (no decoding issues, non-empty), so a needs_ocr
/// computation based on text-quality signals alone says `false`, while
/// detection correctly sees a dominant background image and says the page
/// needs OCR. Both must now agree it needs OCR, and the markdown must not
/// be returned as if the extraction were trustworthy.
#[test]
fn test_extract_pages_markdown_agrees_with_classify_on_scan_with_native_header() {
let buf = std::fs::read("tests/fixtures/scan_with_native_header_text.pdf").unwrap();
let cls = pdf_inspector::detector::detect_pdf_type_mem(&buf).expect("fixture should classify");
assert!(
cls.pages_needing_ocr.contains(&1),
"classify_pdf should flag page 1 as needing OCR (image-dominated), got: {:?}",
cls.pages_needing_ocr
);
let ext = extract_pages_markdown_mem(&buf, None).expect("fixture should extract");
let page = &ext.pages[0];
assert!(
page.needs_ocr,
"extract_pages_markdown must agree with classify_pdf that this page needs OCR"
);
assert!(
page.markdown.is_empty(),
"a page flagged needs_ocr must not return markdown as if extraction were \
trustworthy, got: {:?}",
page.markdown
);
}
+43
View File
@@ -0,0 +1,43 @@
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
HE recent development of various methods of modulation such as PCM and PPM which exchange
# Tbandwidth 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. 2 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