Introduction
pdfboss is a PDF engine written from scratch in safe Rust against the ISO 32000
specification. It is a clean-room implementation: no C dependencies, no
bindings to another engine, and the image and color codecs a PDF needs
(JPEG 2000, JBIG2, CCITT, ICC) are its own. One core sits behind every
surface: the pdfboss command-line tool, an interactive terminal explorer
(pdfboss tui), a set of Rust library crates, and a native Python extension.
The surfaces expose the same engine at different altitudes. The CLI covers
extraction, rendering, creation and explorer subcommands over a document's
structure; the terminal explorer browses a file interactively, local or
remote. Python gets Document and its async twin AsyncDocument, with
pages, styled spans, lazy element iteration, rendering and image extraction,
plus document creation through pdfboss.write and pdfboss.md.to_pdf.
The Rust crates split the core by concern, from parsing (pdfboss-core)
through layout analysis (pdfboss-output) and rasterization
(pdfboss-render) to creation (pdfboss-write).
Leniency
Real-world PDFs are damaged: truncated downloads, editors that miscount stream lengths, generators that write cross-reference tables pointing nowhere. pdfboss reads them anyway:
- A broken or missing cross-reference table is reconstructed by scanning the file for its objects.
- A stream whose declared length is wrong is still decoded.
- Content-stream operators that will not parse are skipped, and the rest of the page still extracts and rasterizes.
Leniency never hides what it cost. Every dropped or approximated piece of
content lands in a report: pdfboss render warns on stderr, the terminal
explorer raises a notice, and the libraries return the report as a value,
through render_page_reporting and extract_text_reporting in Rust and
Page.render_reporting() in Python. A page that came out exactly as the file
describes it carries an empty report, so silence means fidelity, not luck.
Scope
pdfboss extracts plain text and Markdown (headings, lists and tables inferred
from page layout), yields styled text spans carrying position, font, weight,
decorations and color, rasterizes pages to PNG through its own JPEG 2000,
JBIG2, CCITT and ICC codecs, extracts embedded images at their native pixel
size, creates new PDFs (canvas painting and element composition through the
pdfboss-write crate, the pdfboss.write Python module and pdfboss create,
a TOML manifest form included, plus CommonMark+GFM composed into CSS-themed
documents from the CLI, Python pdfboss.md.to_pdf and Rust), and reads documents
asynchronously over range-fetching I/O, from local files or HTTP, without
ever reading the whole file.
Encrypted files open through the standard security handler: RC4 and AES-128/256, with either the user or the owner password; a file whose user password is empty opens without one.
Where to go next
Installation covers the wheel, the binary and the crates; the Quickstart shows each surface doing real work. The guide then takes one task per chapter:
- Extracting text: plain text, reading order, page selection.
- Markdown output: headings, lists and tables inferred from layout.
- Styled spans: positioned text runs with font, size, weight, decorations and color.
- Rendering pages: PNG output, scale, font tiers, render reports.
- Extracting images: every image a page draws, at native size.
- Creating PDFs: canvas painting, composed elements and document slots from the CLI, Python and Rust.
- Editing PDFs: incremental updates to an existing
file, starting with
meta. - Markdown to PDF: CommonMark+GFM composed into themed, paginated documents.
- Async and remote documents: range-fetching access to local files and HTTP URLs.
- Exploring PDF internals: the JSON value tree, jq queries, hexdumps and the terminal explorer.
- Encrypted documents: passwords on every surface.
The reference section holds the CLI reference, the Python API, the Rust crates and the list of limitations.
pdfboss is dual-licensed under MIT or Apache-2.0, at your option.
Installation
Python
pip install pdfboss
Prebuilt abi3 wheels for CPython 3.12 and later; no Rust toolchain required. The wheel compiles in the predefined CJK CMap set, so CJK-coded documents extract out of the box.
Rendering with fonts="full" substitutes replacement faces for fonts the PDF
does not embed. Those faces ship as a separate package, pulled in by the
full extra:
pip install "pdfboss[full]"
This installs pdfboss-fonts alongside the wheel. Without it, fonts="full"
requires a font_dir argument pointing at faces of your own. See
Rendering pages.
CLI
cargo install pdfboss-cli
This installs the pdfboss binary. Two features are on by default:
substitute-fonts: bundles the OFL Croscore substitute faces (about 4 MB) sorenderandtuican paint text for PDFs with non-embedded fonts out of the box.predefined-cmaps: compiles in the predefined CJK CMap set of ISO 32000 Table 118 (about 830 KB) sotextreads Shift-JIS/EUC/Big5/GBK/UHC-coded Type0 fonts.
Opt out of both for a leaner binary:
cargo install pdfboss-cli --no-default-features
Rust crates
The library crates are on crates.io:
cargo add pdfboss-core pdfboss-text pdfboss-output pdfboss-render pdfboss-write pdfboss-markdown pdfboss-aio
| Crate | Responsibility |
|---|---|
pdfboss-core | Parsing, object model, stream filters, document and page tree |
pdfboss-text | Fonts, encodings, positional text spans |
pdfboss-output | Layout analysis to plain text and Markdown |
pdfboss-render | Rasterization to RGBA pixmaps and PNG, embedded-image extraction |
pdfboss-write | PDF creation |
pdfboss-markdown | CommonMark+GFM composed into themed PDFs (pulls in pdfboss-style) |
pdfboss-aio | Async, range-fetching document access |
Add only what you use: pdfboss-core alone parses; the others build on it.
The library crates keep their optional features off by default:
pdfboss-rendersubstitute-fonts: the bundled substitute faces.pdfboss-corepredefined-cmaps: the predefined CJK CMap set.pdfboss-aiohttp: remote documents over HTTP range requests.pdfboss-aiowrite: streaming created documents into tokio writers; shipsTokioSink, which presents anytokio::io::AsyncWritetoPdf::write_into_with(see Creating PDFs).
Enable a feature at add time, for example:
cargo add pdfboss-aio --features http
Building from source
git clone https://github.com/4thel00z/pdfboss
cd pdfboss
cargo build --release # the CLI lands at target/release/pdfboss
cargo test --workspace # Rust test suite
The Python extension builds with maturin into the active virtualenv:
maturin develop # build the extension into your venv
pytest # Python integration tests
Next: the Quickstart.
Quickstart
One taste of each surface. report.pdf stands in for any PDF of yours;
Installation covers getting the binary, the wheel and the
crates.
CLI
pdfboss info report.pdf # version, page count, page sizes, metadata
pdfboss text report.pdf --page 2 # extract text (omit --page for all pages)
pdfboss md report.pdf # markdown: headings, lists, tables from layout
pdfboss render report.pdf --page 1 -o page.png --scale 2.0
mkdir out
pdfboss images report.pdf -o out/ # embedded images as native-size PNGs
pdfboss create text notes.txt -o notes.pdf # a new PDF from a word-wrapped text file
pdfboss create md notes.md -o notes.pdf # markdown composed with a CSS theme
render prints what it wrote (wrote page.png (1224 x 1584 px)) and warns on
stderr about anything it had to drop; images names every file it writes,
out/page-1-image-1.png style. Page numbers are 1-based on the command line.
Python
from pathlib import Path
import pdfboss
doc = pdfboss.Document("report.pdf")
print(doc.page_count, "pages, PDF", doc.version)
text = doc.extract_text() # all pages, form-feed separated
markdown = doc.extract_markdown() # headings, lists, tables from layout
page = doc[0]
Path("page.png").write_bytes(page.render(scale=2.0))
for image in page.extract_images():
print(image.width, image.height, len(image.data))
pdf = pdfboss.md.to_pdf(Path("notes.md").read_text()) # markdown -> PDF bytes
Document also opens from memory (Document(data=raw_bytes)), pages index
0-based with negative indexes from the end, and render returns PNG bytes
directly. extract_images yields each image the page draws at its native
pixel size, PNG-encoded. pdfboss.md.to_pdf composes CommonMark+GFM into a
themed PDF (Markdown to PDF); pdfboss.write composes
pages, elements and document slots with |, the same vocabulary the CLI and
the pdfboss-write crate expose (Creating PDFs).
Rust
With pdfboss-core, pdfboss-output and pdfboss-render added:
use pdfboss_core::Document;
use pdfboss_output::ReadingOrder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
println!("{} pages", doc.page_count());
let page = doc.page(0)?;
let text = pdfboss_output::extract_text(&doc, &page, ReadingOrder::Content)?;
println!("{text}");
let pixmap = pdfboss_render::render_page(&doc, &page, 2.0)?;
pixmap.save_png("page.png")?;
Ok(())
}
render_page returns an RGBA Pixmap (width, height, data); save_png
and encode_png turn it into a file or bytes. Page indexes are 0-based, as in
Python; only the CLI counts from 1.
Next steps
- Extracting text and Markdown output for layout analysis details.
- Rendering pages for scale, font tiers and PNG compression.
- Extracting images for what counts as an embedded image.
- Creating PDFs for canvas painting, composed pages and document slots.
- Markdown to PDF for themed CommonMark+GFM composition.
- Async and remote documents for
AsyncDocumentand HTTP range fetching.
Extracting text from PDFs
Text extraction turns a page's positioned glyphs back into readable text: spans are
grouped into lines, lines are joined with \n, and spaces are inserted at horizontal
gaps. By default reading order follows the content stream: a typeset document writes
each column whole before the next begins, so the text comes out column by column, and
a figure caption or a footnote reads where the producer placed it. Geometry corrects
the streams that write across two columns row by row (a page with a clear gutter
still reads column-major) and takes over entirely when a stream was not written in
reading order at all, which then reads top to bottom. Two other orders can be
selected; see Reading order. Whole-document extraction joins pages
with a form feed (\f). For structured output (headings, lists, tables), see
Markdown output; for the spans themselves, with fonts, sizes and
positions, see Styled spans.
Text drawn turned a quarter, such as a landscape exhibit set on a portrait page or a label running up a chart's side, is laid out in its own upright frame: each span's baseline direction is snapped to the nearest quarter turn, the spans sharing one are grouped into lines and tables the way upright text is, and the groups follow one another top to bottom by where they sit on the page. Text at other angles, such as a diagonal watermark, is treated as the nearest quarter turn. A page with no turned text skips this step.
Reading order
Every extraction entry point takes a reading order. There are three:
| Order | Name | What it does |
|---|---|---|
| Content | content | The content stream's order, corrected by geometry as described above. The default. |
| Structure tree | structure-tree | On a tagged page, the order the structure tree (/StructTreeRoot) declares: each marked-content sequence is ranked by its place in the tree, and untagged content keeps its place after the tagged content it followed. /MarkInfo is never consulted, so a tree with leaves counts even when the file says /Marked false. A page the tree does not reach reads in content order. |
| Geometric | geometric | Position alone: lines top to bottom, spans left to right, a page with a clear gutter column by column. A two-column page the gutter search does not find comes out interleaved row by row, which is why this order is opt-in. |
pdfboss text report.pdf --reading-order structure-tree
pdfboss md report.pdf --reading-order geometric
from pdfboss import Document, ReadingOrder
doc = Document("report.pdf")
doc.extract_text(reading_order=ReadingOrder.STRUCTURE_TREE)
doc[0].extract_markdown(reading_order="geometric") # the string value works too
In Rust the order is the last argument of every entry point:
use pdfboss_core::Document;
use pdfboss_output::ReadingOrder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let page = doc.page(0)?;
let (text, report) =
pdfboss_output::extract_text_reporting(&doc, &page, ReadingOrder::StructureTree)?;
println!("{text}");
println!("read in {} order", report.order);
Ok(())
}
ExtractReport::order says which order the page actually came out in: the one
requested, or Content when the structure tree was asked for and the page has no
entry in it. The content-order walk is compiled without the marked-content
bookkeeping the structure-tree walk needs, so the default costs nothing extra.
Annotations in the reading order
Annotations live in the page's /Annots array, not in the content stream, so
their place in the reading order comes from the structure tree alone (ISO 32000-1
14.8.2.3.2): a Link, Form or Annot element holds the annotation through an object
reference (/Type /OBJR, 14.7.4.3), and the annotation precedes or follows the
marked-content sequences its element sits between. Page.content_items() returns
the page's content items in that order, sequences and annotations ranked
together, each with the element holding it. Text extraction returns no annotation
text, so this is where an annotation's slot among the text is read off:
page = doc[0]
annotations = {a.ref: a for a in page.annotations()}
for item in page.content_items():
if item.kind == "object":
print(item.rank, item.standard_type, annotations[item.ref].subtype)
else:
print(item.rank, item.standard_type, "marked content", item.mcid)
In Rust Document::content_items(&page) returns the same list as PlacedItem
records, and StructureTree::place_items_with places any set of ContentItems
(sequences by their /StructParents key and MCID, objects by reference) in one
shared rank space; the spans of pdfboss_text under structure-tree order carry
the holding element in structure.path, which is how a span and an item of the
same Link element are matched. Only the page's own content stream and its
/Annots are enumerated: the sequences of a form XObject with its own
/StructParents are placed when handed in. The Python spans expose the
element's description and language but not its reference, so in Python the match
between a span and an item goes through the annotation's rectangle.
CLI
pdfboss text report.pdf
prints every page, separated by form feeds. --page selects one page, 1-based:
pdfboss text --page 4 report.pdf
Content that cannot be read is reported on stderr, one warning per skipped stream, with the same 1-based page numbers:
warning: page 17: skipped a form XObject (form limit exceeded)
stdout carries only the extracted text, so the output stays safe to pipe. Encrypted
files take --password. See Encrypted documents.
Python
from pdfboss import Document
doc = Document("report.pdf")
text = doc.extract_text() # all pages, joined by "\f"
page_text = doc[3].extract_text() # one page (0-based index)
Document.extract_text fans the pages out across the machine's cores: each worker
thread holds its own fork of the document (the immutable parsed core is shared, the
caches are private), and one font cache serves every worker so each font loads once
per document. Both calls release the GIL while they run, so other Python threads
keep making progress during long extractions.
Rust
Per page, with pdfboss_output::extract_text:
use pdfboss_core::Document;
use pdfboss_output::ReadingOrder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
for i in 0..doc.page_count() {
let page = doc.page(i)?;
let text = pdfboss_output::extract_text(&doc, &page, ReadingOrder::Content)?;
println!("{text}");
}
Ok(())
}
For a whole document, pdfboss_core::map_pages runs a closure over every page in
parallel and returns the results in page order. It fans out over
std::thread::available_parallelism() threads, each holding its own document fork;
workers pull page indexes from a shared counter, so pages of uneven cost cannot
strand a fast core behind a slow stripe. Pass one FontCache to the _cached
variant so fonts load once per document rather than once per page:
use pdfboss_core::{map_pages, Document};
use pdfboss_output::{FontCache, ReadingOrder};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let fonts = FontCache::default();
let outcomes = map_pages(&doc, |doc, page| {
let (text, _) = pdfboss_output::extract_text_reporting_cached(
doc,
page,
&fonts,
ReadingOrder::Content,
)?;
Ok(text)
});
let texts = outcomes.into_iter().collect::<Result<Vec<String>, _>>()?;
println!("{}", texts.join("\u{c}"));
Ok(())
}
Under ReadingOrder::StructureTree the _cached call still loads the structure tree
on every page, and loading reads the whole parent tree. A document walk loads it once
with structure_for, the optional-content state once with oc_state, and drives the
_with twin over Immediate from each worker. The tree keeps what each page walk
read for the next page, so map_pages_forking gives every worker its own fork of it
(the same tree over an empty cache) rather than one tree the workers would queue on:
use pdfboss_core::{map_pages_forking, source::block_on, Document, Immediate};
use pdfboss_output::{structure_for, FontCache, ReadingOrder};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let order = ReadingOrder::StructureTree;
let fonts = FontCache::default();
let oc = doc.oc_state();
let structure = structure_for(&doc, order);
let outcomes = map_pages_forking(
&doc,
|| structure.clone(),
|structure, doc, page| {
let (text, _) = block_on(pdfboss_output::extract_text_reporting_cached_with(
Immediate(doc),
page,
&fonts,
oc.as_ref(),
structure.as_ref(),
order,
))?;
Ok(text)
},
);
let texts = outcomes.into_iter().collect::<Result<Vec<String>, _>>()?;
println!("{}", texts.join("\u{c}"));
Ok(())
}
Asynchronous callers use extract_text_with against any object source. See
Async and remote documents.
Lenient semantics and reporting
Extraction is lenient the way rendering is: content that will not fetch, decode, or
parse yields no text rather than an error, so one unreadable stream never costs the
rest of the document. extract_text_reporting is what keeps that leniency
accountable. It returns the text together with an ExtractReport:
use pdfboss_core::Document;
use pdfboss_output::ReadingOrder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let page = doc.page(0)?;
let (text, report) =
pdfboss_output::extract_text_reporting(&doc, &page, ReadingOrder::Content)?;
for skip in &report.skipped {
eprintln!("skipped {} ({})", skip.kind, skip.cause);
}
println!("{text}");
Ok(())
}
report.skipped names each stream that yielded no text: the kind (the page's own
contents, a form XObject, an unresolvable XObject, a font's CMap encoding) and the
cause (an unsupported filter, a stream that would not read, content that would not
parse, a missing resource, an exhausted form nesting or invocation limit).
report.hidden counts
content the document's optional-content configuration turns off; that is configured
behavior, not a loss, so report.is_complete() ignores it and is true exactly when
nothing was left out. An empty text with an empty report really is an empty page.
The CLI warnings above are this report, printed. Layers the document turns off
on screen are excluded from the text: the default optional-content
configuration with the View usage application applied, so a print-only layer
is left out while a group of another /Intent is kept. The Rust _with
entries take the state explicitly, so doc.oc_state_for(Some(OcEvent::Print))
extracts the text as printed.
Encodings
Character decoding covers ToUnicode CMaps, the WinAnsi, MacRoman and Standard
simple-font encodings, the built-in encoding of an embedded Type 1 program (the base
table of a simple font that names no /Encoding of its own, which is how TeX's
symbol and math fonts arrive), and CID-keyed Type0 fonts: embedded /Encoding CMap
streams parse, the predefined ISO 32000 CJK CMap set is compiled in (behind the
predefined-cmaps feature, on by default in the CLI and the Python wheel), and when
/ToUnicode is absent CIDs map to Unicode through the font's character collection.
Glyph names resolve through the full Adobe Glyph List plus the TeX symbol-font names
the list lacks, so ligatures (fi, fl, …), small-caps variants and math symbols
(∀, ∃, ↦, ′) decode to their proper Unicode text.
PDF to Markdown
Markdown output runs layout analysis over the same positioned spans that plain text extraction flattens, and renders the result as CommonMark. The analysis infers:
- Headings: ATX headings (
#…######), ranked by font size. With whole-document extraction the sizes are ranked against every page at once, so a title page or a chapter opener (all of it larger than body text) is read as headings rather than as its own idea of body size. Page-local extraction ranks against that page alone; a page whose text is all one size has no heading to find. Prefer document-wide extraction whenever the document is at hand. - Lists: bulleted and numbered. Bullets render as
-regardless of the source glyph; numbered items keep their detected number asn.. - Tables: detected both from column gaps and from drawn borders, so bordered grids and boxed lists without column gaps are found too. When a table's structure is drawn as ruled lines, those borders decide the grid ahead of column occupancy. Tables render as pipe tables while every cell stands in one column, and as HTML tables as soon as a cell spans several.
- Two-column pages: read column-major: the left column top to bottom, then the
right. The
reading_orderkeyword (CLI--reading-order) selects content order, the structure tree of a tagged PDF, or geometric position; see Reading order. - Tagged PDFs: under structure-tree reading order the tags decide the
blocks on every page the tree reaches (ISO 32000-1 14.8.4.3): H1 to H6 are
headings at that level, H a heading as deep as its Part, Art and Sect
elements nest, L a list with one item per LI and the Lbl text as the
marker (read as a number, a letter or a Roman numeral when the list's
ListNumberingattribute names that numbering system, an unlabelled item continuing the count), Table a table with a row per TR and a cell per TH or TD spanning what itsColSpanandRowSpanattributes say, and every other block-level element a paragraph; a Caption or a TOC entry is a paragraph of its own, the text of Figures, Formulas and Forms is laid out by the heuristics below apart from the text around it, and the paragraphs of a BlockQuote render as a block quote. Content the tree does not reach still goes through the heuristics below. - Page headers, footers and page numbers: a page's first or last line, repeated near-verbatim at the same height on at least half the pages (three at minimum), is tagged as a running page header or footer; a line that is nothing but a page number is tagged without any repetition required. Content the producer marked as a pagination artifact (ISO 32000-1 14.8.2.2: a running head, a folio, a watermark) is tagged on the producer's word, with no repetition needed. Tagged lines are dropped from the Markdown output.
CLI
pdfboss md report.pdf
--page extracts one page, 1-based; heading sizes are then ranked per page rather
than across the document:
pdfboss md --page 4 report.pdf
Warnings for skipped content appear on stderr, exactly as for pdfboss text. See
lenient semantics.
Before and after
Page 4 of a physics report carries a cut-flow table. Plain text extraction flattens it into space-separated lines:
Table 4: Muon RD Branch no. 1
Cuts km2loose acc km2tight acc bipulkm2acc acc
BADRUN 5189813 (-) 5189813 (-) - (-)
ictime − cktbm 3324048 (-) 3324048 (-) - (-)
icbit 3322245 (-) 3322245 (-) - (-)
pdfboss md --page 4 report.pdf recovers the grid (first rows shown):
Table 4: Muon RD Branch no. 1
| Cuts | km2loose acc | km2tight acc | bipulkm2acc acc |
| --- | --- | --- | --- |
| BADRUN | 5189813 (-) | 5189813 (-) | - (-) |
| ictime − cktbm | 3324048 (-) | 3324048 (-) | - (-) |
| icbit | 3322245 (-) | 3322245 (-) | - (-) |
Python
from pdfboss import Document
doc = Document("report.pdf")
markdown = doc.extract_markdown() # whole document, headings ranked globally
page_md = doc[3].extract_markdown() # one page, headings ranked per page
Document.extract_markdown extracts the pages in parallel and releases the GIL,
like extract_text.
Layout blocks
layout returns the structure the Markdown is rendered from instead of the
text: per page, a list of LayoutBlock objects in reading order, each with its
kind ("heading", "paragraph", "list", "table"), its bbox in PDF user
space (y-up, like Span.bbox), its plain text, a heading's level, a
paragraph's role ("body", "quote", "page_header", "page_footer"), a
list's items (each with its own marker, text and bbox) and a table's
rows of cells.
for page in doc.layout(): # every page, headings ranked globally
for block in page:
print(block.kind, block.bbox, block.text[:40])
blocks = doc[3].layout() # one page, headings ranked per page
first_two = doc.layout(pages=[0, 1]) # document-wide ranking, two pages returned
The thresholds that cut the blocks are keyword arguments, defaulting to the
values extract_markdown uses. Callers that need block boxes cut differently
tune them per call:
blocks = doc.layout(
paragraph_gap=1.6, # break paragraphs at 1.6 line pitches
paragraph_pitch="lower_half_median", # pitch from the tighter half of the steps
header_footer_min_pages=2,
)
Some producers draw a line twice at the same position, for example over a fresh
white box or offset by a fraction of a point to fake bold. Text, Markdown and
layout() read such a line once: before layout, a span that repeats an earlier
span on the page (same text, size and rotation, origin within redraw_tolerance
of the size, default 0.1, and starting in the first half of that span) is
dropped, so the copy counts neither in the line nor in the heading size ranking.
redraw_tolerance=0 keeps every copy. spans() always reports every draw.
The stubs list every keyword with its default and meaning. AsyncDocument.layout
and AsyncPage.layout are the coroutine twins. In Rust the same blocks come from
extract_layout_opts and extract_page_layout_opts, the thresholds from
LayoutOptions.
Rust
Whole document, with pdfboss_output::extract_markdown:
use pdfboss_core::Document;
use pdfboss_output::ReadingOrder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let markdown = pdfboss_output::extract_markdown(&doc, ReadingOrder::Content)?;
println!("{markdown}");
Ok(())
}
One page, with extract_page_markdown:
use pdfboss_core::Document;
use pdfboss_output::ReadingOrder;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let page = doc.page(0)?;
let markdown = pdfboss_output::extract_page_markdown(&doc, &page, ReadingOrder::Content)?;
println!("{markdown}");
Ok(())
}
extract_markdown_reporting returns the Markdown together with one
ExtractReport per page, in page order (the same accountability contract as text
extraction): unreadable content costs its own text and nothing else, and the report
says what was left out. See
lenient semantics for the report's
shape. Asynchronous callers compose extract_page_markdown_with against any object
source. See Async and remote documents.
Emphasis survives into the output: bold and italic runs render as **bold** and
*italic* inside paragraphs and list items, and under structure-tree reading
order text tagged as a Code element renders as inline code between backticks.
Headings drop emphasis markers: a heading is already the strongest thing on the
page. Blocks are separated by a blank
line, across page boundaries too, so the document reads as one continuous Markdown
file. For the raw style information itself, see Styled spans; for the
reverse direction (Markdown composed into a PDF), see
Markdown to PDF.
Styled spans
Text extraction gives you a page as flowing plain text. Spans are the layer underneath: each span is one positioned run of text together with everything the file states about how it is shown (position, size, font, weight, color, visibility). Use spans when you need to know not just what a page says but where and in what style: finding headings, separating an OCR layer from printed text, or feeding a layout analysis of your own.
What a span carries
| Property | Meaning |
|---|---|
text | The decoded text. |
x, y | Device-space origin and baseline of the span. |
end_x, end_y | Device-space point after the last glyph's advance; end_y equals y for upright text. |
rotation | The page rotation (0, 90, 180 or 270, clockwise) under which the span reads upright: text running bottom to top is 90. Rust names the field rotate, like Page::rotate. |
glyph_x | Device-space x where each character's glyph starts, one entry per character of text; a code that decodes to several characters repeats its glyph's x. Empty when the text has no character at U+0300 or above. |
size | Effective font size. |
font | Font resource name (e.g. "F1"). |
font_name | The font's /BaseFont name verbatim, subset prefix included (e.g. "NZEVTB+Arial-BoldItalicMT"); empty when the file names the font nowhere. |
page | 0-based index of the page the span came from. |
bbox | Device-space box (x0, y0, x1, y1), y-up: origin to advance horizontally, the font's descent..ascent vertically; for text turned a quarter, the box around the turned run. |
ascent, descent | The box's offsets from the baseline in device units: y + ascent is the box top, y + descent (zero or negative) its bottom. See Box and baseline. |
bold, italic | From FontDescriptor evidence, falling back to the /BaseFont name. |
monospace, serif | FontDescriptor /Flags FixedPitch and Serif. |
font_family, font_stretch, font_weight | FontDescriptor /FontFamily (empty when absent), /FontStretch as "ultra-condensed" to "ultra-expanded" (None when absent) and /FontWeight as written (None when absent): the font characteristics ISO 32000-1 Table 122 keeps for tagged PDF. bold is the reading of the weight with the flags, stem width and name as further evidence. |
underline, strikethrough | A drawn ruling below the baseline / across the x-height band, or a text markup annotation over the span. See Decorations. |
highlight, highlight_color | A marker-style filled rectangle behind the span, or a /Highlight annotation over it, and its RGB color. See Decorations. |
rise | The text rise (Ts) the span was shown under: positive above the baseline, a superscript/subscript signal. |
vertical | Writing mode 1: the text advances downward. |
invisible | Shown under render mode 3 or 7, which paint nothing. |
color | Fill color as RGB in [0, 1]; None for pattern fills. |
Two of these deserve a note up front:
invisibleis the signature of an OCR text layer. Scanned PDFs with a text layer draw the page image and then show the recognized text under render mode 3 or 7, which paint nothing. The text extracts normally: it is just never painted.colorisNonefor pattern fills, which have no single color.
Box and baseline
y is the baseline. The box is the em box the font declares, placed on
it: bbox[1] == y + descent and bbox[3] == y + ascent, where ascent is
the FontDescriptor's /Ascent (else the /FontBBox top, else /CapHeight,
else 800 per mille) and descent its /Descent (else the /FontBBox
bottom, else -200 per mille), both scaled by the effective size. descent
is zero or negative, the way /Descent is. The box is a font metric rather
than glyph ink: a span of digits does not reach the descent and a span of
capitals does not reach the ascent. A font whose descriptor states zero
metrics, as the math extension fonts of tectonic documents do, takes its
/FontBBox extent, so a display integral hanging two em below the baseline
sits inside its span box, which then stands well clear of the ink of the
smaller glyphs in that font. Horizontally the box runs from the origin to
the advance after the last glyph.
Vertical writing (vertical) takes the advance as its vertical extent, so
ascent and descent are the advance's extents above and below the origin,
and the box spans half the size to each side of the baseline.
The decoration flags below are judged in this frame: a ruling is an underline or a strikethrough by where it crosses the box, and a highlight band by how much of the box it covers.
Decorations
PDF has no underline, strikethrough or highlight attribute in its text state. The flags come from two places:
- What the page draws. A horizontal ruling (a stroked line or a thin
filled bar) is an underline when it sits between 0.3 of the size below the
baseline (or a tenth of the size under the box bottom, for a font whose
descent reaches deeper) and 0.05 of the size above it, and a strikethrough
when it crosses the x-height band, 0.15 to 0.6 of the size above the
baseline. A filled rectangle about a line tall is a highlight when it is
painted before the text, has a hue (white and gray bands are backgrounds,
knockouts and cell fills), is lighter than the text's color, and covers at
least half the box;
highlight_coloris its fill color, read the waycoloris. In every case the decoration must cover at least 60% of the span's width and stop within an em of the text it covers on both ends. That last rule is what separates decorations from table borders and paragraph shading, which run to the cell edge or the margin rather than to the text; bands that tile one shaded area (same color, same extent, one above the other) are judged together, so a shaded row whose text happens to fill it does not count when its neighbours overhang theirs. A cell border that ends where the cell's text ends, or shading whose every line is filled with text, still reads as an underline or a highlight. Coverage is judged per span: a mark over one word of a span that is a whole line covers too little of it to count. - Text markup annotations.
/Highlight,/Underline,/Squigglyand/StrikeOutannotations (ISO 32000-1 §12.5.6.10) set the flags on every span their/QuadPointsquadrilaterals cover by 60% of the width and half the box height; a squiggly underline counts as an underline, andhighlight_coloris the annotation's/C(orNonewithout one). Annotations flagged Hidden or NoView, or hidden by their/OCentry, mark nothing. Authored markup is taken as such: no color, paint order or extent test applies to it.
Vertical writing is left unmarked: its decorations are vertical lines beside the text, indistinguishable from column rules.
Python
Page.spans() returns the page's spans in emission order. It releases the GIL
while it runs and is lenient the same way text extraction is: unreadable
content yields no spans rather than raising.
import pdfboss
doc = pdfboss.Document("report.pdf")
for span in doc[0].spans():
if not span.bold:
continue
print(f"{span.size:5.1f}pt {span.font_name:30s} {span.text!r}")
Document.spans() iterates the whole document lazily, page by page: it
buffers one page's spans at a time, extracts each page with the GIL released,
and shares one font cache across the walk, so a font used on every page loads
once. Pass pages=[...] (0-based) to restrict the walk, in the order given.
Finding headings (bold text larger than the document's body size) is a document-level walk:
from collections import Counter
import pdfboss
doc = pdfboss.Document("report.pdf")
sizes: Counter[int] = Counter()
bold = []
for span in doc.spans():
sizes[round(span.size)] += len(span.text)
if span.bold:
bold.append(span)
body = sizes.most_common(1)[0][0]
for span in bold:
if span.size <= body:
continue
print(f"page {span.page + 1}: {span.size:.0f}pt {span.text}")
Detecting an OCR layer is a one-liner over invisible:
spans = doc[0].spans()
ocr = [span for span in spans if span.invisible]
print(f"{len(ocr)} of {len(spans)} spans are invisible (an OCR text layer)")
Both have async twins, await page.spans() and async for span in doc.spans(), described in Async and remote documents.
Rust
pdfboss_text::extract_spans returns a Vec<TextSpan> carrying the same
fields as the Python Span (as plain struct fields: text, x, y,
end_x, end_y, rotate, size, font, font_name, page, bbox, ascent, descent,
bold, italic, monospace, serif, font_family, font_stretch,
font_weight, rise, vertical, invisible,
color, underline, strikethrough, highlight, highlight_color):
use pdfboss_core::Document;
use pdfboss_text::extract_spans;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
for index in 0..doc.page_count() {
let page = doc.page(index)?;
for span in extract_spans(&doc, &page)? {
if !span.bold || span.size <= 12.0 {
continue;
}
println!("page {}: {:.0}pt {}", span.page + 1, span.size, span.text);
}
}
Ok(())
}
The crate also offers extract_spans_reporting (an ExtractReport naming
each stream that could not be read: an empty span list with an empty report
really is an empty page), extract_spans_reporting_cached (one FontCache
shared across a whole-document walk, the same trick Document.spans() uses),
and extract_spans_and_rulings_reporting, which additionally returns the
page's Ruling segments, the drawn lines the underline and strikethrough
flags are derived from.
Spans are the input to the layout analysis behind Markdown output; reach for that chapter when you want headings, lists and tables inferred for you rather than deriving them from spans yourself.
Rendering PDF pages to PNG
pdfboss rasterizes pages to RGBA pixels with anti-aliasing and encodes them as
PNG, PPM, BMP or JPEG. The scale factor is the points-to-pixels ratio: at 1.0 one PDF point
becomes one pixel, so a US Letter page renders to 612 × 792 pixels; at 2.0
to 1224 × 1584. The pixel size is ceil(crop_w * scale) × ceil(crop_h * scale) after page rotation, on a white background.
This chapter is about rasterizing whole pages. To pull out the images a page embeds, at their native resolution, see Extracting images.
CLI
pdfboss render report.pdf --page 1
pdfboss render report.pdf --page 1 --scale 2 -o page-1@2x.png
pdfboss render report.pdf --page 1 --scale 2 -o page-1.ppm
The first form writes page-1.png (--page is 1-based). Further flags:
-o <OUT>: the output file; its extension picks the format,.png,.ppm,.bmpor.jpg(see Output formats). Any other extension is an error.--fonts <FONTS>: which fonts to paint, one ofembedded-only,all-embeddedorfull(see the tiers below). The default resolves tofullwhen substitute faces are available (the compiled-in OFL set or--font-dir), otherwiseall-embedded.--font-dir <FONT_DIR>: a directory of substitute faces for--fonts full(e.g. an installedpdfboss-fontspackage), named as listed under Substitute face files. Overrides the compiled-in OFL set.--png-compression <PNG_COMPRESSION>:none,fast,defaultorbest; PNG only.--jpeg-quality <1-100>: the JPEG quality (default 90); JPEG only.--password <PASSWORD>: for encrypted files, covered in Encrypted documents.
Anything the render dropped is warned on stderr.
Font tiers
Glyph painting is staged in tiers; each tier is a strict superset of the previous one.
embedded-onlypaints only embedded TrueType outlines: the fastest tier, and TrueType only, not every embedded font.all-embedded(the default when no substitute faces are available) paints every embedded font program: TrueType, CFF, Type1 and Type3.full(the default whenever substitute faces are available) additionally substitutes a replacement face for non-embedded simple fonts: from a directory you supply (--font-dir,font_dir=), or from the compiled-in OFL Croscore set (Arimo/Tinos/Cousine, metric-compatible with Helvetica/Times/Courier). In Python,pip install pdfboss[full]installs those faces as thepdfboss-fontspackage; with neitherfont_dirnor that package available,fonts="full"raisesValueError.
Text a tier leaves unpainted still advances (through the PDF's own /Widths,
or the Adobe Core-14 AFM tables for a standard-14 face), so everything painted
around it stays where the page put it. /Symbol and /ZapfDingbats have no
license-clean substitute and stay blank; see
Limitations.
Substitute face files
A substitute directory (--font-dir, font_dir=, SubstituteSource::Dir)
holds one file per face, looked up by these exact names:
| Family | Files |
|---|---|
| Sans (Arimo) | Arimo[wght].ttf, Arimo-Italic[wght].ttf (variable files, rendered at their default instance), Arimo-Bold.ttf, Arimo-BoldItalic.ttf (static bold instances) |
| Serif (Tinos) | Tinos-Regular.ttf, Tinos-Bold.ttf, Tinos-Italic.ttf, Tinos-BoldItalic.ttf |
| Mono (Cousine) | Cousine-Regular.ttf, Cousine-Bold.ttf, Cousine-Italic.ttf, Cousine-BoldItalic.ttf |
A file that is missing or unreadable means no substitution for the faces that map to it; nothing else in the directory is read.
Leniency and reporting
Rendering never fails because one construct in a page would not read: content
pdfboss cannot fetch, decode or parse is skipped and the rest of the page
still rasterizes. The honest consequence is that a page can come back blank
without an error. The reporting variants return what was dropped or
approximated, one line per distinct loss, for example
"153 glyphs skipped: no glyph for code 9 in /MBIPWP+Times-Roman" or
"6 annotations skipped: the resource is missing". An empty report means the
page rasterized exactly as it describes itself.
Two things are deliberately not reported, because they are configured behavior
rather than a failure: text left unpainted by the requested font tier, and
content in optional-content layers (PDF layers) the document turns off. The
latter is counted separately, on the report's hidden counter in Rust.
The layers that are off are the ones a viewer hides on screen: the default
configuration's /BaseState, /ON and /OFF, then the usage application
dictionaries for the View event (a layer whose usage says /ViewState /OFF,
such as a print-only notice, does not paint), and a group whose /Intent does
not match the configuration's never hides anything. That is the state
Document::oc_state() builds and pdfium and pdf.js render by default.
Document::oc_state_for(Some(OcEvent::Print)) builds the printed state and
oc_state_for(None) the default configuration alone, without any usage
application, which ISO 32000-1 prescribes for printing and aggregating
applications; pass either through RenderOptions::oc.
Annotations and transparency groups
Annotations paint after the page content. An annotation with an appearance
stream (/AP /N, with /AS choosing the state) runs that stream fitted to
its /Rect. Square, Circle, Line, Polygon, PolyLine, Ink and text markup
annotations without one get an appearance built from their own entries
(colour, border width and dash, interior colour, opacity), and a Link with
/Border or /BS gets its border. The Hidden, NoView, Invisible and
NoRotate flags are honoured. A widget without an appearance stream paints
nothing: field values are read (see Reading forms, bookmarks and
attachments) but never turned into pixels.
A form XObject with /Group /S /Transparency is composited as one object:
its content runs from constant alpha 1, blend mode Normal and no soft mask,
renders offscreen, and the result is composited once with the alpha, blend
mode and soft mask in force at its Do. A group with /K true paints as an
ordinary group and reports knockout group skipped: not supported yet.
Python
Page.render returns PNG bytes. scale must be positive and finite
(ValueError otherwise).
from pathlib import Path
import pdfboss
doc = pdfboss.Document("report.pdf")
page = doc[0]
png = page.render(scale=2.0)
Path("page-1.png").write_bytes(png)
render accepts fonts= ("embedded-only", "all-embedded", "full"),
font_dir=, compression= ("none", "fast", "default", "best"),
format= ("png", "ppm", "bmp", "jpeg", see
Output formats) and quality= (1 to 100, JPEG only),
and releases the GIL while it runs. fonts= defaults to None, which
resolves to "full" when font_dir= is given or the pdfboss-fonts
package is importable, and to "all-embedded" otherwise. Page.render_reporting renders the same
way and returns (png, warnings):
png, warnings = page.render_reporting()
for line in warnings:
print(line)
Document.render_pages renders many pages fanned out across the machine's
cores: every page by default, or the 0-based pages given, returned in the
order given.
pngs = doc.render_pages(scale=2.0)
first_two_reversed = doc.render_pages(pages=[1, 0])
The full signature is render_pages(pages=None, scale=1.0, fonts=None, font_dir=None, compression="default", format="png", quality=90); fonts,
font_dir, compression, format and quality mean the same as on
Page.render, applied to every page, and a fonts of None resolves the
same way. The stub file
_pdfboss.pyi
documents each parameter.
All three have async twins on AsyncPage/AsyncDocument, which also render
documents opened over HTTP. See
Async and remote documents.
Output formats
Every render entry point writes one of four formats, chosen by format=
in Python, by the -o extension on the CLI, and by
pdfboss_render::ImageFormat in Rust:
| format | pixels | what it costs |
|---|---|---|
png (default) | RGBA 8-bit, filtered and deflated | the compression level below |
ppm | binary P6: P6 <w> <h> 255\n then RGB rows, top-down | one packing pass |
bmp | 24-bit BGR, bottom-up rows padded to four bytes, 54-byte header | one packing pass |
jpeg (jpg) | baseline JFIF, 4:4:4, lossy at quality 1 to 100 (default 90) | a DCT per 8×8 block |
PPM and BMP drop the alpha channel; a rendered page is filled white, so alpha is 255 everywhere and nothing is lost. Reach for them when the pixels are consumed right away (a benchmark, an OCR or vision pipeline, a diff against another renderer) and the PNG encode would be wasted work: Pillow, numpy and ImageMagick read both directly.
JPEG is the one lossy choice: it trades exact pixels for smaller files on photographic and scanned pages, with the quality knob scaling the standard quantization tables (50 uses them as printed, 100 keeps every coefficient). On a mostly white text page PNG stays both smaller and exact, so prefer JPEG only where the content is continuous-tone. Chroma is not subsampled, so colored text keeps sharp edges. The encoder is pdfboss's own, written from the JPEG specification, and costs two to three times the default PNG encode per page.
ppm = page.render(scale=2.0, format="ppm")
bmp, warnings = page.render_reporting(scale=2.0, format="bmp")
jpg = page.render(scale=2.0, format="jpeg", quality=80)
PNG compression
The compression level trades encode time against file size; every level
produces the same pixels. none is fastest and largest, fast is very fast
with a decent ratio, default balances the two, and best produces the
smallest files, much slower. The level only touches the PNG encoder; even
none still filters rows and writes checksums, so a raw-pixel consumer is
better served by ppm or bmp. Pick it by whether you are writing
throwaway intermediates or archiving.
Rust
pdfboss_render::render_page(doc, page, scale) returns a Pixmap: width,
height, and data holding width * height * 4 RGBA bytes (straight alpha,
row-major from the top-left). Pixmap::save_png writes it to a file;
encode_png/encode_png_with return the bytes, the latter taking a
PngCompression (None, Fast, Balanced or Best; Balanced is the
level the other surfaces call default).
render_page_with_options adds RenderOptions, a struct of four public
fields:
glyph_paintingselects theGlyphPaintingtier:EmbeddedTrueTypeOnly,AllEmbeddedorFull.substitutessays where theFulltier's replacement faces come from.SubstituteSource::Builtinis the compiled-in OFL set, present only when the crate is built with thesubstitute-fontsCargo feature; without that feature,Builtindegrades to no substitution, soFullbehaves exactly likeAllEmbedded. The probepdfboss_render::builtin_fonts_available()reports whether the compiled-in set exists.SubstituteSource::Dir(path)reads faces from a directory named as in Substitute face files. The defaultSubstituteSource::Nonesubstitutes nothing, soFullbehaves likeAllEmbeddeduntil you opt in.oc: Option<Arc<OcState>>is the document's optional-content visibility. The synchronous entry points fill it from the document when it isNone; an asynchronous caller builds it itself, fromAsyncDocument::oc_state, and leaving itNonethere renders every layer. See Async and remote documents.cache: Option<Arc<RenderCache>>shares oneRenderCacheacross a whole-document walk. It retains loaded fonts and parsedICCBasedcolorspace outcomes across pages;Nonekeeps every load page-local.
render_page_reporting returns the
RenderReport alongside the pixels; report.summary() is a one-line count
per kind, report.warnings() one line per distinct drop.
use pdfboss_core::Document;
use pdfboss_render::{
render_page, render_page_reporting, GlyphPainting, PngCompression, RenderOptions,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let page = doc.page(0)?;
let pixmap = render_page(&doc, &page, 2.0)?;
pixmap.save_png("page-1.png")?;
let opts = RenderOptions {
glyph_painting: GlyphPainting::EmbeddedTrueTypeOnly,
..RenderOptions::default()
};
let (pixmap, report) = render_page_reporting(&doc, &page, 1.0, &opts)?;
if let Some(summary) = report.summary() {
eprintln!("{summary}");
}
for warning in report.warnings() {
eprintln!("{warning}");
}
let png = pixmap.encode_png_with(PngCompression::Best)?;
std::fs::write("page-1-small.png", png)?;
Ok(())
}
For a whole document, pass one RenderCache to every page, so each font
program and each ICCBased profile loads once per document rather than once
per page:
use std::sync::Arc;
use pdfboss_core::{map_pages, Document};
use pdfboss_render::{render_page_with_options, RenderCache, RenderOptions};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let opts = RenderOptions {
cache: Some(Arc::new(RenderCache::default())),
..RenderOptions::default()
};
let outcomes = map_pages(&doc, |doc, page| {
render_page_with_options(doc, page, 2.0, &opts)
});
for (index, pixmap) in outcomes.into_iter().enumerate() {
pixmap?.save_png(format!("page-{}.png", index + 1))?;
}
Ok(())
}
Extracting images from PDFs
pdfboss extracts the images a page draws (photographs, scans, logos, figures),
each decoded at its own pixel dimensions and delivered as RGBA with alpha.
Three surfaces expose it: the pdfboss images CLI command,
Page.extract_images in Python, and pdfboss_render::extract_page_images in
Rust. To rasterize a whole page to a single PNG instead, see
Rendering pages.
What gets extracted
Extraction walks the page's content stream and collects an image at every point where one is drawn:
- Occurrence-based. The result reflects what the page draws, not what its resources contain. An image drawn twice appears twice; an image XObject listed in the resources but never drawn does not appear at all.
- Drawing order. Images come back in the order the content draws them.
- Form XObjects are followed. An image drawn inside a form (a reused header graphic, an overlaid figure) is reached through the form, to the same bounded nesting depth the renderer uses, so a form that draws itself terminates instead of recursing forever.
- Inline images are included.
BI … ID … EIsequences embedded directly in the content stream extract like any image XObject. - Stencil masks are skipped. An image with
/ImageMask truepaints the current fill color through a 1-bit stencil; it carries no pixels of its own, so there is nothing to extract. /SMaskbecomes the alpha channel. An image's soft mask is merged into the output as straight (non-premultiplied) alpha.- Native size. Each image decodes at its own
/Width×/Height, never at render resolution or at the size it occupies on the page. A 3000 × 2000 photo scaled into a thumbnail box still comes out at 3000 × 2000. - Optional content is not consulted. An image inside a hidden
/OCgroup is still embedded in the file, so it still extracts. - Lenient. Content that cannot be read or decoded contributes nothing rather than failing the call, matching how rendering skips what it cannot draw.
Filtering small images
There is no hidden minimum-size filter: every drawn image comes back, including one-pixel spacers and thin decorative strips. Each result carries its native width and height, so callers apply their own threshold: the synchronous Python and Rust examples below skip anything under 100 × 100 pixels.
Where an image is drawn
extract_images answers what a page draws. Page.images answers where,
without decoding a pixel: one PlacedImage per draw, in the same drawing
order, each carrying the device-space box the image occupies. It comes from
the same content-stream walk that produces styled spans, so a
span's bbox and an image's bbox share one frame and can be compared
directly. Use it to tell a scanned page from a born-digital one, to find the
figure regions of a mixed page, or to measure how much of a page is picture
before deciding whether a vision model needs to see it.
import pdfboss
doc = pdfboss.Document("report.pdf")
for number, page in enumerate(doc, start=1):
mx0, my0, mx1, my1 = page.media_box
area = (mx1 - mx0) * (my1 - my0)
covered = 0.0
for image in page.images():
x0, y0, x1, y1 = image.bbox
x0, y0 = max(x0, mx0), max(y0, my0)
x1, y1 = min(x1, mx1), min(y1, my1)
covered += max(x1 - x0, 0.0) * max(y1 - y0, 0.0)
print(f"page {number}: {covered / area:.0%} covered by images")
What a PlacedImage carries:
| Property | Meaning |
|---|---|
page | 0-based index of the page the image is drawn on. |
bbox | Device-space box (x0, y0, x1, y1), y-up and normalized: the box around the image's four corners under the CTM. |
width, height | Native pixel size from /Width and /Height; 0 when the dictionary states none. |
stencil | /ImageMask true: a 1-bit stencil painting the fill color. |
inline | Drawn by a BI … ID … EI sequence rather than a Do. |
The box is image space's unit square mapped through the CTM in force at the
Do (or BI), including every enclosing form's /Matrix, so a rotated or
skewed image reports the axis-aligned box around its outline. It is in the
same space as media_box: unrotated PDF user space, before any /Rotate,
which is why the example measures against media_box and not against
width and height (those are after rotation). It is not clipped: an image
the content places partly off the page reports the box the content gave it,
and intersecting with media_box or crop_box is the caller's decision, as
the example does.
Placement differs from extraction on two points, both deliberate:
- Stencil masks are placed. A 1-bit fax scan is often an
/ImageMaskstencil, and for the question "how much of this page is picture" it is the page.extract_imagesskips stencils because they carry no pixels of their own, so when a page draws one the two lists do not align index for index; thestencilflag says which entriesextract_imagesleft out. - Hidden optional content is excluded. Like text, an image inside a
BDC /OCspan the document turns off on screen (the default configuration with the View usage application applied), or an XObject with a hidden/OCentry, is drawn nowhere and placed nowhere.extract_imagesembeds it anyway, because the bytes are in the file.
Otherwise the two agree: occurrence-based, drawing order, form XObjects
followed to the same bounded depth, inline images included, lenient about
content that will not read. AsyncPage.images is the async twin.
In Rust the same walk is pdfboss_text::placed_images (and
placed_images_with over any AsyncObjectSource), returning
Vec<PlacedImage> with the fields above and bbox as a Rect.
CLI
pdfboss images writes every image the selected pages draw as a PNG named
page-N-image-M.png, with both numbers 1-based and M counting in drawing
order within the page:
mkdir images
pdfboss images --page 6 -o images report.pdf
wrote images/page-6-image-1.png (137 x 178 px)
wrote images/page-6-image-2.png (750 x 989 px)
wrote images/page-6-image-3.png (132 x 177 px)
wrote images/page-6-image-4.png (216 x 295 px)
wrote images/page-6-image-5.png (144 x 168 px)
wrote images/page-6-image-6.png (50 x 120 px)
wrote images/page-6-image-7.png (165 x 211 px)
wrote images/page-6-image-8.png (145 x 188 px)
wrote images/page-6-image-9.png (165 x 206 px)
extracted 9 images
Without --page every page is processed. -o names the output directory
(default: the current directory); it must already exist. --png-compression
trades encode time against file size: none, fast, default or best,
all producing the same pixels. A page whose images cannot be decoded writes
nothing for them and still exits 0.
Python
Page.extract_images returns a list of PageImage objects, each holding the
PNG-encoded pixels as data plus the native width and height. Saving
every sufficiently large image in a document:
from pathlib import Path
import pdfboss
doc = pdfboss.Document("report.pdf")
out = Path("images")
out.mkdir(exist_ok=True)
for number, page in enumerate(doc, start=1):
for i, image in enumerate(page.extract_images(), start=1):
if image.width < 100 or image.height < 100:
continue
target = out / f"page-{number}-image-{i}.png"
target.write_bytes(image.data)
print(f"{target}: {image.width} x {image.height}")
The drawing-order index i is kept even for skipped images, so file names
stay aligned with what pdfboss images would produce. extract_images
accepts the same compression argument as render ("none", "fast",
"default", "best").
AsyncPage.extract_images is the async twin, with the same drawing-order,
native-size and leniency semantics (see
Async and remote documents):
import asyncio
import pdfboss
async def main() -> None:
doc = await pdfboss.AsyncDocument.open("report.pdf")
page = doc.page(0)
images = await page.extract_images()
print(f"page 1 draws {len(images)} images")
asyncio.run(main())
Rust
pdfboss_render::extract_page_images returns Vec<Pixmap>: RGBA8 pixels
with straight alpha, row-major from the top-left, with public width,
height and data fields. Pixmap::save_png writes one to disk;
encode_png/encode_png_with produce the bytes in memory. With
pdfboss-core and pdfboss-render as dependencies:
use pdfboss_core::Document;
use pdfboss_render::extract_page_images;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let page = doc.page(0)?;
let images = extract_page_images(&doc, &page)?;
for (i, image) in images.iter().enumerate() {
if image.width < 100 || image.height < 100 {
continue;
}
image.save_png(format!("image-{}.png", i + 1))?;
println!("image-{}.png: {} x {}", i + 1, image.width, image.height);
}
Ok(())
}
extract_page_images_with is the same walk over any
pdfboss_core::AsyncObjectSource. pdfboss_aio::AsyncDocument implements
that trait and is an Arc handle, so cloning one to pass by value is cheap
(with pdfboss-aio, pdfboss-render and tokio as dependencies):
use pdfboss_aio::AsyncDocument;
use pdfboss_render::extract_page_images_with;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open("report.pdf").await?;
for index in 0..doc.page_count() {
let page = doc.page(index)?;
let images = extract_page_images_with(doc.clone(), &page).await?;
for (i, image) in images.iter().enumerate() {
image.save_png(format!("page-{}-image-{}.png", index + 1, i + 1))?;
}
}
Ok(())
}
The async form works identically over a remote document opened with
AsyncDocument::open_url, fetching only the byte ranges the images need.
Full option listings live in the CLI reference.
Reading forms, bookmarks and attachments
A PDF carries more than its pages: an interactive form with its field values, a bookmark tree, page labels such as iv or A-3, named destinations, embedded files and viewer preferences. pdfboss reads all of them from the catalog as plain data, from Python, Rust and the async surfaces. Nothing here writes: form fields are read, not filled, and signatures are read as dictionaries, not verified. The full class list is in the Python reference.
Python
import pdfboss
doc = pdfboss.Document("form.pdf")
for field in doc.form_fields():
if field.field_type == "text":
print(field.name, field.text)
elif field.button_kind == "check-box":
print(field.name, field.checked)
elif field.field_type == "choice":
print(field.name, field.selected)
for item in doc.outline():
print(item.title, item.page, len(item.children))
labels = doc.page_labels()
print(doc.page_label(0))
for attachment in doc.embedded_files():
with open(attachment.name, "wb") as out:
out.write(doc.embedded_file_data(attachment))
prefs = doc.viewer_preferences()
if prefs:
print(prefs.display_doc_title, prefs.direction)
form_fields() returns every terminal field with the entries it inherits from its parents filled in: name is the fully qualified name, flags a FieldFlags with one boolean per flag, value the field's value as plain Python data, and widgets the widget annotations that draw it. The typed readers text, checked, selected, state and signature return None when the field is of another type. interactive_form() returns the form dictionary itself (need_appearances, signatures_exist, calculation_order, xfa) or None when the document has no form.
outline() returns the bookmark tree, each item with its title, the 0-based page it points to when the destination resolves through the page tree, its destination (fit mode and coordinates), open, color, bold and italic, and nested children. named_destinations() maps each name to a Destination. page_labels() returns the numbering ranges, and page_label(index) formats one page's label. embedded_files() lists attachments with their names, MIME type, size and dates; embedded_file_data(file) decodes one. Every reader has an AsyncDocument coroutine of the same name.
Four more readers cover the catalog and page structures a viewer consults before showing anything. requirements() lists the features the document needs a reader to support, each with the handlers a reader that lacks the feature would run; pdfboss reports a JavaScript handler by name and never runs it. legal_attestation() returns the counts of content a certifying signature cannot vouch for, one integer attribute per count and counts() for the whole table, with the signer's attestation. On a page, viewports() returns the measurement viewports, each a bbox with a measure whose number-format chains (distance, area, angle and the rest) say how user-space lengths convert to real-world units, and separation_info() returns a pre-separated page's device_colorant with the pages of its separation set as 0-based indices. Both page readers have AsyncPage coroutine twins.
for viewport in doc[0].viewports():
if viewport.measure:
print(viewport.name, viewport.measure.scale_ratio)
separation = doc[0].separation_info()
if separation:
print(separation.device_colorant, separation.pages)
for requirement in doc.requirements():
print(requirement.kind, [handler.kind for handler in requirement.handlers])
legal = doc.legal_attestation()
if legal:
print(legal.counts()["non_embedded_fonts"], legal.attestation)
optional_content_groups(event) lists the document's optional content groups, the PDF layers, in the order the catalog declares them. Each has its name, its intent names, its usage dictionary (view, print, export states, print_subtype, zoom_min, zoom_max, language, page_element, creator and the user entries) and visible, the group's state under the event asked for. The default "view" is the state a viewer shows on screen and the one text extraction and rendering use: the default configuration's on and off lists, then the usage application dictionaries for the View event, while a group whose intent does not match the configuration's never hides content. "print" and "export" apply that event's dictionaries instead, and None applies none, which is the state the standard prescribes for printing and aggregating applications.
for layer in doc.optional_content_groups():
print(layer.name, layer.visible, layer.usage.print_subtype)
printed = {layer.name: layer.visible for layer in doc.optional_content_groups("print")}
Annotations, links and actions
annotations() on a page returns its annotation dictionaries in /Annots order, each read as data: the common entries of every annotation (subtype, rect, contents, name, modified, flags, border, color, struct_parent, optional_content, whether an appearance stream is present), the markup entries in markup (title, subject, opacity, in_reply_to, reply_type, intent, created, rich_contents, popup), and the entries of the subtypes pdfboss reads further: a link's destination (explicit or looked up by name, the page resolved to a 0-based index), a text reply's state and state_model, a text or pop-up annotation's open and icon, a pop-up's parent, and a file attachment's file. file_spec_data(spec) on the document decodes the stream a FileSpec embeds; a specification whose file system is URL exposes the address as url instead.
Every action is read and none is executed. action holds an annotation's /A with its /Next chain in next; additional_actions on an annotation, a page and the document hold the /AA trigger events ("cursor-enter", "mouse-up", "page-open", "open", "will-close" and the rest of the standard's tables) with the action each fires. An Action names its kind as the /S entry is written: GoTo carries a destination, GoToR and GoToE a file and a page-numbered destination or a named_destination in the other document (an embedded go-to adds its target path), Launch a file and windows parameters, URI the uri and is_map, Named the viewer action's name, JavaScript the script text; any other kind, SubmitForm for instance, keeps its whole dictionary in entries.
for annotation in doc[0].annotations():
action = annotation.action
if action and action.kind == "URI":
print("link to", action.uri, "at", annotation.rect)
elif annotation.destination:
print("link to page", annotation.destination.page)
if annotation.markup and annotation.markup.in_reply_to is None:
print(annotation.markup.title, "wrote:", annotation.contents)
if annotation.file and annotation.file.ref:
with open(annotation.file.name, "wb") as out:
out.write(doc.file_spec_data(annotation.file))
for triggered in doc.additional_actions():
print("document", triggered.trigger, triggered.action.kind)
Rust
use pdfboss_core::Document;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("form.pdf")?;
for field in doc.form_fields() {
println!("{} = {:?}", field.name, field.value);
}
for item in doc.outline() {
println!("{} ({} children)", item.title, item.children.len());
}
if let Some(labels) = doc.page_labels() {
println!("{} label ranges", labels.len());
}
for file in doc.embedded_files() {
std::fs::write(&file.name, doc.embedded_file_data(&file)?)?;
}
Ok(())
}
The same readers exist as free functions over any async object source, named with a _with suffix and taking the source and the trailer dictionary: pdfboss_core::{form_fields_with, interactive_form_with, outline_with, named_destinations_with, page_labels_with, embedded_files_with, embedded_file_data_with, file_spec_data_with, viewer_preferences_with, requirements_with, legal_attestation_with, viewports_with, separation_info_with, annotations_with, action_with, page_additional_actions_with, document_additional_actions_with, optional_content_groups_with}. pdfboss_aio::AsyncDocument exposes them as methods, and Document::{requirements, legal_attestation, viewports, separation_info, annotations, action, page_additional_actions, additional_actions, file_spec_data, optional_content_groups} are the sync forms of the last ten. The groups reader takes an Option<OcEvent>, the same value Document::oc_state_for builds a visibility state from.
CLI
The command line has no dedicated command for these structures, but pdfboss q reaches the raw catalog dictionary through the object tree: the trailer's Root is a reference, objects are keyed by number, and a reference prints as {"_r": [number, generation]}.
pdfboss q form.pdf '.trailer.value.Root' # {"_r": [131, 0]}
pdfboss q form.pdf '.objects["131"].value | keys' # ["AcroForm", "Names", "Pages", ...]
pdfboss q form.pdf '.objects["131"].value.AcroForm' # {"_r": [132, 0]}
See Exploring PDF internals for the tree and the query language.
Creating PDFs
The write side of pdfboss is the pdfboss-write Rust crate, the pdfboss.write Python module and the pdfboss create CLI. This chapter covers two altitudes: the canvas level, where you place every shape, glyph run and image yourself, and the element level one step above it, where Text, Paragraph, Image and Link values compose onto pages and document slots carry outlines, attachments, page labels and viewer preferences. Python reaches both: pdfboss.write composes elements and slots with |, and its draw protocol paints on the canvas directly. To compose a document from CommonMark+GFM source instead, from the CLI, Rust or Python (pdfboss.md.to_pdf), see Markdown to PDF. Everything the writer emits uses the same content-stream IR the reader parses, so a created file round-trips through the rest of the toolkit.
The document model
A document is plain data: Pdf { metadata, pages, outline, attachments, page_labels, viewer, options }. The fields are the composition: pages appear in the output in the order of the Vec, singleton slots are Options, sequences may stay empty, and Default fills everything optional. Each Page { size, rotation, canvas, content, links } carries operators painted directly on its canvas, composed elements in content (lowered onto the canvas at serialization time, after anything painted directly), and its clickable areas: a LinkAnnotation { rect, target } marks a rectangle in page user space, emitted as a /Link annotation under /Annots, whose LinkTarget is either Uri(String) (a /URI action) or Page(usize) (a /GoTo action with an explicit /XYZ null null null destination that keeps the viewer's current position and zoom).
use pdfboss_write::{Page, PageSize, Pdf, Standard14};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.canvas
.text("Hello from pdfboss", 72.0, 770.0, Standard14::Helvetica, 24.0)?;
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
pdf.save("hello.pdf")?;
Ok(())
}
Coordinates are PDF user space: the origin is the bottom-left corner of the page, y grows upward, and one unit is 1/72 inch (a point). PageSize offers A3 (841.89 × 1190.55), A4 (595.28 × 841.89, the default), A5 (419.53 × 595.28), Letter (612 × 792), Legal (612 × 1008) and Custom { width, height }; dimensions() returns the pair, and landscape() swaps it into a Custom. rotation is a clockwise view rotation in degrees.
Serialization validates rather than guesses. A document with zero pages is an error, and so are a rotation that is not a multiple of 90, a link, bookmark or open_to target page out of range, a duplicate attachment name, a page-label set without a range at page 0 or with a duplicate first_page or a start_at of 0 (numbering starts at 1), and a paragraph whose wrapped lines overflow its rect.
Canvas
Canvas is an imperative painter. Path construction (move_to, line_to, curve_to, close) is separate from painting (fill, fill_even_odd, stroke, close_stroke, fill_stroke, end_path), exactly as in PDF content streams. Convenience shapes append complete subpaths: rect, circle and ellipse (four Bézier arcs), and polygon over a slice of pdfboss_core::Point (fewer than three points appends nothing).
Graphics state follows the same operators: save/restore push and pop, transform concatenates a pdfboss_core::Matrix onto the CTM, and set_line_width, set_line_cap, set_line_join, set_miter_limit and set_dash control stroking. clip and clip_even_odd intersect the clip region with the current path and consume it. Colors are device colors (Color::Gray(g), Color::Rgb(r, g, b), Color::Cmyk(c, m, y, k), components in 0.0..=1.0, with Color::BLACK and Color::WHITE constants), set independently for fill (set_fill) and stroke (set_stroke). For anything the methods do not cover, op pushes a raw pdfboss_core::content::Op.
Canvases nest and carry transparency state. group(canvas, bbox) registers a finished sub-canvas as a Form XObject and returns a GroupHandle; draw_group(handle, matrix) paints it under a matrix, and two calls with the same handle reference one form resource. set_fill_alpha, set_stroke_alpha and set_blend_mode each emit a gs operator over a deduplicated single-key /ExtGState entry (/ca, /CA and /BM respectively); BlendMode covers the twelve separable modes. The resource naming contract, which op callers must keep consistent, is fixed: fonts are F1, F2, …, images Im1, …, groups Gp1, …, graphics states Gs1, …. Fonts are deduplicated document-wide, nested groups included, but a canvas registered as a group on two pages produces two Form XObjects; cross-page group sharing is deferred.
Text
canvas.text(text, x, y, font, size) shows one line with its baseline origin at (x, y). The faces are the fourteen standard fonts every conforming reader provides, as Standard14 variants: Helvetica, HelveticaBold, HelveticaOblique, HelveticaBoldOblique, TimesRoman, TimesBold, TimesItalic, TimesBoldItalic, Courier, CourierBold, CourierOblique, CourierBoldOblique, Symbol, ZapfDingbats. No font program is embedded: readers carry these faces.
The twelve text faces encode as WinAnsi. A character outside the encoding is an error, never silently dropped or replaced, and the error is raised before any operator is pushed, leaving the canvas untouched. Symbol and ZapfDingbats encode through their built-in encodings (ISO 32000-1 Annex D.5 and D.6): Greek letters and mathematical signs for Symbol, the dingbats for ZapfDingbats; a character outside those tables is an encoding error, and no metrics are bundled for the two faces, so text_width has nothing to say about them. The library does not wrap or lay out text: one call is one line; Standard14::text_width(text, size) returns a string's width from the AFM metrics (bare advance widths, no kerning) for callers doing their own layout.
Fonts are deduplicated document-wide: each distinct face gets one font object, in first-use order, no matter how many pages use it.
Images
ImageData imports or wraps pixels; add_image registers it on a canvas and draw_image(handle, x, y, width, height) paints it into an axis-aligned box:
ImageData::png(&bytes): decodes a PNG; truecolor and grayscale (16-bit reduced to 8), palette expanded, alpha split into a soft mask.ImageData::jpeg(&bytes): baseline or progressive JPEG by passthrough. The original bytes are embedded as/DCTDecode, dimensions sniffed from the SOF marker. Grayscale and three-component images only.ImageData::rgb8(w, h, data),gray8(w, h, data): 8-bit rasters,datalength checked against the dimensions.ImageData::mono(w, h, data): 1-bit rasters, rows packed MSB-first and byte-padded; a set bit is black.ImageData::decode(&bytes): dispatches on content rather than file extension. A PNG signature goes topng, a JPEG SOI marker tojpeg, anything else is an error.
Images are embedded per page with no cross-page deduplication: the same raster drawn on two pages is stored twice.
A complete page
One page with shapes, text in two faces, and a generated raster:
use pdfboss_core::Point;
use pdfboss_write::{Color, Date, ImageData, Metadata, Page, PageSize, Pdf, Standard14};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
let canvas = &mut page.canvas;
// A stroked frame with a dash pattern.
canvas.set_stroke(Color::Gray(0.3));
canvas.set_line_width(1.5);
canvas.set_dash(&[6.0, 3.0], 0.0);
canvas.rect(36.0, 36.0, 523.28, 769.89);
canvas.stroke();
canvas.set_dash(&[], 0.0);
// Filled shapes: a rectangle, a circle, a triangle.
canvas.set_fill(Color::Rgb(0.85, 0.2, 0.2));
canvas.rect(72.0, 600.0, 120.0, 80.0);
canvas.fill();
canvas.set_fill(Color::Rgb(0.2, 0.5, 0.85));
canvas.circle(280.0, 640.0, 45.0);
canvas.fill();
canvas.set_fill(Color::Cmyk(0.6, 0.0, 0.9, 0.1));
canvas.polygon(&[
Point::new(380.0, 600.0),
Point::new(500.0, 600.0),
Point::new(440.0, 690.0),
]);
canvas.fill();
// Text in two of the fourteen standard faces.
canvas.set_fill(Color::BLACK);
canvas.text("Quarterly report", 72.0, 540.0, Standard14::HelveticaBold, 28.0)?;
canvas.text(
"Generated with pdfboss-write.",
72.0,
510.0,
Standard14::TimesRoman,
12.0,
)?;
// A generated raster, embedded and drawn at 200 x 100 pt.
let mut pixels = Vec::with_capacity(64 * 32 * 3);
for y in 0..32u32 {
for x in 0..64u32 {
pixels.extend([(x * 4) as u8, (y * 8) as u8, 128]);
}
}
let gradient = ImageData::rgb8(64, 32, pixels)?;
let handle = canvas.add_image(gradient);
canvas.draw_image(handle, 72.0, 380.0, 200.0, 100.0);
let pdf = Pdf {
metadata: Some(Metadata {
title: Some("Quarterly report".into()),
author: Some("pdfboss".into()),
creation_date: Some(Date {
year: 2026,
month: 8,
day: 27,
hour: 12,
minute: 0,
second: 0,
utc_offset_minutes: 0,
}),
..Metadata::default()
}),
pages: vec![page],
..Pdf::default()
};
pdf.save("report.pdf")?;
Ok(())
}
To embed an existing file instead of a generated raster: ImageData::png(&std::fs::read("photo.png")?)?.
Composing pages
Page::content holds Content values, an element vocabulary one step above raw canvas operators: Text, Image, Link and Paragraph, each convertible with Content::from, plus Content::Custom(Box<dyn Draw>) via Content::custom for anything implementing Draw (fn draw(&self, canvas: &mut Canvas) -> Result<()>, plus Send). Elements lower onto the page's canvas at serialization time, in order, after any operators already painted there directly, so elements paint over manual canvas work, never under it. A Link element is the exception: it lowers into the page's links vector instead of painting.
Paragraph { text, rect, font, size, leading, align } wraps text into its rect. \n forces a line break, other whitespace runs between words collapse to one space, and a blank source line keeps its vertical advance. leading defaults to 1.2 * size; align is ParagraphAlign::Left, Center, Right or Justify, and justification stretches word spacing on every line except the last visible one. A paragraph that does not fit is an error naming how many lines fit and how many were needed, and an unencodable character errors exactly as in canvas.text.
Image { data, at, width, height } sizes itself from what is given: with both dimensions None it paints at the natural pixel size at 72 dpi, one given dimension scales the other by aspect, and both given paint the exact box.
use pdfboss_core::Point;
use pdfboss_write::{Content, Link, LinkTarget, Page, PageSize, Paragraph, Pdf, Standard14, Text};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.content.push(Content::from(Text {
value: "Q3 Report".into(),
at: Point::new(72.0, 770.0),
font: Standard14::HelveticaBold,
size: 28.0,
..Text::default()
}));
page.content.push(Content::from(Paragraph {
text: "Prepared for the board. Revenue, costs and outlook for the \
quarter, wrapped and aligned without manual line breaks."
.into(),
rect: [72.0, 600.0, 523.0, 740.0],
..Paragraph::default()
}));
page.content.push(Content::from(Link {
rect: [72.0, 60.0, 200.0, 80.0],
target: LinkTarget::Uri("https://example.com/q3".into()),
}));
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
pdf.save("q3.pdf")?;
Ok(())
}
Document slots
Beside pages, four Pdf fields carry document-level structure, each plain data and each optional.
Outline { bookmarks } is the viewer's bookmark panel: an ordered forest of Bookmark { title, page, children } nodes, Bookmark::new(title, page) building a leaf. Each bookmark targets a 0-based page index with an explicit /XYZ null null null destination, keeping the viewer's current position and zoom.
Attachment { name, data, mime, modified, description } embeds a file via the catalog's /Names /EmbeddedFiles name tree. name becomes the filespec's /F and /UF and the name-tree key; data is stored as the embedded-file stream, compressed per WriteOptions::compress; mime is the stream's /Subtype, defaulting to application/octet-stream; modified and description write /Params /ModDate and /Desc only when given. Attachments are reordered bytewise by name at emission, since the name tree's keys must be sorted, so the order given is not preserved; a duplicate name is an error.
page_labels holds PageLabel { first_page, style, prefix, start_at } ranges controlling how viewers display page numbers. A range takes effect from first_page (0-based) until the next range or the document's end. LabelStyle is Decimal, RomanUpper, RomanLower, LettersUpper or LettersLower, written as /S D, R, r, A or a; prefix prepends text to every number in the range, and /St is written only when start_at is not 1. Ranges are reordered by first_page, and a non-empty set must include a range starting at page 0.
Viewer { layout, mode, open_to } writes the catalog's opening preferences: /PageLayout from PageLayout (SinglePage, OneColumn, TwoColumnLeft, TwoColumnRight, TwoPageLeft, TwoPageRight), /PageMode from PageMode (UseNone, UseOutlines, UseThumbs, FullScreen), and /OpenAction opening the document at a page, keeping position and zoom.
use pdfboss_write::{
Attachment, Bookmark, LabelStyle, Outline, Page, PageLabel, PageMode, PageSize, Pdf,
Standard14, Viewer,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut cover = Page::new(PageSize::A4);
cover
.canvas
.text("Cover", 72.0, 770.0, Standard14::HelveticaBold, 24.0)?;
let mut body = Page::new(PageSize::A4);
body.canvas
.text("Body", 72.0, 770.0, Standard14::Helvetica, 12.0)?;
let pdf = Pdf {
pages: vec![cover, body],
outline: Some(Outline {
bookmarks: vec![Bookmark::new("Cover", 0), Bookmark::new("Body", 1)],
}),
attachments: vec![Attachment {
name: "raw-numbers.csv".into(),
data: b"a,b,c\n1,2,3\n".to_vec(),
mime: Some("text/csv".into()),
modified: None,
description: Some("Source data".into()),
}],
page_labels: vec![PageLabel {
first_page: 0,
style: Some(LabelStyle::RomanLower),
prefix: None,
start_at: 1,
}],
viewer: Some(Viewer {
mode: Some(PageMode::UseOutlines),
..Viewer::default()
}),
..Pdf::default()
};
pdf.save("book.pdf")?;
Ok(())
}
Python: pdfboss.write
pdfboss.write exposes the same vocabulary as frozen values joined with |. A Page(size="a4", landscape=False) composes Text, Image, Link and Paragraph elements — size is a name (a0 to a10, b0 to b10, letter, legal, tabloid), a "<width>x<height>" string in pt, mm, cm or in, or a (width, height) tuple of points; a Pdf() composes pages, Attachment and PageLabel values (each appended, in order) and the singleton Metadata, Outline and Viewer slots, where a second raises TypeError. Every | returns a new value and leaves the receiver unchanged, and copies are cheap handle clones: nothing is built until save(path) or to_bytes(), which lower the composition once and release the GIL to serialize. to_bytes may be called repeatedly.
from pdfboss.write import (
Bookmark,
Link,
Metadata,
Outline,
Page,
Paragraph,
Pdf,
Standard14,
Text,
)
cover = (
Page(size="a4")
| Text("Q3 Report", at=(72, 770), font=Standard14.HELVETICA_BOLD, size=28)
| Paragraph("Prepared for the board.", rect=(72, 700, 500, 740))
| Link(rect=(72, 60, 200, 80), url="https://example.com/q3")
)
pdf = Pdf() | Metadata(title="Q3 Report") | cover | Outline(Bookmark("Cover", 0))
pdf.save("q3.pdf")
Constructors mirror the Rust fields with Python spellings: Standard14 members are SCREAMING_SNAKE (Standard14.HELVETICA_BOLD), string enums are kebab-case (align="justify", style="roman-lower", layout="single-page", mode="use-outlines"), Text takes an optional (r, g, b) color tuple, Image takes a path string or raw bytes (read and decoded only at save/to_bytes time), Link takes exactly one of url or page, and Bookmark(title, page, children=(...)) nests by construction rather than |. The Python write surface stays clock-free, so Metadata and Attachment carry no date parameters. The full inventory is in the Python API reference.
The draw protocol
Any object with a callable draw attribute composes onto a Page like an element. During save/to_bytes the page's in-progress canvas is handed to draw(canvas) as a Canvas value with twelve methods: text, line, rect, move_to, line_to, curve_to, close, stroke, fill, set_fill, set_stroke and set_line_width. Painting lands in content order, exactly where the object sits in the | chain.
from pdfboss.write import Page, Pdf, Text
class Letterhead:
def draw(self, canvas):
canvas.line(72, 806, 523, 806, width=0.5)
canvas.text("ACME GmbH", at=(72, 812), size=8)
page = Page(size="a4") | Letterhead() | Text("Body copy", at=(72, 700))
data = (Pdf() | page).to_bytes()
The canvas is only usable inside the call: any method raises PdfError once draw has returned. An exception raised inside draw propagates from save/to_bytes exactly as the Python code raised it. The protocol is structural: the stub declares a Draw protocol type for checkers, but there is no runtime class to import or inherit.
Metadata and dates
Metadata fills the document information dictionary: title, author, subject, keywords, creator, producer, creation_date, modification_date. Every field is an Option, and an all-None value writes no /Info dictionary at all. Dates are explicit Date { year, month, day, hour, minute, second, utc_offset_minutes } values; the writer never reads a clock, so dates appear in output only when supplied.
Any Some metadata, all-None included, also writes an XMP metadata stream referenced from the catalog as /Metadata, built from the same value so the two always match: title becomes dc:title, author dc:creator, subject dc:description, keywords pdf:Keywords, producer pdf:Producer, creator xmp:CreatorTool, and the dates xmp:CreateDate/xmp:ModifyDate in ISO-8601. The packet carries no xmpMM:InstanceID, no xmpMM:DocumentID and no generated timestamps. Nothing in the crate reads clocks or randomness, and the file identifier derives from a hash of the emitted body, so the same input produces identical output bytes.
Writing the file
Four paths produce the same bytes:
pdf.save(path): serialize and write to a file.pdf.to_bytes(): the whole file as oneVec<u8>.pdf.write_into(impl std::io::Write): the same bytes streamed in bounded chunks. An error can leave a prefix of the file already written, and no flush is performed. Flush a buffered writer yourself.pdf.write_into_with(sink).await: the asynchronous twin over anyAsyncByteSink; it hands the sink back unflushed.Vec<u8>is a sink,Immediatepresents anystd::io::Writeas one, andpdfboss_aio::TokioSink(behind that crate'swritefeature) presents anytokio::io::AsyncWrite.
use pdfboss_write::{Page, PageSize, Pdf, Standard14};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.canvas
.text("Asynchronous emission", 72.0, 770.0, Standard14::Helvetica, 14.0)?;
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
let bytes = pdf.write_into_with(Vec::new()).await?;
tokio::fs::write("async.pdf", &bytes).await?;
Ok(())
}
To stream into a tokio writer directly, wrap it in pdfboss_aio::TokioSink
(the write feature of pdfboss-aio). The one line is
pdf.write_into_with(TokioSink(writer)).await?; the writer comes back out of
the returned sink's .0 field, unflushed, so flush it yourself:
use pdfboss_aio::TokioSink;
use pdfboss_write::{Page, PageSize, Pdf, Standard14};
use tokio::io::AsyncWriteExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.canvas
.text("Streamed through tokio", 72.0, 770.0, Standard14::Helvetica, 14.0)?;
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
let file = tokio::fs::File::create("streamed.pdf").await?;
let mut sink = pdf.write_into_with(TokioSink(file)).await?;
sink.0.flush().await?;
Ok(())
}
Pdf::options is a WriteOptions controlling file emission. xref picks the cross-reference flavor: XrefStyle::Stream (the default) emits a compact PDF 1.5+ cross-reference stream, XrefStyle::Table a classic xref table with a trailer dictionary readable by PDF 1.0-era consumers. compress Flate-compresses stream data that carries no filter of its own (JPEG passthrough keeps its /DCTDecode and is never recompressed). object_streams packs non-stream objects into object streams, effective only with XrefStyle::Stream. version is the header version. The defaults are Stream, compressed, object streams on, version 1.7. For maximum compatibility:
use std::fs::File;
use std::io::{BufWriter, Write};
use pdfboss_write::{Page, PageSize, Pdf, Standard14, WriteOptions, XrefStyle};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::Letter);
page.canvas
.text("Classic xref table", 72.0, 700.0, Standard14::Courier, 12.0)?;
let pdf = Pdf {
pages: vec![page],
options: WriteOptions {
xref: XrefStyle::Table,
compress: false,
object_streams: false,
version: (1, 4),
},
..Pdf::default()
};
let mut out = BufWriter::new(File::create("classic.pdf")?);
pdf.write_into(&mut out)?;
out.flush()?;
Ok(())
}
CLI
pdfboss create covers the common cases without writing Rust, plus create md, which composes a Markdown file with a CSS theme and has its own chapter. Blank pages, with --pages, --size (a name from a0 to a10, b0 to b10, letter, legal, tabloid, or <width>x<height> in pt, mm, cm or in, e.g. 210mmx297mm) and --landscape:
pdfboss create blank --out blank.pdf --pages 3 --size a5 --landscape
A UTF-8 text file, word-wrapped into pages. --font takes any of the fourteen standard faces, plus --font-size and --margin in points:
pdfboss create text notes.txt --out notes.pdf --font times-roman --font-size 12
One page per input image (PNG or JPEG, detected by content); without --size, each page matches its image at 72 dpi:
pdfboss create images photo.png --out photos.pdf
A TOML manifest describes metadata and whole pages declaratively, mapping [meta], [[page]], [[page.text]], [[page.paragraph]], [[page.image]] and [[page.link]] tables onto the element vocabulary above; the schema is in the CLI reference:
pdfboss create manifest q3.toml -o q3.pdf
Each result can be checked immediately with pdfboss info, which reports the version, page count and page sizes. The full flag reference is in the CLI reference.
Round trip
The writer and the reader are two halves of the same engine: generated content streams parse with pdfboss_core::content like any other PDF, so a created document reads back without leaving the process.
use pdfboss_output::ReadingOrder;
use pdfboss_write::{Page, PageSize, Pdf, Standard14};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.canvas
.text("Round trip", 72.0, 770.0, Standard14::Helvetica, 18.0)?;
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
let bytes = pdf.to_bytes()?;
let doc = pdfboss_core::Document::load(bytes)?;
let first = doc.page(0)?;
let text = pdfboss_output::extract_text(&doc, &first, ReadingOrder::Content)?;
println!("{text}");
Ok(())
}
This prints Round trip. The same holds across tools: pdfboss info reads back the metadata, pdfboss text extracts the drawn strings, and pdfboss render rasterizes the page. One render detail: the standard fourteen faces carry no embedded font program, and rendering paints embedded programs by default. Pass --fonts full to substitute bundled faces and see the text in the raster.
Watermarking an existing file
watermark draws the first page of one document over every page of another. It does not rewrite the base file: the result is the base's bytes followed by an incremental update (ISO 32000-1 §7.5.6) holding the overlay page as a form XObject, its resources copied into the base's object space, and one replacement dictionary per page whose content is wrapped in q … Q before the form is drawn. The output therefore grows by the overlay page's size, keeps the base's cross-reference style, and takes no longer than parsing the two files. An encrypted base is refused.
use pdfboss_core::Document;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let base = Document::open("report.pdf")?;
let overlay = Document::open("draft.pdf")?;
let bytes = pdfboss_write::watermark(&base, &overlay)?;
std::fs::write("report-watermarked.pdf", bytes)?;
Ok(())
}
watermark_with(base, overlay, WriteOptions::default()) is the other shape of the same operation: a fresh file written through the Writer, every object the catalog reaches copied over, filterless streams compressed and the rest packed into object streams, so unreachable objects and earlier update sections are left behind and the result is usually smaller than the base.
From Python, pdfboss.write.watermark(data, overlay) takes and returns bytes; rewrite=True selects the fresh-file shape:
import pdfboss
marked = pdfboss.write.watermark(open("report.pdf", "rb").read(), open("draft.pdf", "rb").read())
open("report-watermarked.pdf", "wb").write(marked)
watermark_under(&base, &overlay) in Rust and pdfboss.write.watermark(data, overlay, under=True) in Python draw the overlay beneath the page's own content instead of on top of it, so the page's own content paints over it.
Editing PDFs
pdfboss_write::Update edits an existing document without rewriting it. It appends an incremental update (ISO 32000-1 §7.5.6): a cross-reference section holding only the changed and new objects, chained to the base document's own newest section by /Prev. The base's bytes never move; a conforming reader parses the newest cross-reference section first and falls back to /Prev only for object numbers that section does not cover, so an edit takes effect over the whole file without touching a byte of what came before it.
An encrypted base is refused outright. Update::new checks the trailer for an /Encrypt entry before reading or writing anything and returns Error::EncryptedBase (cannot update an encrypted document, or copy from an encrypted document not opened with its password) if one is there, whether or not the document was opened with the correct password: the check is for the entry, not for whether decryption succeeded, because the new strings and streams an update writes would need encrypting too.
This chapter covers meta and overlay. merge, split, rotate and the whole-document rewrite are in Assembling documents; encrypt and decrypt are in Encrypted PDFs. No command can append an incremental update onto an encrypted base; overlay is built on the same incremental-append machinery as meta, through the watermark family of functions described below; see also Watermarking an existing file for the same family from the composition side.
When the catalog already names an XMP packet, set_metadata rebuilds it from the eight modeled Metadata fields alone: any other XMP property the original packet carried (a PDF/A identifier, a rights statement, edit history, a custom schema) is not carried into the new packet, though the original packet's bytes stay physically present in the base, superseded only by the appended section's newer entry for that object number.
CLI
pdfboss meta sets one or more /Info fields and writes the result as an appended update:
pdfboss meta report.pdf -o report-titled.pdf --set title="Q3 Report" --set author="Finance"
--set KEY=VALUE repeats, one per field: title, author, subject, keywords, creator, producer. --password opens an encrypted input for reading; the write step still refuses it, for the reason above. --rewrite writes the whole document fresh instead of appending: the same rewrite operation, with the metadata merged in along the way.
Python
import pdfboss
from pdfboss.write import Update
update = Update(pdfboss.Document("report.pdf"))
update.set_metadata(title="Q3 Report", author="Finance")
update.save("report-titled.pdf")
set_metadata may be called more than once before saving: a field passed as None keeps whatever an earlier call staged, so calls compose. to_bytes() returns the same bytes without writing a file, and may be called more than once.
Rust
use pdfboss_core::Document;
use pdfboss_write::{Metadata, Update};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let mut update = Update::new(&doc)?;
update.set_metadata(Metadata {
title: Some("Q3 Report".to_string()),
author: Some("Finance".to_string()),
..Metadata::default()
})?;
update.save("report-titled.pdf")?;
Ok(())
}
Calling set_metadata more than once on the same Update does not compound the way the Python binding's own staging does: each call merges its fields against the base document's own /Info, never against an earlier call's, so the later call wins outright and any field it leaves None falls back to the base's original value rather than what an earlier call set.
append_into writes to any impl Write and bytes() returns the bytes directly; both build the update section before a byte reaches the output, so a refused or failing update leaves nothing behind. set, remove and reserve on Update stage arbitrary objects into the same appended section for edits beyond metadata.
Overlay
pdfboss overlay draws the overlay file's first page onto every page of the base file. On top of the page's own content by default; --under draws it beneath instead. Appends an incremental update by default, the same way meta does; --rewrite writes a fresh file instead. Both inputs are refused when encrypted, each error naming its own file.
pdfboss overlay report.pdf draft.pdf -o out.pdf --under
--password opens both encrypted inputs for reading; the write step still refuses them, for the reason above.
From Python, pdfboss.write.watermark(data, overlay, *, rewrite=False, under=False) takes and returns bytes:
import pdfboss
data = open("report.pdf", "rb").read()
mark = open("draft.pdf", "rb").read()
out = pdfboss.write.watermark(data, mark, under=True)
open("out.pdf", "wb").write(out)
pdfboss_write exposes the same choice as four functions: watermark (over, appended), watermark_under (under, appended), watermark_with (over, a fresh file through Writer) and watermark_under_with (under, a fresh file). Drawing under paints the overlay form first and the page's own content after, so opaque content covers it; drawing over paints the page's own content first and the form last, on top.
use pdfboss_core::Document;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let base = Document::open("report.pdf")?;
let overlay = Document::open("draft.pdf")?;
let bytes = pdfboss_write::watermark_under(&base, &overlay)?;
std::fs::write("out.pdf", bytes)?;
Ok(())
}
Limitations
Placement is absolute and unscaled: the overlay page draws at its own coordinates on every page of the base file, with no scaling to that page's size, and the overlay page's /Rotate and /CropBox are not applied, so an overlay file should use the same page size as the base.
For the over placement, a page whose own content leaves unbalanced graphics state (an unclosed clip or transform) can clip or restyle the overlay, since the wrapper's one closing Q cannot undo it; --under is unaffected, since the form paints before any of the page's own operators run.
Async
pdfboss-aio's write feature carries the same append over an AsyncDocument, without holding the whole base in memory: overlay_base(&doc) reads its trailer and newest cross-reference section into an OverlayBase, and append_overlay(&doc, &overlay, sink) streams the base's bytes through any AsyncByteSink in 64 KiB chunks, then writes the built section in one call. Update itself stays synchronous; Overlay, OverlayBase and set_metadata_with are the pieces both sides are built from, but not start_offset, since append_overlay reads the base's own last byte to compute its pad instead of calling it.
Assembling documents
pdfboss_write::assemble builds new documents out of existing ones: merge_documents gathers selected pages from several sources under a fresh page tree (ISO 32000-1 §7.7.3), split_document cuts one document into consecutive-page parts, rotate_rewrite turns selected pages by a quarter-turn multiple in a whole fresh copy, and rewrite_document/rewrite_with_metadata write a document fresh with no page change. merge_documents, split_document, rewrite_document/rewrite_with_metadata, and rotate_rewrite all route through Importer, which renumbers every object reference it meets once and copies it into the output; rotate's default append mode instead stages its change through Update, the same way meta does.
At the CLI, merge, split, rotate, rewrite and overlay all refuse every encrypted input outright, whether or not the password opened it, the same way Update does. The pdfboss_write functions underneath are narrower: Importer::new refuses only a locked source, one that is encrypted with no working decryptor; a source already opened under its password copies its content across as plaintext, the same content encrypt_document relies on to re-encrypt a document under new passwords.
Merging
merge combines selected pages from one or more inputs into a single fresh document, keeping the pages in argument order. A source contributes either every page or a specific, reorderable selection.
pdfboss merge report.pdf:2-9 appendix.pdf -o combined.pdf
report.pdf:2-9 is a 1-based CLI range; appendix.pdf alone takes every page. From Python, the same selection is 0-based and expressed as a list:
from pdfboss.write import merge
combined = merge([(report_bytes, [1, 2, 3, 4, 5, 6, 7, 8]), appendix_bytes])
Only the pages themselves are imported: document-level trees carried by the inputs, such as outlines, name trees and optional content, are not part of the merged output.
Link annotations are not yet rewritten for the new page tree. When a merged or split page carries a link annotation pointing at another page of its source, that referenced page's objects ride along in the output outside the new page tree, unreferenced by any /Kids array (a larger file than the selection alone would need), and the link still resolves to those carried objects rather than to whichever imported page they came from.
Splitting
split cuts a document into consecutive chunks of every pages, the last chunk carrying whatever remains.
pdfboss split report.pdf -o 'part-%d.pdf' --every 10
-o names an output pattern containing %d, substituted with each part's 1-based number. Python's split(data, every) returns the parts as a list of bytes instead of writing files:
from pdfboss.write import split
parts = split(report_bytes, every=10)
One part at a time
split holds every part in memory at once. split_parts(data, every, *, password="") builds the same parts one at a time as you iterate, so only one part is held besides the input; each part reads the input afresh, so nothing cached for one part stays alive for the next. It works with both for and async for; under async for each part is built on a worker thread, so the event loop keeps running. A step that is cancelled or times out leaves its part for the next step, and awaiting a second step while one is pending raises RuntimeError, so parts always arrive complete and in order. The input is parsed when the first part is requested, so an unreadable file, or a wrong or missing password, raises PdfError there rather than at the call; every below 1 raises ValueError at the call. A protected input opened with password yields plain, unencrypted parts.
split_stream(stream, every, *, password="") takes the PDF as an async iterable of bytes chunks, such as an upload body or an S3 object body, and yields the parts as they are built. every and password are checked before the first chunk is read. A PDF's cross-reference table sits at the end of the file, so no page can be located before the last chunk arrives: split_stream copies each chunk into the input as it arrives and parses once the stream ends. Memory holds the file plus one part.
from pdfboss.write import split_stream
async def split_upload(request, s3, bucket):
index = 0
async for part in split_stream(request.stream(), every=10):
index += 1
await s3.put_object(Bucket=bucket, Key=f"part-{index}.pdf", Body=part)
In Rust, split_runs(doc, every) gives the runs of page indices split_document cuts doc into, counting the pages the page tree actually holds rather than its declared /Count, and split_part(doc, run, options) builds one of them into a Vec<u8>; split_document is those two in a loop. copy_pages_with(src, pages, options, sink) is the asynchronous form: it builds one fresh document from pages of src and writes it into sink, handing the sink back so the caller can finish it (close a file, complete an upload) before building the next part. src is any AsyncObjectSource: a Document through pdfboss_core::Immediate(&doc) (not pdfboss_write::Immediate, which wraps a byte sink), or an AsyncDocument reading a file or a URL by range. Every page must come from src; a page whose object in src is missing or different is refused. Fetch each run's pages just before building its part, so only one run's pages are held at a time:
use pdfboss_aio::{AsyncDocument, TokioSink};
use pdfboss_core::Page;
use pdfboss_write::{copy_pages_with, WriteOptions};
use tokio::io::AsyncWriteExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open("report.pdf").await?;
let total = doc.page_count();
for (index, start) in (0..total).step_by(10).enumerate() {
let pages = (start..(start + 10).min(total))
.map(|page| doc.page(page))
.collect::<Result<Vec<Page>, _>>()?;
let file = tokio::fs::File::create(format!("part-{}.pdf", index + 1)).await?;
let TokioSink(mut file) =
copy_pages_with(doc.clone(), pages, WriteOptions::default(), TokioSink(file)).await?;
file.flush().await?;
}
Ok(())
}
TokioSink needs pdfboss-aio's write feature. An AsyncDocument keeps every object it has read cached for as long as it lives, so over a long split its memory grows by the objects each part copies; open the document again for each part where that matters.
Rotating
rotate turns selected pages by 90, 180 or 270 degrees clockwise. By default it appends an incremental update, the same way meta does; --rewrite (rewrite=True in Python) writes the whole document fresh instead.
pdfboss rotate report.pdf -o rotated.pdf --pages 2,4-9 --by 90
from pdfboss.write import rotate
rotated = rotate(report_bytes, 90, pages=[1, 3, 4, 5, 6, 7, 8])
Either mode refuses a page inlined directly into /Kids with no object of its own: pdfboss does not yet restructure such a page to rotate it.
Rewriting
rewrite writes a document fresh through the Writer with no page change: streams recompressed, unreachable objects and earlier update sections left behind. meta --rewrite (see Editing PDFs) is the same operation with metadata merged in along the way.
pdfboss rewrite report.pdf -o report-clean.pdf
from pdfboss.write import rewrite
clean = rewrite(report_bytes)
Append or rewrite
| Command | Default | Full rewrite |
|---|---|---|
meta | appends an incremental update | --rewrite / rewrite=True |
overlay | appends an incremental update | --rewrite / rewrite=True |
rotate | appends an incremental update | --rewrite / rewrite=True |
merge | always a fresh document | no incremental mode |
split | always fresh documents | no incremental mode |
rewrite | always a fresh document | its only mode |
The Importer
Importer::new(writer, source) opens one source document for copying into a Writer, refusing a locked source: one that is encrypted with no working decryptor. A source already opened under its password copies across as plaintext instead, the same way encrypt_document relies on it to re-encrypt a document under new passwords. page(index, parent) imports one page as a self-contained object under parent, translating its effective resources, media box and rotation and returning the page's new reference; it is what merge_documents calls once per selected page. document() instead walks the whole reachable graph from the source catalog and returns the new root reference; rewrite_document and rotate_rewrite build on it.
Importer::with_source(writer, source) opens any AsyncObjectSource instead, such as an AsyncDocument, for the asynchronous twins: page_with(&page, parent) imports a Page the caller already holds, which must come from source, and finish_with() drains the queue, awaiting each fetch. The synchronous methods are these twins run over pdfboss_core::Immediate(&doc), the source type Importer defaults to, so Importer<'_, '_> still names the importer over a borrowed Document. A source cannot report whether it is locked, so with_source trusts the caller to hand over one that already decrypts, as an opened AsyncDocument does.
use pdfboss_core::{Dict, Document, Name, Object};
use pdfboss_write::{Importer, WriteOptions, Writer};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let mut writer = Writer::new(WriteOptions::default());
let pages_ref = writer.reserve();
let mut importer = Importer::new(&mut writer, &doc)?;
let kids = vec![importer.page(2, pages_ref)?, importer.page(0, pages_ref)?];
let mut tree = Dict::new();
tree.insert(Name("Type".to_string()), Object::Name(Name("Pages".to_string())));
tree.insert(
Name("Kids".to_string()),
Object::Array(kids.into_iter().map(Object::Ref).collect()),
);
tree.insert(Name("Count".to_string()), Object::Int(2));
writer.fill(pages_ref, Object::Dict(tree))?;
let mut catalog = Dict::new();
catalog.insert(Name("Type".to_string()), Object::Name(Name("Catalog".to_string())));
catalog.insert(Name("Pages".to_string()), Object::Ref(pages_ref));
let root = writer.put(Object::Dict(catalog));
std::fs::write("reordered.pdf", writer.finish(root)?)?;
Ok(())
}
substitute(r, body) replaces the body a queued reference r will get once the drain reaches it, with body already in target space; copy(obj) translates a source-space object's own references into the target, the piece substitute callers use to build that body. rotate_rewrite is exactly this pattern: it calls document() to queue the whole graph, but first calls copy on each selected page's dictionary (with /Rotate set to its new value) and substitutes the result for that page's own reference, so the drain fills it with the rotated body instead of the source's original one. A substitution must be staged before the drain reaches that reference; page itself never risks the ordering, since it only substitutes a reference it has just queued for the first time.
Markdown to PDF
pdfboss composes CommonMark+GFM source into a themed, paginated PDF: the
pdfboss create md CLI subcommand, pdfboss.md.to_pdf in Python, and
pdfboss_markdown::to_pdf in Rust. This is the reverse of
Markdown output, which extracts Markdown from a PDF; the
composed document reads back through the whole toolkit like any other file.
What the composition covers: headings, paragraphs, bulleted and numbered
lists (nested included), GFM tables with per-column alignment, fenced and
indented code blocks, block quotes, thematic breaks, emphasis, strong,
strikethrough and inline code runs, hyperlinks (emitted as real clickable
/Link annotations), and images (PNG or JPEG, detected by content, with
relative paths resolved against a base directory). Raw HTML fragments are
skipped and reported rather than half-rendered.
Two properties worth designing around:
- Deterministic. The same markdown, theme and options always produce byte-identical output: nothing reads a clock, randomness or the environment.
- Replace-and-report. Text renders in the standard Helvetica, Times and
Courier families, which encode as WinAnsi. A character outside that
encoding is replaced with
?and tallied in a report naming each replaced character; a clean document reports nothing.
Themes
A theme is a small CSS subset: element-type selectors only, cascading over
the built-in default theme, with inheritance from body down. The twenty
selectable elements are body, h1–h6, p, code, pre, blockquote,
ul, ol, li, table, th, td, a, del and hr. Properties
include font-family (helvetica, times, courier, or the sans-serif/
serif/monospace aliases), font-size (pt, px, em, mm, cm,
in), font-style, font-weight, color and background-color (named,
#hex or rgb()), text-align, text-decoration, line-height, and
margin/padding with their per-side forms. Parse errors are strict and
located: a typo fails with a line and column rather than being ignored.
body { font-family: times; font-size: 10.5pt; color: #222; }
h1 { font-family: helvetica; font-size: 2.2em; color: #a33; }
code { font-family: courier; background-color: #eee; }
pre { background-color: #eee; padding: 8pt; }
CLI
pdfboss create md notes.md -o notes.pdf --theme theme.css --size letter
--theme takes a CSS file (omitted, the built-in default theme applies);
--size is a page size name (a0 to a10, b0 to b10, letter, legal, tabloid; default a4) or <width>x<height> in pt, mm, cm or in, and
--landscape swaps width and height. Relative image paths in the markdown
resolve against the input file's directory. The result round-trips
immediately:
pdfboss info themed.pdf # 1 page, 612 x 792 pt
pdfboss text themed.pdf # the composed text back out
Python
pdfboss.md.to_pdf returns the PDF as bytes. theme is CSS source text,
not a path: read the file yourself when the theme lives in one. size
names the page size case-insensitively, landscape swaps the dimensions,
and base_dir anchors relative image paths (default: the current
directory).
from pathlib import Path
import pdfboss
theme = Path("theme.css").read_text()
pdf = pdfboss.md.to_pdf(Path("notes.md").read_text(), theme=theme, size="letter")
Path("notes.pdf").write_bytes(pdf)
Replacements and skipped raw HTML surface as a single UserWarning naming
what changed, so a clean run stays silent and a lossy one is visible without
being fatal:
import warnings
import pdfboss
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
pdf = pdfboss.md.to_pdf("Snowman ☃ here")
for warning in caught:
print(warning.message) # replaced 1 character unavailable in the standard fonts: '☃'×1
An unknown size or an invalid theme raises PdfError.
Rust
pdfboss_markdown::to_pdf(markdown, &options) returns the composed
pdfboss_write::Pdf (still a value, not yet bytes)
alongside the replace-and-report Report. Options carries the parsed
Theme, the PageSize and the image base_dir; Theme::parse reads CSS
source, Theme::default_theme() is the built-in look. Serialize the Pdf
with any of the write paths:
use pdfboss_markdown::{to_pdf, Options, PageSize, Theme};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let markdown = std::fs::read_to_string("notes.md")?;
let theme = Theme::parse("h1 { font-family: helvetica; color: #a33; }")?;
let options = Options {
theme,
page_size: PageSize::Letter,
..Options::default()
};
let (pdf, report) = to_pdf(&markdown, &options)?;
if !report.is_empty() {
eprintln!("{}", report.summary());
}
pdf.save("notes.pdf")?;
Ok(())
}
Because the result is a plain Pdf, everything from
Creating PDFs applies afterwards: set metadata, attach an
outline or embedded files, add page labels or viewer preferences, append
pages of your own, or stream the bytes asynchronously.
Round trip
Composition and extraction are the two directions of one engine. A document
composed from Markdown reads back with pdfboss text, renders with
pdfboss render, and, closing the loop, pdfboss md re-infers headings,
lists and tables from the composed layout. The composed text uses only
standard-14 faces, so rendering paints it at the full
font tier.
Async and remote PDFs over HTTP
AsyncDocument opens a PDF without reading the whole file. The open flow fetches only what it needs (the header, the cross-reference chain and the page tree) and every later operation fetches only the byte ranges it touches. The file backend reads windows of the file on demand; the HTTP backend turns each read into a Range request, so a document on a server can be paged through without downloading it. A server that ignores Range and answers 200 with the full body (python3 -m http.server, for one) still works: the first such answer is kept as the whole resource and every read is served from it, at the cost of one full download held in memory for the life of the document.
Python
Three constructors, all coroutines. Each takes password= for encrypted files (see Encrypted documents):
| Constructor | Source |
|---|---|
AsyncDocument.open(path) | A local file, read in ranges |
AsyncDocument.open_url(url, headers=None) | An http(s) URL, fetched via Range requests; headers go out on every request, the HEAD included |
AsyncDocument.from_bytes(data) | Bytes already in memory |
What is sync and what is a coroutine follows from what the open flow already parsed. page_count, version, len(doc) and page access (doc[i], doc.page(i) and every AsyncPage geometry property) are plain sync attributes: the xref chain and the page tree were parsed at open, so nothing there needs I/O. Everything that must read more of the file is a coroutine: extract_text, extract_markdown, render_pages, metadata, get_object on the document, and extract_text, extract_markdown, render, render_reporting, extract_images and spans on a page.
Coroutines are driven by one shared multi-thread tokio runtime behind the asyncio loop. render_pages fans pages across the machine's cores as tokio tasks, so the loop stays free while pages rasterize.
import asyncio
import pdfboss
async def main() -> None:
doc = await pdfboss.AsyncDocument.open("report.pdf")
print(doc.page_count, doc.version)
text = await doc.extract_text()
metadata = await doc.metadata()
png = await doc[0].render(scale=2.0)
async for span in doc.spans(pages=[0]):
print(span.text, span.font_name)
asyncio.run(main())
doc.elements() and doc.spans() return async iterators, consumed with async for, with the same ordering and salvage semantics as their sync twins. Each step returns one item. The iterator refills an internal buffer from the document in batches, so most steps return without suspending and a long walk costs about as much as the sync one. A step that is cancelled or times out leaves its item for the next step, so no item is lost or repeated. See Exploring PDF internals and Styled spans.
A remote document differs only in the constructor:
import asyncio
import pdfboss
async def main() -> None:
doc = await pdfboss.AsyncDocument.open_url("https://example.com/report.pdf")
print(doc.page_count)
print(await doc.extract_markdown())
asyncio.run(main())
Rust
pdfboss_aio::AsyncDocument has the same constructors: open/open_with_password, from_bytes/from_bytes_with_password and, behind the crate's http feature, open_url/open_url_with_password/open_url_with_headers (the last takes a reqwest::header::HeaderMap sent on every request). with_backend opens a document over any byte source you build yourself.
use pdfboss_aio::AsyncDocument;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open_url("https://example.com/report.pdf").await?;
println!("{} pages", doc.page_count());
let metadata = doc.metadata().await?;
println!("{:?}", metadata.title);
Ok(())
}
Backends
A document reads through the Backend trait: len() and read_at(offset, buf), both returning boxed futures so the trait is object-safe and a document can hold Arc<dyn Backend>. Four implementations ship with the crate:
MemBackend: bytes fully resident in memory;from_bytesuses it directly, with no cache.FileBackend: positioned reads (pread-style, no shared cursor) run on tokio's blocking thread pool, so disk I/O never stalls the async runtime. The length is captured once at open; the file is treated as immutable while the backend lives.HttpBackend(featurehttp): the length comes from aHEADrequest'sContent-Length; each read is aGETwith aRange: bytes=header. A200answer where206was asked for means the server ignoresRange; its body is the whole resource, so it is collected once (capped at the declared length, so a buggy or hostile server cannot balloon memory) and all reads are served from it.on_fallback_progressregisters an observer for that one-time download (the CLI draws its stderr progress bar through it). A206body is likewise collected only up to the requested size.CachedBackend: a chunked LRU read cache over any backend: many small reads become few chunk-sized fetches, and hot chunks stay resident up to a byte budget. Defaults: 64 KiB chunks, 32 MiB total. Misses batch adaptively: a miss landing near the previous one doubles the batch, up to 8 MiB (and a quarter of the budget), a far jump halves it, and each miss fetches its uncached neighborhood, growing around the missed chunk until it hits resident chunks or the batch budget. Dense access over a high-latency server collapses into few large requests, whichever direction the reader walks the file, while scattered access never over-fetches.on_fetchregisters an observer called with the offset and length of every inner fetch a miss triggers (the CLI draws its ranged-open coverage minimap through it); cache hits never call it.
open and open_url wrap their backend in a CachedBackend automatically. from_bytes stays uncached, and with_backend adds nothing, so a composition of your own is used exactly as given (with_backend_with_password is the same for encrypted files):
use pdfboss_aio::{AsyncDocument, CachedBackend, FileBackend};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let backend = FileBackend::open("report.pdf").await?;
let cached = CachedBackend::with_capacity(backend, 128 * 1024, 64 * 1024 * 1024);
let doc = AsyncDocument::with_backend(cached).await?;
println!("{} pages", doc.page_count());
Ok(())
}
The sync crates over an async document
The extraction and rendering crates are written sans-I/O. Each entry point is implemented as a *_with function generic over pdfboss_core::AsyncObjectSource; the sync signature is that same implementation run over an immediate, no-I/O source. AsyncDocument implements AsyncObjectSource, so pdfboss_output::extract_text_with, pdfboss_output::extract_page_markdown_with, pdfboss_render::render_page_reporting_with and pdfboss_render::extract_page_images_with all run over range-fetching reads unchanged. The document is an Arc handle: cloning one to hand to an entry point by value costs two atomic increments.
use pdfboss_aio::AsyncDocument;
use pdfboss_output::{extract_text_with, ReadingOrder};
use pdfboss_render::extract_page_images_with;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open("report.pdf").await?;
println!("{} pages", doc.page_count());
let page = doc.page(0)?;
let oc = doc.oc_state().await;
let text = extract_text_with(doc.clone(), &page, oc.as_ref(), None, ReadingOrder::Content)
.await?;
println!("{text}");
let images = extract_page_images_with(doc.clone(), &page).await?;
for (index, image) in images.iter().enumerate() {
image.save_png(format!("image-{index}.png"))?;
}
Ok(())
}
The oc parameter carries the document's optional-content visibility (doc.oc_state().await, the state a viewer shows on screen; doc.oc_state_for(event).await builds the printed or exported state, or the default configuration alone for None); text and markdown extraction use it to exclude layers that state turns off, exactly as the sync entry points do. The structure parameter next to it is the document's structure tree (doc.structure_tree().await), read only under ReadingOrder::StructureTree; pass None for the other orders. See Reading order. Rendering takes the same state through RenderOptions::oc: set opts.oc = doc.oc_state().await.map(Arc::new); before calling render_page_reporting_with. Leaving it None renders every layer; only the sync entry points fill it from the document. What the extracted images contain (drawing order, native size, /SMask alpha) is described in Extracting images; the sync Rust and Python surfaces are in Rust crates and Python API.
Exploring PDF internals
A PDF is two structures at once: a physical file (header, numbered objects, cross-reference sections, trailer) and the logical document those objects encode (pages, fonts, images, annotations). pdfboss exposes both as one lazy stream of elements, reachable from Python and Rust, and from the CLI as a JSON tree, jq queries, hexdumps and an interactive terminal explorer.
The element model
A walk yields the physical elements first, in file order, each with its byte span in the file:
| kind | What it is |
|---|---|
header | The %PDF-1.x marker |
object | One indirect object (N G obj … endobj); ref carries (num, gen) |
xref | One cross-reference section, table or stream |
trailer | The trailer dictionary |
startxref | The startxref pointer |
eof | The %%EOF marker |
Then the logical elements, in document order:
| kind | What it is |
|---|---|
page | One page; page carries the 0-based index |
font, image, annotation | A page's resources, under that page's index |
content_op | One content-stream operator; the span is the range within the page's decoded content stream |
Parsing is lazy: nothing is located, parsed or decoded before it is yielded. Iteration salvages: an element that cannot be parsed raises for that item alone, and the walk continues past it.
Python
Document.elements() returns a lazy iterator; each step releases the GIL while the next element is parsed. Keyword arguments select the layers: physical= and logical= toggle the two passes, pages= restricts logical elements to the 0-based pages given, and content_ops=True adds the (high-volume) per-page operators.
import pdfboss
doc = pdfboss.Document("report.pdf")
for element in doc.elements(logical=False):
print(element.kind, element.span, element.ref)
Each Element carries kind, span (byte range, where applicable), ref (the (num, gen) object reference, where applicable) and page (0-based index for logical elements). value() converts the element lazily to plain Python data: dicts, lists, str, bytes, numbers, bool, None; PDF names become str, streams become {"dict": ..., "length": n}, references become {"ref": (num, gen)}. That full conversion applies to object and trailer elements; the other kinds convert to fixed shapes:
| kind | value() |
|---|---|
header | the version string, e.g. "1.7" |
xref | {"kind": "table" or "stream", "entries": int} |
startxref | the offset as int |
font | {"subtype": str, "base_font": str or None} |
image | {"width": int, "height": int} |
annotation | {"subtype": str} |
content_op | the operator rendered as a string |
eof, page | None |
import pdfboss
doc = pdfboss.Document("report.pdf")
for element in doc.elements(physical=False, pages=[0]):
if element.kind != "font":
continue
print(element.value())
A for loop stops at the first raising element, so a walk that must survive damage drives the iterator explicitly. A per-item PdfError leaves the iterator usable:
import pdfboss
doc = pdfboss.Document("report.pdf")
elements = doc.elements()
while True:
try:
element = next(elements)
except StopIteration:
break
except pdfboss.PdfError as err:
print("unreadable element:", err)
continue
print(element.kind)
AsyncDocument.elements() is the async twin: same arguments, same ordering, same salvage semantics, consumed with async for (Async and remote documents).
Rust
The same walk in Rust is pdfboss_core::Document::elements(ElementOpts), an iterator of Result<Element>. ElementOpts selects the layers with the same four knobs (physical, logical, pages, content_ops); Element is an enum; variants carry their payload fields directly, with byte spans on the physical variants.
use pdfboss_core::{Document, Element, ElementOpts};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
for element in doc.elements(ElementOpts::default()) {
match element {
Ok(Element::IndirectObject { r, span, .. }) => {
println!("{} {} obj at {}..{}", r.num, r.gen, span.start, span.end);
}
Ok(_) => {}
Err(err) => eprintln!("unreadable element: {err}"),
}
}
Ok(())
}
pdfboss_aio::AsyncDocument::elements(ElementOpts) returns an ElementStream, a futures_core::Stream of Result<Element> with the same ordering and salvage semantics. The stream owns an Arc clone of the document, so it is 'static and can be spawned:
use futures_util::StreamExt;
use pdfboss_aio::AsyncDocument;
use pdfboss_core::ElementOpts;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open("report.pdf").await?;
let mut elements = doc.elements(ElementOpts::default());
while let Some(element) = elements.next().await {
match element {
Ok(element) => println!("{element:?}"),
Err(err) => eprintln!("unreadable element: {err}"),
}
}
Ok(())
}
CLI
Four subcommands plus the terminal explorer. json, q, hex and tui accept a local path or an http(s) URL (a URL is fetched in ranges when the server honors Range; one that doesn't costs a single full download); obj takes a local path. Full flag listings are in the CLI reference.
json: the document as a JSON value tree
pdfboss json report.pdf > report.json
The tree's top-level keys are header, objects (keyed "N G"), pages, xref, trailer and startxref. Physical entries carry a _span byte range; indirect references appear as {"_r": [num, gen]}. --layout adds a top-level layout array: per page, the inferred blocks (headings, paragraphs, lists, tables). --pages restricts the logical layer, --no-logical skips it, --content-ops adds per-page operators, and --raw/--decode embed stream data as base64 (still encoded, or decoded).
q: jq programs over the same tree
pdfboss q report.pdf '. | keys'
pdfboss q report.pdf '[.pages[].fonts[].base_font] | unique'
pdfboss q report.pdf '.objects["2 0"]'
The second one answers "which fonts does this document use" in one line:
[
"BAMEDE+StoneSans-Bold",
"BAMFAO+StoneSerif-Italic",
...
"Helvetica",
"Helvetica-Bold"
]
-r prints string results raw, and --hex hexdumps any result that carries a _span instead of printing its JSON: a query language for choosing what to dump.
hex: the bytes themselves
pdfboss hex report.pdf obj:2 # one object's bytes
pdfboss hex report.pdf trailer # or: header, xref:0, range:0x100-0x140
pdfboss hex report.pdf --annotate # whole file, element boundaries labeled
000000aa 32 20 30 20 6f 62 6a 0d 3c 3c 20 0d 2f 50 72 6f |2 0 obj.<< ./Pro|
000000ba 63 53 65 74 20 5b 20 2f 50 44 46 20 2f 54 65 78 |cSet [ /PDF /Tex|
Selectors: obj:N[,G], header, xref:N (sections indexed in chain order, newest first), trailer, range:START-END (offsets decimal or 0x-hex); without one, the whole file. --annotate prints a labeled boundary line as the dump crosses each element.
obj: one object, pretty-printed
pdfboss obj report.pdf 2
<<
/ColorSpace <<
/Cs5 122 0 R
...
>>
/ExtGState <<
/GS1 148 0 R
>>
/Font <<
/F1 132 0 R
...
>>
/ProcSet [/PDF /Text /ImageB /ImageC]
...
>>
tui: the interactive explorer
pdfboss tui report.pdf
The screen splits into a tree pane on the left, an inspector above a hex pane on the right, and a status bar. The tree is the element model as a lazy hierarchy, populated by background tasks as sections expand: Document → Pages (each with its Fonts, Images, Annotations and Contents) → Objects → Xref sections → Trailer. The inspector pretty-prints the selection; d cycles it through raw bytes, decoded bytes and disassembled content operators for streams, and Enter jumps through any N G R reference under the cursor (Backspace goes back). p swaps the inspector for a rasterized page preview and m for the page's Markdown; both follow the selection when it moves to another page. The hex pane tracks the selection's bytes.
Tab cycles focus, arrows or j/k/h/l move, g/G jump to top/bottom, / searches (with n/N for next/previous hit), q quits. Alt+arrows (Option on macOS) resize the panes: left/right move the tree divider, up/down the inspector/hex divider (Ctrl+arrows and Ctrl+Shift+arrows also work where the terminal delivers them, and the ESC b/ESC f word motions stock iTerm2 and Terminal.app send for Option+Left/Right are accepted as horizontal resizes). Long operations (element streaming, hex fetches, search, preview rasterization) run off the event loop, so input never blocks, including over an HTTP-backed document.
y opens a yank menu that copies the selection to the clipboard: q the pdfboss q expression addressing it (.objects["12 0"], .pages[0].fonts, .trailer), c the full shell command, x a hexdump of its bytes, b the raw bytes, e the pretty-printed element, m the page's Markdown, o the object reference (12 0 R), Esc cancels. Copies go to the native clipboard, falling back to the OSC 52 escape sequence (which works over SSH in terminals that support it). A selection past 1 MiB yields the equivalent pdfboss hex command instead of the bytes.
Encrypted PDFs
pdfboss opens files encrypted with the PDF Standard security handler: RC4
(40–128-bit, /V 1–2, and /V 4 with crypt filter V2), AES-128 (/V 4,
crypt filter AESV2) and AES-256 (/V 5, crypt filter AESV3). Either the user password or the owner
password opens the document, and both unlock the same full content. A file
protected only by an owner password has an empty user password and opens
transparently, with no password at all.
Non-ASCII passwords are tried UTF-8 encoded and, for the legacy RC4/AES-128 revisions, Latin-1 encoded as well, covering both encodings real files use.
Checking whether a file needs a password
pdfboss info never fails on an encrypted file. Whether the file needs a
password is the very question being asked:
pdfboss info locked.pdf
version: 1.7
encrypted: true
pages: unknown
encrypted: true means the file did not open with the password supplied (by
default, none). A file that opens (because it is unencrypted, protected only
by an owner password, or because --password carried the right value)
reports encrypted: false along with the full page and metadata listing:
pdfboss info --password hunter2 locked.pdf
version: 1.7
encrypted: false
pages: 1
page 1: 612 x 792 pt
CLI
Every subcommand that reads a PDF (info, text, md,
render, images, obj, tui, json, hex, meta and q) takes
--password, accepted as either the user or the owner password. For
meta it only unlocks the base for reading: every assembly command,
meta included, refuses an encrypted input at write time regardless of
password; decrypt is the one command that removes encryption instead
of refusing it:
pdfboss text --password hunter2 locked.pdf
Apart from info, a command given no password (or a wrong one) for a
password-protected file prints an error and exits nonzero.
Python
Document takes a password keyword, and raises PdfError when the file
needs a password it was not given (or the given one is wrong):
import pdfboss
try:
doc = pdfboss.Document("locked.pdf")
except pdfboss.PdfError:
doc = pdfboss.Document("locked.pdf", password="hunter2")
print(doc.extract_text())
The same keyword exists on the data= form of the constructor and on all
three async constructors: AsyncDocument.open, AsyncDocument.open_url and
AsyncDocument.from_bytes (see
Async and remote documents):
import asyncio
import pdfboss
async def main() -> None:
doc = await pdfboss.AsyncDocument.open("locked.pdf", password="hunter2")
print(doc.page_count)
asyncio.run(main())
Rust
Document::open (and Document::load for bytes in memory) handles the
empty-user-password case on its own and returns Error::Encrypted when a
real password is needed; open_with_password/load_with_password take one:
use pdfboss_core::{Document, Error};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = match Document::open("report.pdf") {
Err(Error::Encrypted) => Document::open_with_password("report.pdf", "hunter2")?,
other => other?,
};
println!("{} pages", doc.page_count());
Ok(())
}
A wrong password also comes back as Error::Encrypted. The async document
mirrors the sync surface with AsyncDocument::open_with_password,
open_url_with_password and from_bytes_with_password:
use pdfboss_aio::AsyncDocument;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open_with_password("report.pdf", "hunter2").await?;
println!("{} pages", doc.page_count());
Ok(())
}
Once a document is open, every operation (text, markdown, rendering, images) works exactly as on an unencrypted file; decryption happens transparently underneath. Full option listings live in the CLI reference.
Encrypting a file
encrypt builds a fresh AES-256, revision 6 protected copy of a document
(ISO 32000-2 §7.6.4.3). At least one of the user or owner password must
be set; an omitted owner password falls back to the user password, and
either password opens the file:
pdfboss encrypt report.pdf -o locked.pdf --user-password hunter2
--allow restricts what a reader opening under the user password may
do; the owner password always grants everything. Values: print,
modify, copy, annotate, fill-forms, accessibility, assemble,
print-hires; every permission is granted when --allow is omitted:
pdfboss encrypt report.pdf -o locked.pdf --user-password hunter2 --allow print,copy
--password opens an input that is itself encrypted, so an
already-protected file re-encrypts under new passwords:
pdfboss encrypt locked.pdf -o relocked.pdf --password hunter2 --user-password newpass
Rust:
use pdfboss_core::{Document, Permissions};
use pdfboss_write::{encrypt_document, WriteOptions};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let permissions = Permissions { modify: false, ..Permissions::all() };
let bytes = encrypt_document(&doc, "hunter2", "", permissions, WriteOptions::default())?;
std::fs::write("locked.pdf", bytes)?;
Ok(())
}
Python:
from pdfboss.write import encrypt
locked = encrypt(report_bytes, user_password="hunter2", allow=["print", "copy"])
Removing encryption
decrypt opens a file under its user or owner password and writes a
fresh, unencrypted copy:
pdfboss decrypt locked.pdf -o plain.pdf --password hunter2
use pdfboss_core::Document;
use pdfboss_write::{decrypt_document, WriteOptions};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open_with_password("locked.pdf", "hunter2")?;
let bytes = decrypt_document(&doc, WriteOptions::default())?;
std::fs::write("plain.pdf", bytes)?;
Ok(())
}
from pdfboss.write import decrypt
plain = decrypt(locked_bytes, password="hunter2")
Limitations
pdfboss writes AES-256, revision 6 encryption only; RC4 and AES-128
files stay readable but are never produced. Each encrypted output uses
a fresh random file key, salts and initialization vectors, so
encrypting the same document twice under the same passwords never
produces identical bytes. An incremental update appended onto an
encrypted base (the default mode of meta, rotate and overlay, and
pdfboss_write::Update directly) still refuses the base outright,
whether or not it opens under a password. encrypt and decrypt are
the only commands that take a password-opened encrypted input
directly; merge, split, rewrite, meta --rewrite and the
--rewrite forms of rotate/overlay refuse every encrypted input at
the CLI regardless of password, even though the pdfboss_write
functions behind them would accept an already-opened one and carry its
content across as plaintext. A file pdfboss writes always encrypts the
/Info dictionary's strings and the XMP metadata stream, like every
other string and stream in the file. Reading a third-party file is
different when its /Encrypt dictionary carries /EncryptMetadata false: its metadata stream was stored in plaintext, and pdfboss leaves
those bytes untouched rather than decrypting them.
pdfboss CLI reference
One binary, pdfboss, with a subcommand per job. This chapter is the flag inventory; worked examples live in the guide chapters linked from each entry.
Shared behavior:
- Every subcommand that reads a PDF takes
--password <PASSWORD>for encrypted files: user or owner password; the empty user password opens transparently. See Encrypted documents. - The explorer subcommands (
tui,json,hex,q) accept either a local path or anhttp(s)://URL as input. URLs are range-fetched, and when stderr is a terminal the open draws a coverage minimap there: a caret marking the byte region being fetched over a map of which stretches of the file have arrived, erased once the document is open. A server that ignoresRangecosts one full download instead, reported with a progress bar. The other subcommands take a local path. - Exit codes: 0 on success, 1 for PDF and I/O problems, 2 for an invalid jq program (mirroring clap's own usage-error code).
renderandimagesare lenient: content that cannot be read is skipped with a warning on stderr, and the exit code stays 0.
info
Show version, page count, page sizes and metadata.
pdfboss info [OPTIONS] <FILE>
pdfboss info report.pdf
text
Extract text; all pages separated by form feed unless --page (1-based) is given. See Extracting text.
pdfboss text [OPTIONS] <FILE>
pdfboss text report.pdf --page 1
md
Extract markdown, with headings, lists and tables inferred from layout. --page <PAGE> restricts to one 1-based page: heading sizes are then judged per page, not across the document. See Markdown output.
pdfboss md [OPTIONS] <FILE>
pdfboss md report.pdf > report.md
render
Render a page to PNG, PPM, BMP or JPEG. See Rendering pages.
pdfboss render [OPTIONS] --page <PAGE> <FILE>
--page <PAGE>: 1-based page number (required)-o, --out <OUT>: output file; its extension picks the format,.png,.ppm,.bmpor.jpg(default:page-N.png)--scale <SCALE>: scale factor (default: 1)--fonts <FONTS>: which fonts to paint, one ofembedded-only(only embedded TrueType outlines, fastest),all-embedded(every embedded program),full(also substitute bundled faces for non-embedded fonts); the default resolves tofullwhen substitute faces are available (the compiled-in OFL set or--font-dir), otherwiseall-embedded--font-dir <FONT_DIR>: directory of substitute faces for--fonts full; overrides the compiled-in OFL set--png-compression <PNG_COMPRESSION>:none,fast,defaultorbest(encode time against file size, same pixels; PNG only)--jpeg-quality <JPEG_QUALITY>: 1 to 100 (default 90; JPEG only)
pdfboss render --page 1 --scale 2 -o page-1.png report.pdf
pdfboss render --page 1 --scale 2 -o page-1.ppm report.pdf
pdfboss render --page 1 --scale 2 -o page-1.jpg --jpeg-quality 80 report.pdf
images
Extract every image a page draws, each as a native-size PNG. See Extracting images.
pdfboss images [OPTIONS] <FILE>
--page <PAGE>: 1-based page number (default: all pages)-o, --out <OUT>: output directory, which must already exist (default: current directory)--png-compression <PNG_COMPRESSION>: as inrender
pdfboss images report.pdf --page 1 -o out
obj
Pretty-print a single object by number, with an optional generation number (default 0).
pdfboss obj [OPTIONS] <FILE> <NUM> [GEN]
pdfboss obj report.pdf 1
tui
Explore a PDF interactively in the terminal: element tree, object inspector, hex view, page preview and Markdown preview. Takes a path or http(s) URL and requires an interactive terminal. See Exploring PDF internals.
pdfboss tui [OPTIONS] <TARGET>
pdfboss tui report.pdf
json
Dump the document as a JSON value tree, for piping to external tools.
pdfboss json [OPTIONS] <INPUT>
--raw: embed raw (still encoded) stream data as base64; combining it with--decodeis a usage error--decode: embed decoded stream data as base64--pages <PAGES>: restrict logical elements to these 1-based pages (comma separated)--no-logical: skip the logical layer (pages/fonts/images/annotations)--content-ops: include per-page content-stream operators (high volume)--layout: include per-page layout blocks (headings, paragraphs, lists, tables)
pdfboss json report.pdf --no-logical > tree.json
hex
Hexdump the file or a selected element, hexyl-style.
pdfboss hex [OPTIONS] <INPUT> [SELECTOR]
The selector is one of obj:N[,G], header, xref:N, trailer or range:START-END (offsets decimal or 0x-hex; xref sections indexed in chain order, newest first); without one, the whole file is dumped.
--annotate: print labeled element boundaries as the dump crosses them--width <WIDTH>: bytes per row (default: 16)
The dump is colorized with ANSI escapes when stdout is a tty; setting the
NO_COLOR environment variable (any value) disables color.
pdfboss hex report.pdf header
q
Run a jq program over the document's JSON value tree (the same tree json prints).
pdfboss q [OPTIONS] <INPUT> <PROGRAM>
--raw,--decode,--pages <PAGES>,--no-logical,--content-ops: as injson, including the--raw/--decodeusage error when combined--hex: hexdump results carrying a_spaninstead of printing JSON; colorized on a tty likehex, withNO_COLORhonored-r: print string results raw, without quotes (likejq -r)
pdfboss q report.pdf -r '.pages[].fonts[].base_font'
create
Create a new PDF: blank pages, word-wrapped text, image pages, a themed Markdown document, or a TOML manifest of composed pages.
pdfboss create <COMMAND>
Five subcommands, each writing to -o, --out <OUT>. The first four share --size <SIZE> — a name (a0 to a10, b0 to b10, letter, legal, tabloid) or <width>x<height> with each length in points or suffixed pt, mm, cm or in (612x792, 210mmx297mm) — and --landscape (swap page width and height); manifest takes neither, since page size and orientation live per page inside the TOML. See Creating PDFs and Markdown to PDF.
create blank
Empty pages. --pages <PAGES> sets the page count (default: 1); --size defaults to a4.
pdfboss create blank [OPTIONS] --out <OUT>
pdfboss create blank -o blank.pdf --pages 3 --size letter
create text
A UTF-8 text file, word-wrapped into pages.
pdfboss create text [OPTIONS] --out <OUT> <INPUT>
--font <FONT>: one of the fourteen standard fonts,helvetica(default),helvetica-bold,helvetica-oblique,helvetica-bold-oblique,times-roman,times-bold,times-italic,times-bold-italic,courier,courier-bold,courier-oblique,courier-bold-oblique,symbol,zapf-dingbats--font-size <FONT_SIZE>: font size in points (default: 11)--margin <MARGIN>: page margin in points, all four sides (default: 72)
pdfboss create text notes.txt -o notes.pdf --font times-roman --font-size 12
create images
One page per input image (PNG or JPEG, detected by content). Without --size, each page matches its image at 72 dpi. --landscape requires --size: passing it alone is a usage error, not a no-op.
pdfboss create images [OPTIONS] --out <OUT> <INPUTS>...
pdfboss create images scan-1.png scan-2.png -o scans.pdf
create md
A markdown file composed into a themed document. See Markdown to PDF.
pdfboss create md [OPTIONS] --out <OUT> <INPUT>
--theme <THEME>: CSS theme file (default: the built-in theme)--size <SIZE>: as above;--landscapeswaps width and height
Relative image paths in the markdown resolve against the input file's directory.
pdfboss create md notes.md -o notes.pdf --theme theme.css --size letter
create manifest
A TOML manifest describing metadata and pages: text, paragraphs, images and links mapped onto the compose vocabulary of pdfboss-write. See Creating PDFs for that vocabulary.
pdfboss create manifest --out <OUT> <INPUT>
The manifest's tables:
[meta]: optional document information, mapped onto/Info:title,author,subject,keywords,creator,producer, each a string.[[page]]: one table per page, in reading order.sizeis a page size: a name (a0toa10,b0tob10,letter,legal,tabloid, case-insensitively) or<width>x<height>with each length in points or suffixedpt,mm,cmorin; absent defaults toa4andlandscape(boolean) swaps width and height, both per page.[[page.text]]: one line of text:value,at = [x, y](the baseline origin), optionalfontandsize.[[page.paragraph]]: wrapped text:value,rect = [x0, y0, x1, y1], optionalfont,size,leadingandalign(left,center,right,justify).[[page.image]]: a placed raster:path(resolved relative to the manifest's directory, decoded by content as PNG or JPEG),at = [x, y], optionalwidthandheight.[[page.link]]: a clickable rectangle:rectplus exactly one ofurlorpage(a 0-based page index in the same document).
Font names are PostScript base names (Helvetica, Helvetica-Bold, Times-Roman, Courier-Oblique, …), unlike the kebab-case values of create text --font; an unknown name errors listing the valid set, and an absent one defaults to Helvetica. Unknown TOML keys are rejected, and every error message is prefixed with the manifest's path. Within a page, content lowers in schema order (text, then paragraphs, then images, then links) regardless of how the tables interleave in the file; TOML's separate arrays of tables carry no cross-type order.
[meta]
title = "Q3 Report"
author = "pdfboss"
[[page]]
size = "a4"
[[page.text]]
value = "Q3 Report"
at = [72, 770]
font = "Helvetica-Bold"
size = 28
[[page.paragraph]]
value = "Body copy for the quarter."
rect = [72, 380, 523, 720]
size = 11
leading = 15
align = "left"
[[page.image]]
path = "chart.png"
at = [72, 96]
width = 200
[[page.link]]
rect = [72, 88, 523, 380]
url = "https://example.com/q3"
pdfboss create manifest q3.toml -o q3.pdf
meta
Set one or more /Info fields, appending an incremental update by default (the input file's own bytes untouched) or, with --rewrite, writing the whole document fresh. See Editing PDFs.
pdfboss meta [OPTIONS] --out <OUT> --set <SET> <FILE>
-o, --out <OUT>: output file (required)--set <KEY=VALUE>: metadata assignment, repeatable and required at least once;KEYis one oftitle,author,subject,keywords,creator,producer--rewrite: full rewrite instead of an incremental append--password <PASSWORD>: opens an encrypted input for reading; an encrypted input is still refused at write time, in either mode
pdfboss meta report.pdf -o report-titled.pdf --set title="Q3 Report" --set author="Finance"
merge
Combine selected pages from several inputs into one fresh document. See Assembling documents.
pdfboss merge [OPTIONS] --out <OUT> <INPUTS>...
<INPUTS>...: one or more inputs, each optionallyFILE:RANGE(1-based, e.g.report.pdf:2-9); a bare path takes every page-o, --out <OUT>: output file (required)--password <PASSWORD>: one password tried against every encrypted input; an encrypted input is still refused at write time
pdfboss merge report.pdf:2-9 appendix.pdf -o combined.pdf
split
Cut a document into consecutive chunks of pages. See Assembling documents.
pdfboss split [OPTIONS] --out <OUT> --every <EVERY> <FILE>
-o, --out <OUT>: output pattern containing%d, substituted with the 1-based part number (required)--every <EVERY>: pages per part (required); the last part carries whatever remains--password <PASSWORD>: password for an encrypted file (user or owner password); an encrypted input is still refused at write time
pdfboss split report.pdf -o 'part-%d.pdf' --every 10
rotate
Rotate selected pages by a quarter-turn multiple, clockwise. See Assembling documents.
pdfboss rotate [OPTIONS] --out <OUT> --by <BY> <FILE>
-o, --out <OUT>: output file (required)--pages <PAGES>: 1-based pages, e.g.2,4-9; every page when omitted--by <BY>: quarter turns clockwise, one of90,180,270(required)--rewrite: full rewrite instead of an incremental append--password <PASSWORD>: password for an encrypted file (user or owner password); an encrypted input is still refused at write time
Either mode refuses a page inlined directly into /Kids with no object of its own: pdfboss does not yet restructure such a page to rotate it.
pdfboss rotate report.pdf -o rotated.pdf --pages 2,4-9 --by 90
overlay
Draw the first page of <OVERLAY> onto every page of <FILE>, on top by default or beneath with --under. See Editing PDFs.
pdfboss overlay [OPTIONS] --out <OUT> <FILE> <OVERLAY>
-o, --out <OUT>: output file (required)--under: draw beneath the page content instead of on top of it--rewrite: full rewrite instead of an incremental append--password <PASSWORD>: password for an encrypted file (user or owner password), tried against both<FILE>and<OVERLAY>; either encrypted input is still refused at write time
pdfboss overlay report.pdf draft.pdf -o out.pdf --under
rewrite
Rewrite a document fresh: recompressed, unreachable objects and earlier update sections left behind, with no page change. See Assembling documents.
pdfboss rewrite [OPTIONS] --out <OUT> <FILE>
-o, --out <OUT>: output file (required)--password <PASSWORD>: password for an encrypted file (user or owner password); an encrypted input is still refused at write time
pdfboss rewrite report.pdf -o rewritten.pdf
encrypt
Encrypt a document with AES-256, revision 6, writing a fresh output. See Encrypting a file.
pdfboss encrypt [OPTIONS] --out <OUT> <FILE>
-o, --out <OUT>: output file (required)--user-password <PASSWORD>: password readers must supply to open the file--owner-password <PASSWORD>: owner password; falls back to--user-passwordwhen omitted--allow <VALUES>: comma-separated permissions granted to a reader opening under the user password; one or more ofprint,modify,copy,annotate,fill-forms,accessibility,assemble,print-hires; every permission when omitted--password <PASSWORD>: opens an input that is itself encrypted, so it re-encrypts under the new passwords instead of refusing
At least one of --user-password/--owner-password must be set; both empty is refused.
pdfboss encrypt report.pdf -o locked.pdf --user-password hunter2 --allow print,copy
decrypt
Remove encryption from a document, writing a fresh plain output. See Removing encryption.
pdfboss decrypt [OPTIONS] --out <OUT> <FILE>
-o, --out <OUT>: output file (required)--password <PASSWORD>: password for the encrypted file (user or owner password)
pdfboss decrypt locked.pdf -o report.pdf --password hunter2
Python API reference
The pdfboss package re-exports the compiled extension module pdfboss._pdfboss. Its public API is the document, page, layout and iteration classes, thirteen classes for the interactive form and the catalog's document-level structures, the md and write submodules and the __version__ string; the typed stubs in _pdfboss.pyi are the authoritative reference for every signature and docstring. This chapter is the inventory; worked examples live in the guide chapters.
The document classes
| Name | What it is |
|---|---|
Document | A loaded PDF, from a path or bytes; pages by index, the metadata property, extract_text, extract_markdown, layout, render_pages, elements, spans, plus the form, catalog and document structure readers below |
Page | One page: geometry (width/height/rotation and the five boxes), extract_text, extract_markdown, layout, spans, images, render, render_reporting, extract_images, plus piece_info, thumbnail, thumbnail_image, beads, presentation, viewports, separation_info |
ReadingOrder | CONTENT, STRUCTURE_TREE, GEOMETRIC: the reading_order keyword every extraction method takes, as the enum or its string value |
AsyncDocument | The async twin of Document, opened from a path, bytes, or an HTTP URL via range requests; data-fetching methods are coroutines |
AsyncPage | The async twin of Page; attributes are synchronous, extraction and rendering are coroutines |
Element | One physical or logical element of a PDF (kind, span, ref, page, lazy value()), yielded by elements |
ElementIter | Lazy sync iterator over elements; each step releases the GIL |
AsyncElementIter | Async iterator over elements; each step is a coroutine |
Span | One styled text span: text, position, bbox with ascent/descent, font identity, bold/italic/monospace/serif, font family/stretch/weight, underline/strikethrough/highlight with highlight color, rise, vertical, invisible, color |
SpanIter | Lazy sync iterator over a document's spans, buffering one page at a time |
AsyncSpanIter | Async iterator over a document's spans |
PageImage | One embedded image extracted from a page: native width/height and PNG-encoded data |
PlacedImage | One image a page draws, where it is drawn: page, device-space bbox, native width/height, stencil, inline; yielded by Page.images without decoding pixels |
LayoutBlock | One block of a page's layout from layout: kind (heading, paragraph, list, table), heading level, paragraph role, bbox in PDF user space, plain text, list items, table rows |
LayoutListItem | One list item of a LayoutBlock: marker, text, bbox |
LayoutCell | One table cell of a LayoutBlock: text, colspan, rowspan |
PdfError | The exception type for any PDF processing error |
Document.metadata is a property returning the document information
dictionary as a dict[str, str], only keys present in the file included;
AsyncDocument.metadata() is a coroutine yielding the same mapping.
AsyncDocument also has page(index) (synchronous, 0-based, no negative
indexes; subscription doc[i] accepts them) and get_object(num, gen=0), a
coroutine fetching one indirect object and returning it through the same
plain-Python conversion as Element.value().
The form and catalog classes
Ten Document methods read the interactive form and the catalog's document-level structures, each with an AsyncDocument coroutine of the same name and result: interactive_form(), form_fields(), outline(), named_destinations(), page_labels(), page_label(index), embedded_files(), embedded_file_data(file), viewer_preferences() and extensions(). They return frozen instances of the classes below; every enumeration is a kebab-case string ("check-box", "roman-upper", "fit-r"), every object reference a (num, gen) tuple like Element.ref, and a field's value plain Python data through the Element.value() conversion. form_fields, outline, named_destinations and embedded_file_data release the GIL while they read.
| Name | What it is |
|---|---|
InteractiveForm | The form dictionary: root fields, need_appearances, signatures_exist, append_only, calculation_order, default_resources, default_appearance, quadding, xfa |
FormField | One field with inherited entries filled in: ref, parent, kids, widgets, field_type, name (fully qualified), partial_name, alternate_name, mapping_name, flags, value, default_value, default_appearance, quadding, default_style, rich_text, max_len, options, top_index, selected_indices, additional_actions, lock, seed_value; the typed readers text, button_kind, state, checked, on_widgets, signature, selected |
FieldFlags | The flag word as bits, the set flags' names, and one boolean per flag (read_only, required, multiline, combo, radio, pushbutton, …) |
Widget | One widget annotation of a field: ref, appearance_state, on_state, characteristics |
AppearanceCharacteristics | A button widget's captions and icons: caption, rollover_caption, alternate_caption, icon, rollover_icon, alternate_icon, caption_position |
ChoiceOption | One choice option or button export value: export_value, name |
Signature | A signature dictionary read as data, nothing verified: filter, sub_filter, byte_range, contents, name, signing_time, location, reason, contact_info |
DefaultAppearance | A parsed default appearance string, from DefaultAppearance.parse(da): font, font_size, fill_color |
OutlineItem | One bookmark: title, destination, page, open, color, italic, bold, structure_element, nested children |
Destination | A page and how it is shown: page (0-based, resolved through the page tree), page_ref, fit, left, top, right, bottom, zoom |
PageLabel | One page-numbering range: first_page, style, prefix, start_at, label(index); write.PageLabel takes the same fields |
EmbeddedFile | One attachment: name, file_name, description, file_system, volatile, ref, mime, size, created, modified (ISO 8601 strings), checksum |
ViewerPreferences | The catalog's viewer preferences with the standard's defaults filled in: the five hide/fit/center flags, display_doc_title, non_full_screen_page_mode, direction, the four view and print boxes, print_scaling, duplex, pick_tray_by_pdf_size, print_page_range, num_copies |
DeveloperExtension | One catalog extension: prefix, base_version, extension_level |
import pdfboss
doc = pdfboss.Document("form.pdf")
for field in doc.form_fields():
if field.field_type == "text":
print(field.name, field.text)
elif field.button_kind == "check-box":
print(field.name, field.checked)
elif field.field_type == "choice":
print(field.name, field.selected)
for item in doc.outline():
print(item.title, item.page)
for attachment in doc.embedded_files():
open(attachment.name, "wb").write(doc.embedded_file_data(attachment))
Guide chapters with runnable examples: Extracting text, Markdown output, Styled spans, Rendering pages, Extracting images, Reading forms, bookmarks and attachments, Creating PDFs, Markdown to PDF, Async and remote documents, Encrypted documents, Editing PDFs, Assembling documents.
The document and page structure classes
Nine more Document methods read structures beyond the catalog readers above: linearization(), is_linearized(), output_intents(), optional_content_groups(event), piece_info(), articles(), permission_handlers(), requirements() and legal_attestation(). AsyncDocument has each under the same name; the first two are plain calls there too, since the file head is read at open, the rest are coroutines. Ten Page methods read a page's own structures, each with an AsyncPage coroutine twin: piece_info(), thumbnail(), thumbnail_image(compression), beads(), presentation(), viewports(), separation_info(), annotations(), additional_actions() and content_items(). Document.additional_actions() reads the catalog's trigger events and Document.file_spec_data(spec) decodes the file a FileSpec embeds, both with AsyncDocument twins. The conventions above hold: enumerations are kebab-case strings, object references (num, gen) tuples, dates ISO 8601 strings, and every reader that resolves objects releases the GIL.
| Name | What it is |
|---|---|
Linearization | The linearization parameter dictionary as written: version, file_length, hint_streams, first_page_object, first_page_end, page_count, main_xref_offset, first_page; Document.is_linearized() applies the length rule |
OutputIntent | One output intent: subtype, output_condition, output_condition_identifier, registry_name, info, destination_profile (a reference) |
OptionalContentGroup | One optional content group (a PDF layer): ref, name, intent, usage, visible under the event Document.optional_content_groups(event) was asked for: "view" (the default, also what extraction and rendering use), "print", "export", or None for the default configuration alone |
OptionalContentUsage | A group's usage dictionary: view, print, print_subtype, export, zoom_min, zoom_max, language, language_preferred, page_element, creator, creator_subtype, user_type, user_names |
PagePiece | One product's entry in a page-piece dictionary: product, last_modified (ISO 8601), private (plain Python data) |
Thumbnail | A page's thumbnail as written: width, height, bits_per_component, color_space, decode; Page.thumbnail_image() decodes it to a PageImage |
ArticleThread | One article thread: ref, info (the keys of Document.metadata), beads in reading order |
Bead | One bead: ref, page (0-based, resolved through the page tree), page_ref, rect |
Presentation | A page's display in a presentation: duration, transition |
Transition | A page transition with the standard's defaults filled in: style, duration, dimension, motion, direction (degrees or None), scale, opaque |
PermissionHandlers | The catalog's certifying and usage rights signatures, read as data: doc_mdp, usage_rights (each a Signature or None) |
Requirement | One entry of the catalog's requirements array, a feature a reader needs to show the document as intended: kind ("EnableJavaScripts" or the name as written), handlers |
RequirementHandler | One handler for a reader that lacks the feature, reported and never run: kind ("JS", "NoOp" or the name as written), script |
LegalAttestation | The catalog's legal attestation: one integer attribute per count in Table 259 (java_script_actions, uri_actions, non_embedded_fonts, ...), attestation, and counts() for the whole table as a dict |
Viewport | One measurement viewport of a page: bbox, name, measure |
Measure | How a viewport's coordinates convert to real-world units, with the standard's defaults filled in: subtype, scale_ratio, the number-format chains x, y, distance, area, angle, slope, plus origin and y_to_x |
NumberFormat | One unit of a chain: unit, conversion, fraction ("decimal", "fraction", "round", "truncate"), precision, fixed_denominator, thousands, radix, prefix_spacing, suffix_spacing, label ("suffix" or "prefix") |
SeparationInfo | A pre-separated page's separation dictionary: pages (0-based, None where the reference names no page), page_refs, device_colorant, color_space (plain Python data) |
Annotation | One annotation of a page, Table 164 read as data: ref, subtype, rect, contents, name, modified and modified_date, flags, has_appearance, appearance_state, border, color, struct_parent, optional_content; then markup, state and state_model, open, icon, parent, file, destination (a link's /Dest), action and additional_actions |
AnnotationFlags | The /F word with one boolean per flag: invisible, hidden, print, no_zoom, no_rotate, no_view, read_only, locked, toggle_no_view, locked_contents, plus value |
Border | The /Border array: horizontal_radius, vertical_radius, width, dash |
Markup | The Table 170 entries of a markup annotation: title, popup, opacity, rich_contents, created, in_reply_to, subject, reply_type ("reply" or "group"), intent |
FileSpec | A file specification: name, file, unicode_file, description, file_system, url (for /FS /URL), volatile, ref (the embedded stream, decoded by Document.file_spec_data) |
Action | One action dictionary, never executed: kind (the /S name as written), destination and named_destination for the go-to family, file and new_window, target for an embedded go-to, windows for a launch, uri and is_map, name for a named action, script for JavaScript, entries (the whole dictionary) for any other kind, and next (the /Next chain) |
Target | One step of an embedded go-to action's path: relationship, name, page, annotation, next |
WindowsLaunch | A launch action's Windows parameters: file, directory, operation, parameters |
TriggeredAction | One entry of an additional-actions dictionary: trigger (kebab-case, "cursor-enter", "page-open", "will-close" and the rest of Tables 194 to 197) and action |
ContentItem | One content item of a page in structure-tree order (14.7.4), returned by Page.content_items(): kind ("sequence" or "object"), mcid and struct_parents for a marked-content sequence, ref for an annotation the tree holds through an object reference (14.7.4.3), rank, the holding element's structure_type, mapped_type, standard_type and path, plus alt, lang, expansion |
import pdfboss
doc = pdfboss.Document("slides.pdf")
print(doc.is_linearized(), [intent.subtype for intent in doc.output_intents()])
for thread in doc.articles():
print(thread.info.get("title"), [(bead.page, bead.rect) for bead in thread.beads])
page = doc[0]
shown = page.presentation()
if shown and shown.transition:
print(shown.duration, shown.transition.style)
thumb = page.thumbnail_image()
if thumb:
open("thumb.png", "wb").write(thumb.data)
for viewport in page.viewports():
scale = viewport.measure
if scale:
print(viewport.name, scale.scale_ratio, [f.unit for f in scale.distance])
separation = page.separation_info()
if separation:
print(separation.device_colorant, separation.pages)
for requirement in doc.requirements():
print(requirement.kind, [handler.kind for handler in requirement.handlers])
legal = doc.legal_attestation()
if legal:
print(legal.attestation, legal.counts())
for annotation in page.annotations():
action = annotation.action
if annotation.subtype == "Link" and action and action.kind == "URI":
print(annotation.rect, action.uri)
elif annotation.destination:
print(annotation.rect, "-> page", annotation.destination.page)
if annotation.markup:
print(annotation.markup.title, annotation.contents, annotation.state)
if annotation.file and annotation.file.ref:
open(annotation.file.name, "wb").write(doc.file_spec_data(annotation.file))
for triggered in page.additional_actions() + doc.additional_actions():
print(triggered.trigger, triggered.action.kind)
for item in page.content_items():
print(item.rank, item.standard_type, item.ref if item.kind == "object" else item.mcid)
The md submodule
pdfboss.md.to_pdf(markdown, theme=None, size="a4", landscape=False, base_dir=None) composes CommonMark+GFM source into a themed PDF and returns the file bytes. theme is CSS source text, not a path; size is a page size name (a0 to a10, b0 to b10, letter, legal, tabloid) or <width>x<height> in pt, mm, cm or in, and an unknown one raises PdfError; replaced characters and skipped raw HTML surface as one UserWarning. Details and examples in Markdown to PDF.
Canvas-level and element-level creation from Python is the write submodule below.
The write submodule
pdfboss.write composes new PDFs from frozen values joined with |; the same vocabulary is the Rust crate pdfboss-write and the pdfboss create CLI, and worked examples live in Creating PDFs. Fourteen classes:
| Name | What it is |
|---|---|
Pdf | A document under construction; composes pages, attachments and page labels (appended) and the singleton Metadata, Outline and Viewer slots; save(path) and to_bytes() serialize |
Page | One page (size names a size case-insensitively, default "a4"; landscape swaps width and height); composes Text, Image, Link, Paragraph or any draw object |
Text | One line of text: value, at=(x, y) baseline origin, font (default Standard14.HELVETICA), size (default 12.0), optional (r, g, b) color |
Paragraph | Wrapped, aligned text: text, rect, font, size (default 11.0), leading (default derived from size), align of left, center, right or justify |
Image | A placed raster: data as a path string or bytes, at, optional width/height; either source is read and decoded at save/to_bytes time |
Link | A clickable rectangle: rect plus exactly one of url or page (0-based) |
Bookmark | One outline entry: title, page, keyword-only children; nests by construction |
Outline | The bookmark panel, Outline(*bookmarks); a singleton Pdf slot |
Attachment | An embedded file: name, data, optional mime and description; carries no dates |
PageLabel | One page-numbering range: first_page (0-based), optional style (decimal, roman-upper, roman-lower, letters-upper, letters-lower), prefix, start_at (default 1) |
Viewer | Opening preferences: layout, mode, open_to; a singleton Pdf slot |
Metadata | /Info text fields: title, author, subject, keywords, creator, producer; a singleton Pdf slot |
Standard14 | The fourteen standard faces as SCREAMING_SNAKE members: HELVETICA, TIMES_BOLD_ITALIC, ZAPF_DINGBATS, … |
Canvas | The painting surface handed to a draw object's draw method; it has no public constructor |
Every | returns a new value and leaves the receiver unchanged; copies are cheap handle clones, and nothing is built until save or to_bytes, which lower the composition once under the GIL and release it to serialize. to_bytes may be called repeatedly, since the composed value is never consumed.
The draw protocol is structural: any object with a callable draw attribute composes onto a Page, and its draw(canvas) receives a Canvas with twelve methods (text, line, rect, move_to, line_to, curve_to, close, stroke, fill, set_fill, set_stroke, set_line_width) that paints in content order. The stub declares a Draw protocol type for checkers only; there is no runtime Draw class to import or inherit. The canvas is only usable inside the call, and every method raises PdfError once draw has returned.
Two more names work on existing files, neither in the table above since neither composes with |: watermark(data, overlay, *, rewrite=False, under=False) takes two PDFs as bytes and returns data with the first page of overlay drawn over every page, as an incremental update appended to data's bytes, or with rewrite=True as a fresh, compressed file; under=True draws the overlay beneath each page's own content instead of over it (see Watermarking an existing file); Update(doc) stages a metadata edit over an existing Document, with set_metadata(title=None, author=None, subject=None, keywords=None, creator=None, producer=None) merging the given fields and save(path) or to_bytes() writing the result as an incremental update (see Editing PDFs). Both release the GIL while they run. Update always raises PdfError for an encrypted base, whether or not it opens. watermark's default (rewrite=False, appending) does the same for an encrypted data or overlay; with rewrite=True, only a data or overlay that cannot be opened at all raises PdfError, and one that opens under its password, including the empty user password, copies across as plain, unencrypted bytes instead.
Six more functions assemble documents from existing file bytes, also outside the table since none composes with | (see Assembling documents): merge(inputs) gathers each item, raw bytes for every page or a (bytes, list[int]) tuple for specific 0-based pages, into one fresh document in argument order; split(data, every) cuts data into consecutive parts of every pages, the last part carrying whatever remains; split_parts(data, every, *, password="") yields the same parts one at a time as a SplitIterator iterator usable with both for and async for; split_stream(stream, every, *, password="") does the same for a PDF arriving as an async iterable of bytes chunks, collecting the chunks before the first part is built (see One part at a time); rotate(data, by, pages=None, rewrite=False) turns pages (0-based, every page when omitted) by by degrees clockwise, restricted to 90, 180 or 270 (else ValueError), appending an incremental update by default or writing a fresh file with rewrite=True; rewrite(data) writes data fresh, recompressed with unreachable objects and earlier update sections left behind. every below 1 raises ValueError from all three split functions at the call. merge, the split functions and rewrite always produce fresh files; only rotate can append instead, the same choice Update/meta offers. An input that cannot be opened at all (encrypted with no working password) raises PdfError from merge, split, rotate and rewrite immediately, before any assembly work begins; split_parts and split_stream parse lazily, so they raise it from the first part instead, split_stream after the whole stream has been read. One that opens under its password, including the empty user password, passes through merge, split and rewrite as plain, unencrypted bytes, through split_parts and split_stream the same way when opened with their password, and through rotate the same way when rewrite=True; rotate's default appending mode still raises PdfError for any encrypted input, opened or not. An owner-password-only file therefore loses its /P restrictions the moment its holder runs it through rewrite (or merge, a split function, or rotate(rewrite=True)): the freshly written output carries no encryption at all, restrictions included. rotate also refuses, in either mode, a page inlined directly into /Kids with no object of its own, since pdfboss does not yet restructure such a page to rotate it.
Two more functions protect or remove protection from a document's bytes, also outside the table above (see Encrypted PDFs): encrypt(data, *, user_password="", owner_password="", allow=None) AES-256, revision-6 protects data and returns the fresh file, restricted by allow, a list drawn from print, modify, copy, annotate, fill-forms, accessibility, assemble and print-hires (every permission when omitted); an unknown allow value raises ValueError naming it, and so does leaving both user_password and owner_password empty, while an empty owner_password alone falls back to user_password. decrypt(data, *, password="") opens data under its user or owner password and returns a fresh, unencrypted file; a wrong or missing password raises PdfError. Both release the GIL around the actual work.
Error handling
Everything raises PdfError: bad or truncated data, unsupported encryption, stream decode failures and I/O errors, with the underlying detail in the message. Messages from the element and async APIs are prefixed by the layer they came from: "parse: …", "io: …" or "http: …".
import pdfboss
try:
doc = pdfboss.Document("report.pdf")
except pdfboss.PdfError as e:
print(f"could not open: {e}")
Two conventional exceptions apply where Python conventions demand them: constructing a Document with neither or both of path and data raises ValueError (as does a non-positive scale, an unknown fonts=, compression= or format= string, a quality= outside 1 to 100, or an unusable fonts="full" setup in render), and an out-of-range page index raises IndexError. Element iterators have salvage semantics: a per-item failure raises PdfError for that item, and iteration may be continued. Span iterators raise PdfError when a page cannot be materialized.
The write submodule splits its failures by phase. TypeError is raised at construction for a Link with neither or both of url and page, an unknown align, page-label style, viewer layout or mode, or Image data that is neither str nor bytes; and at composition for an unsupported | operand or a second Metadata, Outline or Viewer. Everything that fails while lowering (an unreadable or undecodable image file, a paragraph overflowing its rect, an unencodable character, a target page out of range) raises PdfError from save/to_bytes. A draw object's Canvas stops working the moment its draw call returns: any later method call on it raises PdfError at that call site. An exception raised inside draw() itself propagates from save/to_bytes exactly as the Python code raised it.
Threading
A Document (and any Page it hands out) may be used from any thread. Access to the underlying parsed document is serialized internally, and extract_text/render release the GIL while they run, so other Python threads keep making progress during long extractions or renders. Element and span iteration release the GIL per step the same way. Document.render_pages fans page rendering out across the machine's cores.
The async API needs no thread juggling: AsyncDocument's coroutines are driven by one global multi-thread tokio runtime, and AsyncDocument.render_pages fans out as tokio tasks so the asyncio loop stays free.
Rust crate reference
pdfboss is a workspace of focused crates, all sharing one version. Add the ones you need with cargo add; each crate's API reference lives on docs.rs.
| Crate | Responsibility | Docs |
|---|---|---|
pdfboss-core | PDF syntax, objects, filters, cross-references and document model (ISO 32000) | docs.rs |
pdfboss-text | Font loading, encodings, ToUnicode CMaps and text extraction | docs.rs |
pdfboss-output | Layout analysis and output rendering: plain text and markdown | docs.rs |
pdfboss-encoding | Shared font encoding tables and glyph-name mappings (ISO 32000 Appendix D) | docs.rs |
pdfboss-jpx | Cleanroom JPEG 2000 (JPXDecode) decoder (ITU-T T.800) | docs.rs |
pdfboss-icc | Cleanroom ICC profile parser and colour transform (ICC.1:2010) | docs.rs |
pdfboss-render | Page rasterization to RGBA pixmaps and PNG, plus embedded-image extraction | docs.rs |
pdfboss-write | PDF creation: COS object writer, content canvas, composed elements (text, paragraph, image, link), outlines, attachments, page labels, viewer preferences, XMP metadata and document assembly | docs.rs |
pdfboss-style | CSS-subset themes for document composition | docs.rs |
pdfboss-markdown | CommonMark+GFM composed into themed PDFs | docs.rs |
pdfboss-aio | Async, range-fetching PDF access: huge files, many documents, remote HTTP sources | docs.rs |
pdfboss-cli | The pdfboss command-line tool | docs.rs |
pdfboss-tui | Terminal explorer for PDF internals: element tree, object inspector, hex view, page preview and Markdown preview | docs.rs |
pdfboss-py | PyO3 extension module pdfboss._pdfboss, built with maturin | not on crates.io; ships as the pdfboss wheel |
A further workspace member, pdfboss-testkit, is an internal PDF fixture builder for the test suite; it is not published.
Where to start
- Reading a document:
pdfboss_core::Document(open,load, and their_with_passwordtwins), thenpage,page_count,metadata,version. - Text and markdown:
pdfboss_output::{extract_text, extract_markdown}, each taking aReadingOrder(Content,StructureTree,Geometric); positioned styled spans viapdfboss_text::extract_spans; where each image is drawn, without decoding it, viapdfboss_text::placed_images. - Rasterizing:
pdfboss_render::{render_page, render_page_with_options, render_page_reporting}andPixmap::save_png; embedded images viaextract_page_images. - Creating:
pdfboss_write::{Pdf, Page, Canvas, Content}; see Creating PDFs. - Composing Markdown:
pdfboss_markdown::to_pdfwith apdfboss_style::Theme; see Markdown to PDF. - Async and HTTP sources:
pdfboss_aio::AsyncDocument(open,open_url,from_bytes); see Async and remote documents. - Element iteration:
pdfboss_core::Document::elements(ElementOpts), a lazy iterator over physical and logical elements, and the asyncAsyncDocument::elements, which returns anElementStream; see Exploring PDF internals. - Forms, bookmarks and attachments:
Document::{interactive_form, form_fields, outline, named_destinations, page_labels, embedded_files, embedded_file_data, viewer_preferences}, each with a*_with(src, trailer)twin for an async source (pdfboss_core::{form_fields_with, outline_with, ...}); see Reading forms, bookmarks and attachments. - Document-level structures:
pdfboss_core::{linearization_dictionary, output_intents_with, articles_with, page_beads_with}and the page thumbnail, piece-info and presentation readers under the same naming. - Structure tree:
pdfboss_core::StructureTree(load_with,place_with,place_items_with,content_items_with, andcontent_item_counts_withfor a summary count that reads no element) andDocument::content_items(&page), a page's marked-content sequences and tagged annotations in tree order; see Annotations in the reading order. - Editing existing files:
pdfboss_write::{merge_documents, split_document, rotate_rewrite, rewrite_document, encrypt_document, decrypt_document}for a fresh document through theImporter,pdfboss_write::{split_runs, split_part, copy_pages_with}to build one part at a time, synchronously from aDocumentor from anyAsyncObjectSourceinto any byte sink, andpdfboss_write::{Update, rotate_pages, set_metadata_with, watermark}for an incremental update; see Editing PDFs, Assembling documents and Encrypted PDFs.
The guide chapters carry compiled examples for each of these; the Quickstart has the shortest end-to-end one.
Limitations
Rendering is lenient and it says so: content pdfboss cannot read is skipped so the rest of the page still rasterizes, and every dropped or approximated item lands in a report. pdfboss render warns on stderr, the terminal explorer raises a notice, and the libraries return it through render_page_reporting (Rust) and Page.render_reporting() (Python). See Rendering pages for the reporting APIs.
The whole not-yet-supported list is two faces: /Symbol and /ZapfDingbats have no license-clean substitute, so they stay blank rather than borrowing an unrelated face's glyphs.
Clause by clause, the ISO 32000 conformance chapter lists every part of the standard with the status pdfboss claims for it and what is missing where the status is not implemented.
Fonts
Glyph painting is staged in tiers (embedded-only, all-embedded, full) selected with --fonts (CLI), the fonts parameter (Python) or RenderOptions::glyph_painting (Rust); the tiers are described in Rendering pages. What stays limited:
fullsubstitutes only non-embedded simple fonts, and a bold sans substitute is not visually distinct from regular weight.- Standard-14 advance widths come from the Adobe Core-14 AFM tables, behind the PDF's own
/Widths, both when a substitute is painted and when text extraction reports a span'send_xandbbox.
CMaps and CID fonts
Type0 /Encoding CMaps resolve: the predefined ISO 32000 Table 118 CJK set is compiled in (behind the predefined-cmaps feature, on by default in the CLI and the wheel), embedded CMap streams parse the same way, widths key on the mapped CID, vertical text (WMode 1) advances by /W2//DW2 with the default position vector, and extraction maps CIDs to Unicode through the character collection when /ToUnicode is absent.
Deferred: vertical runs still extract as horizontal-schema spans, one per show operator, with x/y at the glyph origin.
Turned text
Text drawn turned a quarter is grouped into lines in its own upright frame (see Extracting text). What stays limited:
- Text at other angles is treated as the nearest quarter turn, so a line set at 30 degrees can still split.
- Underline, strikethrough and highlight are judged in page space, so on turned text they are unreliable.
- A turned span carries no
glyph_x, so combining marks in turned non-Latin text are joined without glyph positions. - In layout output, the lines of a turned block keep the coordinates of their upright frame; the block's
bboxis in page space.
JBIG2
JBIG2Decode covers the embedded stream format end to end: generic regions (all four templates, with TPGDON, arithmetic or MMR-coded), symbol dictionaries and text regions in both the arithmetic and the Huffman variant (refinement/aggregate-coded symbols and refined instance placements included), pattern dictionaries and halftone regions, generic refinement regions (both templates, with TPGRON) refining either the page or a retained intermediate region, intermediate regions of every type, and custom code table segments. Nothing in the standard's segment type table is refused; a malformed or truncated stream fails with a message naming what was wrong instead of rendering a blank.
Colour
Colour converts to sRGB. ICCBased spaces parse their embedded profile (v2 and v4; matrix/TRC and grayTRC models, and A2B0 lookup pipelines): a profile equivalent to sRGB keeps the exact device-RGB path, others transform per colour with Bradford adaptation from the D50 connection space, and a profile that will not parse falls back to the /N channel-count reduction. CalRGB, CalGray, and Lab convert through CIE XYZ the same way.
Only a profile's default transform is used (rendering intents are not switched), and DeviceN keeps a tint approximation.
JPEG 2000
JPXDecode implements ITU-T T.800 (JPEG 2000 Part 1); what it approximates it reports as a render warning rather than passing off silently.
ICC profiles embedded in the JPEG 2000 container are interpreted through the same ICC engine as ICCBased colour (a profile equivalent to sRGB or device gray keeps the exact device path, others transform per sample), and only a profile that will not parse falls back to the channel-count approximation. sYCC converts with the exact IEC 61966-2-1 Amd. 1 inverse.
Part 2 (ISO/IEC 15444-2) extensions are tolerated in the container but not decoded. Every output sample is normalized to 8 bits per channel with round-to-nearest, so sources deeper than 8 bits (the spec allows up to 38) still land on an 8-bit output grid.
Annotations
Appearance streams paint as the file carries them, and Square, Circle, Line, Polygon, PolyLine, Ink, text markup and Link annotations get an appearance built from their entries when they have none. A widget annotation without an appearance stream paints nothing: form field values are read through form_fields() but not rendered. Annotations are never written.
Transparency groups
A transparency group composites as one object with the alpha, blend mode and soft mask at its Do. Every group renders isolated (/I is not consulted); /K true is not honoured and is reported as a skipped knockout group; the group colour space /CS is never read, so blending stays in device RGB.
Optional content
Optional content groups (PDF layers, ISO 32000 §8.11) are honored per the document's default configuration: rendering and text extraction skip layers it turns off, counting them on the reports' hidden counters.
ISO 32000 conformance
pdfboss keeps a ledger of ISO 32000 clauses in iso32000/Iso32000/Catalogue/, one row per
clause with the status pdfboss claims: implemented, incomplete, not implemented or out of
scope. Clause numbers follow ISO 32000-1:2008; where ISO 32000-2:2020 numbers a clause
differently the row carries both.
The ledger is checked by Lean 4 on every push (make iso32000-gate). Six theorems in
iso32000/Iso32000/Gate.lean establish that every row is unique, that every clause of chapters
7 to 14 and the normative annexes has a row speaking to it, that every implemented row is cited
by both implementation code and a test in the source tree, that every incomplete row is cited by
implementation code and explains what is missing, that every other row explains itself, and that
no first-edition citation names a clause the standard does not have. They are decided by Lean's
compiled evaluator (native_decide), so they trust the Lean compiler as well as its kernel; the
decode-after-encode theorems of the reference filter decoders in iso32000/Iso32000/Reference/,
whose vectors the Rust tests replay, are checked by the kernel alone. Citation counts below are
the number of source comments naming the clause or a sub-clause of it, in implementation code
and in tests.
This page is generated by make iso32000-report. Edit the ledger, not the page.
| Status | Rows |
|---|---|
| implemented | 277 |
| incomplete | 81 |
| not implemented | 42 |
| out of scope | 74 |
7 Syntax
| Clause | Feature | Status | Code citations | Test citations | Note |
|---|---|---|---|---|---|
| 7.2.2 | Character Set | implemented | 7 | 2 | Table 1 white-space bytes, Table 2 delimiters and regular bytes classify every token; the writer escapes delimiter bytes in names (crates/pdfboss-write/src/ser.rs write_name). |
| 7.2.3 | Comments | implemented | 1 | 2 | A percent sign skips to CR or LF everywhere tokens are read; the header and startxref comments are read by their own scanners (document.rs, xref.rs). |
| 7.3.2 | Boolean Objects | implemented | 2 | 2 | The true and false keywords parse to Object::Bool; the writer serializes them (crates/pdfboss-write/src/ser.rs, test ser.rs scalars_serialize_exactly). |
| 7.3.3 | Numeric Objects | implemented | 2 | 4 | Integers read as i64 and reals as f64 with sign and leading-dot forms, malformed runs cleaned leniently; the writer emits plain decimals without exponents (crates/pdfboss-write/src/ser.rs, test ser.rs real_pins_exact_bytes). |
| 7.3.4 | String Objects | implemented | 5 | 9 | Both string forms decode to raw bytes in Object::String; the writer picks the literal or hex form from the bytes (crates/pdfboss-write/src/ser.rs write_string, tests ser.rs strings_use_literal_form_with_escapes and ser.rs strings_fall_back_to_hex_form). |
| 7.3.4.2 | Literal Strings | implemented | 1 | 4 | Balanced parentheses, all Table 3 escapes, one to three digit octal codes, backslash line continuation and EOL normalization to LF are covered, and an unknown escape drops the backslash as the clause requires. |
| 7.3.4.3 | Hexadecimal Strings | implemented | 2 | 2 | White space inside the brackets is skipped and an odd final digit is read as the high nibble; non-hex bytes are skipped leniently (the implementation comment at lexer.rs cites the clause, the tests do not). |
| 7.3.5 | Name Objects | implemented | 1 | 1 | Number-sign escapes decode, the empty name is accepted and a bad escape is kept literally; the writer escapes bytes outside 0x21-0x7E and rejects NUL (crates/pdfboss-write/src/ser.rs and ser.rs, tests ser.rs names_escape_irregular_bytes and ser.rs names_with_nul_bytes_are_rejected). |
| 7.3.6 | Array Objects | implemented | 1 | 3 | Heterogeneous nested arrays parse with a nesting cap and an unterminated array ends leniently at end of input; the writer separates items with single spaces (crates/pdfboss-write/src/ser.rs arrays_separate_items_with_single_spaces). |
| 7.3.7 | Dictionary Objects | implemented | 2 | 3 | Name keys with any value parse, a non-name key is dropped and a value-less key maps to null; a null-valued entry is stored rather than treated as absent and a repeated key keeps the last value, while the writer sorts keys bytewise (crates/pdfboss-write/src/ser.rs dicts_sort_keys_bytewise). |
| 7.3.8 | Stream Objects | incomplete | 11 | 11 | The dictionary plus stream/endstream body, /Length, /Filter and /DecodeParms are handled, but the external-file entries (/F, /FFilter, /FDecodeParms) and /DL are never read; the writer refuses a stream nested inside another object (crates/pdfboss-write/src/error.rs, test ser.rs nested_streams_are_errors). |
| 7.3.8.1 | General | incomplete | 5 | 4 | Of the Table 5 entries only /Length, /Filter and /DecodeParms are consumed, so stream data kept in an external file (/F with /FFilter and /FDecodeParms) is never fetched and /DL is ignored. |
| 7.3.8.2 | Stream Extent | implemented | 1 | 5 | The EOL after the stream keyword (CRLF or LF, a lone CR tolerated) is skipped, a direct or indirect /Length is verified against the following endstream, and a wrong or missing length falls back to a scan for endstream that trims one trailing EOL. |
| 7.3.9 | Null Object | implemented | 2 | 3 | The null keyword parses to Object::Null and a reference to a missing object resolves to null; a null /Filter reads as no filter. |
| 7.3.10 | Indirect Objects | implemented | 4 | 8 | Object headers, endobj (lenient when missing), the N G R lookahead and the rule that a reference to a missing object is null are all covered, with generation mismatches tolerated; the writer emits N G R and numbers objects densely (crates/pdfboss-write/src/ser.rs refs_serialize_as_num_gen_r). |
| 7.4.2 | ASCIIHexDecode Filter | implemented | 3 | 7 | Hex pairs with white space skipped, the > EOD marker and an odd final digit padded with zero decode, and the AHx abbreviation is accepted. |
| 7.4.3 | ASCII85Decode Filter | implemented | 3 | 7 | Five-character groups, the z shorthand, the |
| 7.4.4 | LZWDecode and FlateDecode Filters | implemented | 13 | 29 | Both filters decode with the Table 8 parameters and the PNG and TIFF predictors at every bit depth (7.4.4.1 to 7.4.4.4); the writer compresses streams with Flate (crates/pdfboss-write/src/writer.rs compress_into, test writer.rs compressed_stream_round_trips). |
| 7.4.4.1 | General | implemented | 2 | 5 | FlateDecode accepts zlib (RFC 1950) and raw deflate data, returns the decoded prefix of a truncated stream and caps output size; LZWDecode is assessed under 7.4.4.2. |
| 7.4.4.2 | Details of LZW Encoding | implemented | 2 | 5 | Codes grow from 9 to 12 bits, clear-table (256) and EOD (257) codes, the KwKwK case and both EarlyChange width schedules decode; corrupt or truncated data keeps the decoded prefix. |
| 7.4.4.3 | LZWDecode and FlateDecode Parameters | implemented | 2 | 7 | Table 8 parameters (/Predictor, /Colors, /BitsPerComponent, /Columns, /EarlyChange) are read with their defaults, /DecodeParms arrays stay aligned with the /Filter array and indirect values resolve. |
| 7.4.4.4 | LZW and Flate Predictor Functions | implemented | 3 | 9 | PNG predictors 10 to 15 (per-row filter byte, all five filter types, sub-byte samples) and TIFF predictor 2 at 1, 2, 4, 8 and 16 bits per component decode (crates/pdfboss-core/src/filters/predictor.rs tiff_horizontal: bytes in place, big-endian 16-bit components, packed sub-byte components per colour with a row's padding bits left as stored). Tests: predictor.rs tiff_horizontal_diff_round_trips, tiff_16bit_samples_add_big_endian, tiff_4bit_samples_add_within_packed_bytes, tiff_2bit_samples_add_per_colour, tiff_1bit_samples_add_modulo_two, png_every_row_type_round_trips and png_row_length_rounds_up_for_sub_byte_samples. |
| 7.4.5 | RunLengthDecode Filter | implemented | 2 | 6 | Literal runs (0-127), repeat runs (129-255) and the 128 EOD byte decode, a missing EOD is tolerated and output size is capped. |
| 7.4.6 | CCITTFaxDecode Filter | implemented | 10 | 17 | Group 4 (K<0), pure 1-D Group 3 (K=0) and mixed Group 3 (K>0) decode to packed 1-bit samples, reading /K, /Columns, /Rows, /EncodedByteAlign, /BlackIs1 and /EndOfLine with Table 11 defaults; /EndOfBlock and /DamagedRowsBeforeError are described in comments but never read, the row count being inferred from the data instead. |
| 7.4.7 | JBIG2Decode Filter | implemented | 2 | 8 | The PDF embedded-stream organization with /JBIG2Globals decodes through a cleanroom codec (generic, refinement, symbol/text, pattern/halftone regions and MMR) and 1 bits are inverted to DeviceGray ink as the clause requires; the filter must be last in the chain. |
| 7.4.8 | DCTDecode Filter | implemented | 6 | 11 | The filter chain passes JPEG bytes through (crates/pdfboss-core/src/filters/mod.rs decode_stream) and the image layer decodes them with the external jpeg-decoder 0.3 crate (crates/pdfboss-render/src/image.rs decode_jpeg: gray, RGB and CMYK layouts, baseline and progressive). The colour transform follows Table 13: the Adobe APP14 flag when present (adobe_transform), else /ColorTransform from the trailing filter's decode parameters (dct_color_transform), else the crate's default of YCbCr for three components and none for four (jpeg_color_transform). Four-component samples are ink values as stored, the crate's 255 - ink output undone, and /Decode applies per component. Two-component JPEGs, which the clause allows, are refused by the crate; and the image's /ColorSpace is not consulted for JPEG data, which takes the device conversion for its component count (a limitation of the colour space rows under 8.6, not of the filter). Tests: image.rs dct_color_transform_zero_keeps_three_components_as_stored, dct_adobe_marker_overrides_color_transform, dct_color_transform_one_reads_four_components_as_ycck, dct_cmyk_samples_are_direct_ink_values. |
| 7.4.9 | JPXDecode Filter | implemented | 16 | 15 | A cleanroom JPEG 2000 decoder handles the codestream, the colour space comes from the codestream when the dictionary has none, /Decode is ignored except for image masks, /SMaskInData 1 and 2 (image.rs) and Indexed over JPX are honoured, and the filter must be last in the chain. |
| 7.4.10 | Crypt Filter | implemented | 6 | 6 | The decryptor reads a stream's own /Crypt filter when it loads the object (crates/pdfboss-core/src/crypt.rs stream_crypt_filter): /Name Identity, or no /Name, leaves the data as stored, a name from /CF decrypts with that filter's method and the file key as is, as the clause asks, and a name matching no /CF entry leaves the data as stored; decode_stream then treats Crypt as a no-op (crates/pdfboss-core/src/filters/mod.rs). Tests: crypt.rs a_stream_crypt_filter_naming_identity_is_left_as_stored, a_stream_crypt_filter_without_a_name_means_identity and a_stream_crypt_filter_naming_a_cf_entry_uses_the_file_key_as_is; filters/mod.rs crypt_filter_leaves_data_as_stored and crypt_filter_first_in_a_chain_is_a_no_op. |
| 7.5.2 | File Header | implemented | 2 | 2 | The %PDF-M.m header is searched in the first 1 KiB and defaults to 1.4 when absent, without shifting xref offsets for a header not at byte 0 (the recovery scan covers that case); the writer emits the header and a binary comment line (crates/pdfboss-write/src/writer.rs write_header, test writer.rs table_mode_minimal_document_loads). |
| 7.5.3 | File Body | implemented | 2 | 5 | Indirect objects are read at their cross-reference offsets, and when the table is unusable a whole-file scan for N G obj headers rebuilds it with the last occurrence winning. |
| 7.5.4 | Cross-Reference Table | implemented | 5 | 4 | Subsection headers and n/f entries are read token-wise so 19 and 21 byte lines load as well as 20 byte ones, though the free-entry linked list is not tracked; the writer emits exact 20-byte entries (crates/pdfboss-write/src/writer.rs emit_table and writer.rs table_offset, test writer.rs table_offsets_past_ten_digits_are_rejected). |
| 7.5.5 | File Trailer | implemented | 3 | 5 | The startxref offset is found in the last 1 KiB widening to 64 KiB and the trailer's /Size, /Root, /Info, /ID and /Encrypt are consumed, with %%EOF itself not required; the writer emits the trailer, startxref and %%EOF (crates/pdfboss-write/src/writer.rs trailer_dict). |
| 7.5.6 | Incremental Updates | implemented | 4 | 3 | The newest section wins and /Prev is followed with a visited-offset loop guard; the writer appends update sections chained by /Prev (crates/pdfboss-write/src/update.rs cites the clause; tests crates/pdfboss-write/tests/update_append.rs two_appends_chain and tests/test_update.py test_update_appends_and_preserves_bytes). |
| 7.5.7 | Object Streams | implemented | 6 | 3 | The /N and /First header pairs are parsed once and members are addressed by index and never decrypted separately, while /Extends is not needed for reading and not read; the writer packs objects into streams (crates/pdfboss-write/src/writer.rs build_objstm, test writer.rs object_streams_pack_and_resolve). |
| 7.5.8 | Cross-Reference Streams | implemented | 9 | 11 | Cross-reference streams are decoded through the ordinary filter chain and their dictionary serves as the trailer; the writer emits them by default (crates/pdfboss-write/src/writer.rs emit_stream, test writer.rs stream_mode_minimal_document_loads). |
| 7.5.8.2 | Cross-Reference Stream Dictionary | implemented | 1 | 2 | /W field widths of 0 to 8 bytes, /Size, /Index (defaulting to [0 Size]) and /Prev are read; /Type /XRef is not checked. |
| 7.5.8.3 | Cross-Reference Stream Data | implemented | 2 | 3 | Type 0, 1 and 2 entries are decoded as big-endian fields per /W, a zero-width type field defaults to type 1, unknown types read as free and truncated data ends the table leniently. |
| 7.5.8.4 | Compatibility with Applications That Do Not Support Compressed Reference Streams | implemented | 4 | 3 | A classic trailer's /XRefStm section merges ahead of its own table and before /Prev is followed (the fixture crates/pdfboss-testkit/src/lib.rs hybrid_doc documents the clause); the writer appends a classic table to a hybrid base (crates/pdfboss-write/tests/update_append.rs hybrid_base_appends_a_classic_table). |
| 7.6.2 | General Encryption Algorithm | implemented | 4 | 6 | Algorithm 1 per-object keys (with the sAlT suffix for AES-128) and the direct AES-256 file key decrypt strings and stream bodies, object-stream members are left alone and /Encrypt and /ID are never decrypted; the writer encrypts the same way with AES-256 (crates/pdfboss-write/src/writer.rs new_encrypted; tests crates/pdfboss-write/tests/encrypt.rs encrypt_dict_strings_are_never_encrypted and encrypt.rs xref_stream_is_never_encrypted). |
| 7.6.3 | Standard Security Handler | incomplete | 15 | 25 | V1/V2 RC4, V4 with the V2 or AESV2 crypt filter and V5 R5/R6 AES-256 files open under user or owner passwords, but a V4 file whose stream crypt filter is /Identity is refused (see 7.6.3.2 and 7.6.5); the writer produces AES-256 R6 files (crates/pdfboss-core/src/crypt.rs aes256_encrypt_dict). |
| 7.6.3.2 | Standard Encryption Dictionary | implemented | 11 | 10 | /Filter /Standard, /V, /R, /Length, /P, /O, /U, /OE, /UE, /Perms, /EncryptMetadata, /CF, /StmF, /StrF and /EFF are read (crates/pdfboss-core/src/crypt.rs from_standard_with_password and CryptFilters::parse; tests crypt.rs string_filter_is_chosen_independently_of_the_stream_filter and embedded_file_filter_applies_to_embedded_file_streams); /SubFilter names the formats of public-key handlers (adbe.pkcs7.*), so a reader with only the Standard handler has nothing to read there. |
| 7.6.3.3 | Encryption Key Algorithm | implemented | 11 | 5 | Algorithm 2 with the 32-byte pad, the 50-round MD5 for R3 and up and the R4 EncryptMetadata-false suffix derives the RC4/AES-128 key, and Algorithms 2.A and 2.B recover the AES-256 key for R5 and R6; every RC4 fixture is /V 2 /R 3 or /V 4 /R 4, so the R2 40-bit branch (crypt.rs) has no test. |
| 7.6.3.4 | Password Algorithms | implemented | 9 | 6 | The user password is verified through /U (Algorithms 4 and 5), the owner password recovers the padded user password from /O (Algorithm 7) and the R5/R6 checks use /O, /U, /OE, /UE and /Perms, while Algorithm 3 (computing /O) lives only in fixtures (crates/pdfboss-testkit/src/crypt.rs) and the R2 branch (crypt.rs) is untested; the writer computes /O, /U, /OE, /UE and /Perms for R6 (crates/pdfboss-core/src/crypt.rs, test crates/pdfboss-write/tests/encrypt.rs owner_password_also_opens). |
| 7.6.4 | Public-Key Security Handlers | out of scope | 12 | 0 | Only /Filter /Standard is accepted (crypt.rs) and every other handler fails with Error::Encrypted, because certificate and private-key handling is deliberately left out of pdfboss. |
| 7.6.5 | Crypt Filters | implemented | 7 | 13 | The /CF filters are read by name with /CFM V2, AESV2 or AESV3, /StmF, /StrF and /EFF pick among them with the standard Identity filter as the default and as a pass-through (crates/pdfboss-core/src/crypt.rs CryptFilters::parse), the crypt filter /Length sets the V4 key size (v4_key_length), /CFM None or an unknown method refuses the file as Error::Encrypted, and a stream's own /Crypt filter overrides /StmF (see 7.4.10); /EFF is recognised through /Type /EmbeddedFile, which Table 45 makes optional, so an untyped embedded file stream takes /StmF. /AuthEvent is not consulted: the password arrives when the document opens, Table 25 says readers ignore the entry for the /StmF and /StrF filters, and the Standard handler has the one file key for /EFF too. Tests: crypt.rs identity_stream_filter_leaves_streams_as_stored, string_filter_is_chosen_independently_of_the_stream_filter, embedded_file_filter_applies_to_embedded_file_streams, crypt_filter_length_sets_the_key_size and aesv3_identity_string_filter_leaves_strings_as_stored. |
| 7.7.2 | Document Catalog | incomplete | 6 | 3 | /Pages, /OCProperties, /StructTreeRoot, /Names (crates/pdfboss-core/src/names.rs), /Dests (destination.rs), /Outlines (outline.rs) and /PageLabels (page_label.rs) are read from the catalog; /Version never overrides the header and /Metadata, /MarkInfo, /Lang, /AcroForm, /PageMode and /PageLayout are not consumed, while the writer emits /Pages, /Outlines, /Metadata, /Names, /PageLabels and viewer entries (crates/pdfboss-write/src/pdf.rs and pdf.rs). |
| 7.7.3 | Page Tree | incomplete | 19 | 18 | The tree walk, inheritance and page count are handled, with page-level gaps listed under 7.7.3.3; the writer builds page trees (crates/pdfboss-write/src/pdf.rs, crates/pdfboss-write/src/assemble.rs cites the clause). |
| 7.7.3.2 | Page Tree Nodes | implemented | 2 | 6 | /Kids are walked depth-first in document order, /Count is used when plausible, /Type /Page ends descent and cycles and depth are capped; /Parent is not needed for reading. |
| 7.7.3.3 | Page Objects | incomplete | 9 | 6 | /Contents (single stream or array joined by newlines with nulls skipped), the five boxes with Table 30 defaults, /Rotate, /Resources, /Annots and /StructParents are consumed; /UserUnit and the page-level /Group transparency dictionary are never read, and the viewer entries (/Dur, /Trans, /AA, /Tabs) are ignored. |
| 7.7.3.4 | Inheritance of Page Attributes | implemented | 5 | 4 | /Resources, /MediaBox, /CropBox and /Rotate flow down the /Pages nodes with the nearest ancestor winning and the non-inheritable boxes staying on the leaf (the fixture at parity.rs cites the clause, the test does not). |
| 7.7.4 | Name Dictionary | implemented | 7 | 3 | crates/pdfboss-core/src/names.rs: NameTree names the ten trees of Table 31 by their dictionary key, name_tree_root_with reads the catalog /Names entry and the tree's root (direct or indirect, anything that is not a dictionary reads as absent), named_with resolves the object one name designates through tree.rs lookup, names_with lists a tree's pairs; Document::named and Document::names on the sync side, AsyncDocument::named and AsyncDocument::names on the async side (tests names.rs named_objects_come_from_the_catalog_name_dictionary, documents_without_a_name_dictionary_have_no_names, every_table_31_tree_has_its_dictionary_key; crates/pdfboss-aio/tests/parity.rs names fixture). The writer emits /Names /EmbeddedFiles (crates/pdfboss-write/src/pdf.rs, test crates/pdfboss-write/tests/roundtrip.rs attachments_sort_by_name_and_round_trip_bytes). Consumers of the individual trees (destinations 12.3.2.3, embedded files 7.11.4) are their own rows. |
| 7.8.2 | Content Streams | implemented | 2 | 5 | Operands followed by an operator are tokenized with unknown operators skipped, inline images with their abbreviations are parsed, and a /Contents array is concatenated with a newline between parts; the writer produces content streams (crates/pdfboss-write/src/canvas.rs and crates/pdfboss-write/src/content.rs). |
| 7.8.3 | Resource Dictionaries | implemented | 3 | 4 | Category/name lookups walk the resource chain innermost first (form, then caller, then page, with a page missing /Resources inheriting from /Pages), and text extraction and rendering apply the same rule. |
| 7.9.2 | String Object Types | implemented | 9 | 12 | Text strings decode from UTF-16BE, UTF-8 or PDFDocEncoding by their byte-order mark (crates/pdfboss-core/src/object.rs decode_text_string) and byte strings stay raw; the writer encodes text strings as ASCII or UTF-16BE (crates/pdfboss-write/src/pdf.rs text_string cites 7.9.2.2). |
| 7.9.2.2 | Text String Type | implemented | 3 | 3 | UTF-16BE with a BOM and the PDF 2.0 UTF-8 BOM form decode with lone surrogates and odd trailing bytes replaced by U+FFFD, while language escape sequences inside UTF-16 text are kept unchanged; the writer writes ASCII plainly and everything else as UTF-16BE with a BOM (crates/pdfboss-write/src/pdf.rs). |
| 7.9.2.3 | PDFDocEncoded String Type | implemented | 2 | 5 | A string without a BOM decodes through the PDFDocEncoding table (crates/pdfboss-core/src/object.rs pdf_doc_char): the accents at 0x18-0x1F, the punctuation and ligatures at 0x80-0x9E and the Euro at 0xA0 map to their characters, the undefined codes become U+FFFD, and every other code agrees with Latin-1 (tests object.rs decode_pdf_doc_encoding_accents_at_0x18, decode_pdf_doc_encoding_punctuation_at_0x80, decode_pdf_doc_encoding_undefined_codes_are_replaced). |
| 7.9.2.4 | Byte String Type | implemented | 2 | 2 | Strings are kept as raw bytes in Object::String and exposed through as_str_bytes with no character interpretation, which is what /ID, /O and /U consumers rely on. |
| 7.9.3 | Text Streams | not implemented | 0 | 0 | No consumer reads a text stream (used by JavaScript and similar viewer features); decode_text_string is only applied to string objects. |
| 7.9.4 | Dates | implemented | 11 | 8 | One Date type in the core crate (crates/pdfboss-core/src/date.rs, re-exported by pdfboss-write) parses the D:YYYYMMDDHHmmSSOHH'mm form with the clause's defaults and field ranges, taking the deviations producers write (a trailing apostrophe, Z followed by offset digits, an offset without apostrophes, no D: prefix) and refusing out-of-range fields; it formats back to the D: form and to ISO 8601. Metadata keeps /CreationDate and /ModDate as written and reads them through creation_date_parsed and mod_date_parsed (document.rs); the CLI info command prints a parsed date as ISO 8601. The Python metadata dict still carries the strings as written. Tests: date.rs parse_pdf_the_clause_example, parse_pdf_partial_fields_take_their_defaults, parse_pdf_accepts_the_common_deviations, parse_pdf_refuses_out_of_range_fields; document.rs metadata_dates_parse_per_the_date_clause. |
| 7.9.5 | Rectangles | implemented | 2 | 3 | Four-number arrays with indirect elements resolved are normalized so that any two opposite corners define the rectangle. |
| 7.9.6 | Name Trees | implemented | 5 | 4 | crates/pdfboss-core/src/tree.rs lookup and entries walk a tree of any depth: TreeKey for byte-string keys (Vec |
| 7.9.7 | Number Trees | implemented | 5 | 3 | crates/pdfboss-core/src/tree.rs lookup and entries with integer keys (TreeKey for i64): /Nums leaves, /Kids subtrees and /Limits under a visited set and a node budget, used by the structure /ParentTree lookup (structure.rs parent_array; tests tree.rs number_lookup_reads_nums_leaves, structure.rs parent_tree_kids_and_limits_are_descended); the writer emits a /PageLabels /Nums tree (crates/pdfboss-write/src/pdf.rs, test crates/pdfboss-write/tests/roundtrip.rs page_labels_resolve_roman_front_matter_and_offset_decimal). |
| 7.10.2 | Type 0 (Sampled) Functions | implemented | 2 | 7 | /Size, /BitsPerSample of 1 to 32, /Encode and /Decode with their defaults and multilinear interpolation between the 2^m nearest samples are covered, and /Order 3 is not honoured (the clause permits linear interpolation). |
| 7.10.3 | Type 2 (Exponential Interpolation) Functions | implemented | 2 | 3 | C0 + x^N (C1 - C0) with the C0, C1 and N defaults evaluates for shadings and tint transforms, the first input only as the clause defines. |
| 7.10.4 | Type 3 (Stitching) Functions | implemented | 2 | 2 | /Functions, /Bounds (k-1 of them, checked) and /Encode with its default remap the input into the selected subfunction, with subfunctions loaded under a MAX_FUNCTIONS budget. |
| 7.10.5 | Type 4 (PostScript Calculator) Functions | implemented | 4 | 11 | The full Table 42 operator set (arithmetic, relational, boolean, bitwise, stack and if/ifelse compiled to jumps) is parsed from the brace-delimited program with comments skipped, and the result feeds shadings and Separation/DeviceN tint transforms. |
| 7.10.5.2 | Errors in Type 4 Functions | implemented | 2 | 4 | Syntax errors reject the function at load time, and runtime failures (stack underflow or overflow, type mismatch, division by zero and other domain errors, runaway programs) clamp every output to the bottom of /Range rather than aborting the paint. |
| 7.11.2 | File Specification Strings | implemented | 3 | 2 | crates/pdfboss-core/src/embedded_file.rs spec_components splits a file specification string on every solidus not preceded by a reverse solidus and removes the reverse solidi that escape a solidus or each other, an absolute specification starting with an empty component (test embedded_file.rs file_specification_strings_split_on_unescaped_solidus); FileSpec keeps /F as stored and /UF decoded, and names the file from /UF then /F. The components are handed on as bytes without conversion to a platform path, as 7.11.2.1 asks; the writer stores the attachment name as both /F and /UF (crates/pdfboss-write/src/pdf.rs embedded_files_dict). |
| 7.11.3 | File Specification Dictionaries | implemented | 4 | 2 | crates/pdfboss-core/src/embedded_file.rs file_spec_with reads a /Type /Filespec dictionary (/F, /UF, /Desc, /FS, /V, and the /EF stream chosen from /UF, /F or the obsolescent /DOS, /Mac and /Unix) or a bare string as the /F of a specification; /ID, /RF and /CI are not read (tests embedded_file.rs a_file_specification_names_its_file_from_uf_then_f_then_the_legacy_keys, embedded_files_come_from_the_name_tree). The writer emits /Type /Filespec with /F, /UF, /Desc and /EF (crates/pdfboss-write/src/pdf.rs, test crates/pdfboss-write/tests/roundtrip.rs attachments_sort_by_name_and_round_trip_bytes). |
| 7.11.4 | Embedded File Streams | implemented | 8 | 3 | crates/pdfboss-core/src/embedded_file.rs embedded_files_with lists the catalog's /EmbeddedFiles name tree in tree order through names.rs, each EmbeddedFile carrying the decoded name, its FileSpec and the stream's /Subtype (Table 45) and /Params /Size, /CreationDate, /ModDate and /CheckSum (Table 46); embedded_file_data_with decodes the stream through the source's filter chain, MissingKey(EF) when the specification embeds nothing; Document::embedded_files and embedded_file_data, AsyncDocument twins (tests embedded_file.rs embedded_files_come_from_the_name_tree, documents_without_embedded_files_have_none; crates/pdfboss-aio/tests/parity.rs names fixture). Related files arrays (7.11.4.2) and the Mac subdictionary (Table 47) are not read; file attachment annotations belong to 12.5.6.15. The writer emits /Type /EmbeddedFile streams with /Subtype and /Params /Size and /ModDate but no related-files array (crates/pdfboss-write/src/pdf.rs embedded_files_dict; tests crates/pdfboss-write/tests/roundtrip.rs attachment_params_size_and_conditional_moddate and tests/test_write.py test_multiple_attachments_and_page_labels_compose). The CLI and Python bindings do not list attachments yet. |
| 7.11.5 | URL Specifications | implemented | 2 | 1 | FileSpec::url returns the /F of a specification whose /FS is URL, read as the 7-bit ASCII the clause requires (crates/pdfboss-core/src/embedded_file.rs; test url_specifications_name_a_url), so a URL file attachment or action target reaches Python as FileSpec.url; the writer does not emit URL specifications (crates/pdfboss-write/src/pdf.rs writes a /URI action, which belongs to chapter 12). |
| 7.11.6 | Collection Items | not implemented | 0 | 0 | No /CI collection item dictionaries are read or written. |
| 7.11.7 | Maintenance of File Specifications | out of scope | 0 | 0 | This clause describes how applications that move or rename files keep specifications valid, a file-management duty pdfboss has no role in. |
| 7.12.2 | Developer Extensions Dictionary | implemented | 1 | 2 | crates/pdfboss-core/src/extension.rs extensions_with reads the catalog's /Extensions dictionary into one DeveloperExtension per developer prefix (Table 50: the prefix is the key, /BaseVersion a name, /ExtensionLevel an integer; the dictionary's own /Type entry and an entry missing either value are skipped, indirect objects followed although the clause asks for direct ones, the result sorted by prefix since a dictionary keeps no order); Document::extensions and AsyncDocument::extensions expose it and the CLI's info command prints each extension after the version (tests extension.rs the_catalog_extensions_are_read_sorted_by_prefix with the clause's EXAMPLE 3, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_lists_developer_extensions). Not exposed to Python; pdfboss-write emits no extensions dictionary. |
| 7.12.3 | BaseVersion | implemented | 2 | 2 | DeveloperExtension::base_version keeps the /BaseVersion name as written and DeveloperExtension::base_version_numbers reads it as the two integers around a period the clause prescribes, not as a real number, None for anything else (test extension.rs base_versions_are_two_integers). The clause's requirement that the base version not exceed the header or catalog version is not checked. |
| 7.12.4 | ExtensionLevel | implemented | 1 | 1 | DeveloperExtension::extension_level carries the /ExtensionLevel integer of each developer extensions dictionary, read with 7.12.2 (test extension.rs the_catalog_extensions_are_read_sorted_by_prefix: the levels 3 and 1002 of the clause's EXAMPLE 3). Ordering the levels over time is the developer's business and nothing pdfboss checks. |
8 Graphics
| Clause | Feature | Status | Code citations | Test citations | Note |
|---|---|---|---|---|---|
| 8.2 | Graphics Objects | implemented | 3 | 5 | Path, text, XObject, inline image and shading objects all execute, BX/EX parse and unknown operators are skipped; the object nesting rules bind writers and are not validated on read. |
| 8.3.2.2 | Device Space | implemented | 2 | 3 | The crop box, /Rotate and the scale factor build the base matrix with the y flip that puts the first device row at the top. |
| 8.3.2.3 | User Space | implemented | 2 | 2 | The page's default user space is the base matrix, cm concatenates onto the CTM and q/Q restores it. |
| 8.3.2.4 | Other Coordinate Spaces | implemented | 2 | 4 | Form, image (unit square), pattern, text and Type3 glyph spaces each map through their own matrix onto the CTM. |
| 8.3.2.5 | Relationships among Coordinate Spaces | implemented | 2 | 2 | Transformations compose by matrix concatenation from the innermost space out to device space. |
| 8.3.3 | Common Transformations | implemented | 2 | 1 | Translation, scaling and rotation constructors follow the clause's matrix forms; skew is expressed through the general six-element matrix. |
| 8.3.4 | Transformation Matrices | implemented | 2 | 4 | The six-element matrix, its concatenation order and inversion follow the clause; a non-finite cm operand is skipped. |
| 8.4.1 | General | incomplete | 2 | 2 | Table 52's CTM, clip, colour spaces and colours, line width/cap/join/miter/dash, alpha, blend mode, soft mask and text state are tracked with the clause's initial values; rendering intent, stroke adjustment and alpha source are not, and none of Table 53 (overprint, black generation, undercolor removal, transfer, halftone, flatness, smoothness) is tracked. |
| 8.4.2 | Graphics State Stack | implemented | 2 | 5 | q pushes and Q pops the whole state including clip and soft mask, a form's unbalanced Q cannot pop its caller's state (case form_with_unbalanced_restore), and nesting is capped at 64 levels. |
| 8.4.3.2 | Line Width | implemented | 3 | 3 | The pen is a user-space circle carried through the CTM (anisotropic scaling widens it correctly) and a zero or sub-pixel width paints the thinnest visible hairline; the writer emits w through canvas.rs set_line_width. |
| 8.4.3.3 | Line Cap Style | implemented | 7 | 5 | J and /LC set stroke.rs LineCap, which stroke_path reads at every run end: butt caps stop at the endpoint, round caps add the pen disc, projecting square caps carry the band on by half the line width in user space (stroke.rs butt_caps_stop_at_the_endpoints, square_caps_extend_the_band_by_half_the_width, executor.rs line_caps_follow_j_and_lc); zero-length dashes take the cap style along the path's direction; a J operand outside 0 to 2 sets butt; the writer emits J through canvas.rs set_line_cap. |
| 8.4.3.4 | Line Join Style | implemented | 7 | 7 | j and /LJ set stroke.rs LineJoin, which stroke_path reads at every interior vertex and at the start of a closed subpath: a miter carries the two bands' outer edges to their meeting point, a bevel cuts across their ends, a round join adds the pen disc; collinear segments get no join and a segment turning straight back gets only the round disc (stroke.rs miter_joins_fill_the_outer_corner, bevel_joins_cut_the_corner, round_joins_keep_the_pen_disc, joins_follow_a_flipped_matrix, executor.rs line_joins_follow_j_lj_m_and_ml); a j operand outside 0 to 2 sets miter; the writer emits j through canvas.rs set_line_join. |
| 8.4.3.5 | Miter Limit | implemented | 6 | 3 | M and /ML set miter_limit (values under 1 are ignored, default 10); stroke.rs miter_fits measures the two segment directions in user space and cuts a miter to a bevel when 1/sin(angle/2) is over the limit (stroke.rs the_miter_limit_cuts_sharp_joins_to_bevels, executor.rs line_joins_follow_j_lj_m_and_ml); the writer emits M through canvas.rs set_miter_limit. |
| 8.4.3.6 | Line Dash Pattern | implemented | 3 | 6 | The dash array and phase are measured in user space through the inverse CTM, an empty array restores solid strokes and /D in an ExtGState is honored; the writer emits d through canvas.rs set_dash. |
| 8.4.4 | Graphics State Operators | implemented | 2 | 3 | All eleven operators parse and set their state, and ri and i are accepted with no effect as the clause allows. |
| 8.4.5 | Graphics State Parameter Dictionaries | incomplete | 4 | 7 | /LW /LC /LJ /ML /D /CA /ca /BM /SMask are applied from the named resource; /RI /Font /OP /op /OPM /TR /TR2 /HT /FL /SM /SA /BG /BG2 /UCR /UCR2 /TK /AIS are ignored; the writer emits /ca /CA /BM only. |
| 8.5.2.1 | General | implemented | 3 | 4 | All seven construction operators build subpaths with the current point semantics of Table 59, re starts a closed rectangle subpath, and a segment before any m starts at the origin leniently instead of erroring. |
| 8.5.2.2 | Cubic Bezier Curves | implemented | 2 | 4 | c, v and y build the four control points per Table 59 and the curve is flattened adaptively within tolerance; the writer emits c through canvas.rs curve_to. |
| 8.5.3.1 | General | implemented | 2 | 3 | Every painting operator ends the path, the closing variants close the subpath first, F is accepted as f, and n discards the path while still applying a pending clip. |
| 8.5.3.2 | Stroking | implemented | 4 | 9 | Strokes are offset quads under the full CTM with dashes, stroke patterns and the cap style applied; path.rs keeps a degenerate subpath (a closed single point or coincident points), which stroke_path paints as a filled disc under round caps and not at all under butt or square caps, while a single-point open subpath paints nothing and zero-length dashes keep their caps (stroke.rs degenerate_subpaths_paint_a_dot_only_under_round_caps, zero_length_dashes_take_the_cap_style, executor.rs tests of the same names, path.rs degenerate_subpaths_are_kept_for_the_stroker); joins follow 8.4.3.4 and 8.4.3.5. |
| 8.5.3.3.1 | General | implemented | 2 | 2 | Open subpaths are implicitly closed before filling and coverage is anti-aliased per pixel. |
| 8.5.3.3.2 | Nonzero Winding Number Rule | implemented | 3 | 2 | f, B and b accumulate signed winding per scanline span and paint where it is nonzero. |
| 8.5.3.3.3 | Even-Odd Rule | implemented | 2 | 3 | f*, B* and b* count crossings and paint where the count is odd; the writer emits f* through canvas.rs fill_even_odd. |
| 8.5.4 | Clipping Path Operators | implemented | 3 | 4 | W and W* mark a pending clip that intersects the current clip after the following painting operator (n included), nested clips and a clip straddling a form (cases nested_clips, clip_straddling_a_form) intersect correctly, and the writer emits W n and W* n through canvas.rs clip. |
| 8.6.2 | Colour Values | implemented | 2 | 3 | Each family declares its component count and decode range, operands are clamped to the space's range, and every colour ends as RGB for compositing. |
| 8.6.3 | Colour Space Families | implemented | 2 | 2 | The device, CIE-based (CalGray, CalRGB, Lab, ICCBased) and special (Indexed, Separation, DeviceN, Pattern) families all parse from name or array form, with unknown names falling back to DeviceGray. |
| 8.6.4.2 | DeviceGray Colour Space | implemented | 1 | 2 | g/G and DeviceGray image samples convert gray to equal RGB channels (case device_colorspaces). |
| 8.6.4.3 | DeviceRGB Colour Space | implemented | 1 | 2 | rg/RG and DeviceRGB image samples pass through unchanged. |
| 8.6.4.4 | DeviceCMYK Colour Space | incomplete | 1 | 3 | k/K and CMYK images convert, but by a deliberate multiplicative formula (1-c)(1-k) rather than the clause's 1 - min(1, c + k), chosen so deep colours keep their hue. |
| 8.6.5.2 | CalGray Colour Spaces | implemented | 2 | 2 | /Gamma and /WhitePoint are read and gray decodes through XYZ to sRGB; /BlackPoint is ignored and a bare /CalGray name reads as DeviceGray. |
| 8.6.5.3 | CalRGB Colour Spaces | implemented | 1 | 2 | Per-channel /Gamma and the column-major /Matrix map to XYZ with identity defaults; /BlackPoint is ignored and a bare /CalRGB name or a dictionary without /WhitePoint falls back to DeviceRGB. |
| 8.6.5.4 | Lab Colour Spaces | implemented | 3 | 4 | /Range defaults to plus or minus 100, the default image decode is the Lab range, and the inverse transfer follows the clause's two-branch form (the source comment at math.rs cites the clause). |
| 8.6.5.5 | ICCBased Colour Spaces | implemented | 1 | 6 | Matrix/TRC, lut8/lut16 and lutAtoB profiles transform through the PCS, sRGB-equivalent profiles take the device path, /N mismatches and unparseable profiles fall back to /N or /Alternate, and /Range and /Metadata are ignored. |
| 8.6.5.6 | Default Colour Spaces | implemented | 3 | 2 | executor.rs device_or_default replaces a device space selected by g/G, rg/RG, k/K or a cs/CS device name (a pattern space's base included) with the /DefaultGray, /DefaultRGB or /DefaultCMYK entry of the /ColorSpace resources when that entry has the same component count and is neither Indexed nor Lab, passing the component values through; image_colorspace does the same for image XObjects and inline images (executor.rs default_colour_spaces_remap_device_colours, an_unsuitable_default_colour_space_is_ignored); the defaults are resolved once per content stream and inherited through the resource chain; not remapped: the initial colour of a page or form, shading dictionaries, the base of an Indexed space and the alternate of a Separation or DeviceN. |
| 8.6.5.7 | Implicit Conversion of CIE-Based Colour Spaces | implemented | 2 | 3 | Every CIE-based colour converts to the sRGB output space through XYZ with Bradford adaptation of the declared white point; the output space is fixed to sRGB. |
| 8.6.5.8 | Rendering Intents | incomplete | 2 | 1 | ri parses and is accepted, but neither it nor /RI changes anything; the ICC pipeline uses one fixed transform regardless of the requested intent. |
| 8.6.6.2 | Pattern Colour Spaces | incomplete | 2 | 5 | /Pattern and [/Pattern base] spaces select tiling and shading patterns for path fills and strokes, but text glyphs (executor.rs) and image stencils (executor.rs) painted with a pattern colour get a mid-gray stand-in and a report entry. |
| 8.6.6.3 | Indexed Colour Spaces | implemented | 3 | 5 | String and stream lookup tables over any base (Lab and nested Indexed included) resolve, out-of-range indices clamp, and the image decode default is [0 2^bpc-1] (case indexed_image_and_stencil). |
| 8.6.6.4 | Separation Colour Spaces | implemented | 10 | 10 | The tint transform (function types 0, 2, 3, 4) runs into the alternate space with initial tint 1.0 (crates/pdfboss-render/src/color.rs parse_with and to_rgb, executor.rs initial_components). The special colorant All ignores the alternate space and the transform and puts the tint on every colorant, gray 1 - tint on the composite output (ColorSpace::SeparationAll). None never marks the page: fills, strokes and text carry a zero-alpha colour (executor.rs fill_rgba8, stroke_rgba8), a stencil mask returns before painting (blit_image), an image in the space returns from image.rs draw, and a shading from shading.rs paint and paint_background. Not caught: a Type3 glyph that sets its own colour inside a None fill, and an Indexed space over a None base; a DeviceN whose colorants are all None belongs to 8.6.6.5. Tests: executor.rs separation_none_never_marks_the_page and separation_all_paints_the_tint_on_every_colorant, color.rs separation_all_and_none_ignore_the_alternate_and_transform. |
| 8.6.6.5 | DeviceN Colour Spaces | incomplete | 1 | 4 | n-colourant tint transforms evaluate into the alternate space, but spaces with more than 8 colourants (the clause allows 32) fall back to an ink approximation and the attributes dictionary is ignored. |
| 8.6.6.6 | Multitone Examples | implemented | 1 | 1 | Informative examples of 8.6.6.5; a two-colourant DeviceN with a sampled tint transform (the duotone shape) evaluates through the general DeviceN path. |
| 8.6.7 | Overprint Control | not implemented | 0 | 0 | Nothing reads /OP, /op or /OPM; every paint composites over the backdrop in RGB with no overprint simulation. |
| 8.6.8 | Colour Operators | implemented | 3 | 5 | All twelve operators set the stroking or nonstroking space and colour, black DeviceGray is the initial colour and cs/CS applies the space's initial colour; the writer emits rg/RG/g/G/k/K through canvas.rs set_fill and 219 set_stroke. |
| 8.7.2 | General Properties of Patterns | implemented | 2 | 3 | The pattern matrix maps pattern space to the default space of the page or of the form whose resource it is, not to the CTM at painting time, and loaded patterns are cached per document. |
| 8.7.3.1 | General | implemented | 2 | 6 | Table 75's /BBox, /XStep, /YStep, /Resources and /Matrix are read (a zero or missing step falls back to the bbox size), /TilingType is ignored as a spacing hint, and the tile count is capped at 65536 with a report. |
| 8.7.3.2 | Coloured Tiling Patterns | implemented | 2 | 3 | /PaintType 1 cells run with their own colour operators and inherit the paint site's blend mode and soft mask. |
| 8.7.3.3 | Uncoloured Tiling Patterns | implemented | 2 | 2 | /PaintType 2 cells paint in the scn colour given in the [/Pattern base] underlying space with the cell's own colour operators locked out. |
| 8.7.4.1 | General | incomplete | 1 | 3 | /Shading and /Matrix of a Table 76 pattern dictionary are read and the fill paints through the path with /Background, but the pattern's own /ExtGState entry is not applied. |
| 8.7.4.2 | Shading Operator | implemented | 2 | 4 | sh paints the named shading over the current clip intersected with its /BBox, ignores /Background as the clause requires, and reports a singular CTM instead of painting; the writer serializes sh at content.rs. |
| 8.7.4.3 | Shading Dictionaries | implemented | 1 | 4 | /ShadingType, /ColorSpace, /Background and /BBox are honored (/Background only behind pattern fills, per Table 78); /AntiAlias is ignored as a hint. |
| 8.7.4.4 | Colour Space: Special Considerations | incomplete | 2 | 2 | A /Function turns colour data into one parametric value and mesh vertex colours decode through the shading's space, but an /Indexed space combined with /Function and a /Pattern shading space are not rejected as the clause requires. |
| 8.7.4.5.2 | Type 1 (Function-Based) Shadings | implemented | 1 | 2 | The 2-in function evaluates per pixel over /Domain under /Matrix and points outside the domain stay unpainted. |
| 8.7.4.5.3 | Type 2 (Axial) Shadings | implemented | 2 | 3 | /Coords, /Domain, /Extend and a single or array-valued /Function are honored. |
| 8.7.4.5.4 | Type 3 (Radial) Shadings | implemented | 1 | 1 | The two circles, /Domain and /Extend are honored and a negative radius is rejected; the test covers concentric circles without extension. |
| 8.7.4.5.5 | Type 4 Shadings (Free-Form Gouraud-Shaded Triangle Meshes) | implemented | 2 | 4 | Flags 0, 1 and 2 with /BitsPerCoordinate, /BitsPerComponent, /BitsPerFlag and /Decode are read from the stream and Gouraud interpolation paints each triangle. |
| 8.7.4.5.6 | Type 5 Shadings (Lattice-Form Gouraud-Shaded Triangle Meshes) | implemented | 2 | 1 | /VerticesPerRow rows are split into triangle pairs and painted like type 4. |
| 8.7.4.5.7 | Type 6 Shadings (Coons Patch Meshes) | implemented | 2 | 3 | The 12 control points per patch in Table 85 order, edge sharing for flags 1 to 3, and interior points derived by the Coons formula are all handled. |
| 8.7.4.5.8 | Type 7 Shadings (Tensor-Product Patch Meshes) | implemented | 2 | 1 | The four extra interior points are read in Table 86 order and the patch is evaluated as a bicubic tensor patch. |
| 8.8.1 | General | implemented | 2 | 3 | Do dispatches on /Subtype (indirect values resolved) to image or form drawing and reports a missing or untyped XObject; the writer emits Do for images and form groups (canvas.rs draw_image, 387 draw_group). |
| 8.8.2 | PostScript XObjects | out of scope | 0 | 0 | pdfboss does not interpret PostScript, so a /PS XObject is skipped with an Unsupported report entry, which is what the clause tells a non-PostScript reader to do. |
| 8.9.2 | Image Parameters | implemented | 2 | 4 | /Width, /Height, /BitsPerComponent and /ColorSpace drive decoding; bad dimensions are rejected and short sample data reports as truncated. |
| 8.9.3 | Sample Representation | implemented | 2 | 3 | 1, 2, 4, 8 and 16 bit samples are read big-endian with each row padded to a whole byte, 16-bit samples scaling by 65535. |
| 8.9.4 | Image Coordinate System | implemented | 3 | 4 | The image fills the unit square under the CTM with row 0 at the top edge (v = 1) by inverse mapping of each device pixel. |
| 8.9.5.1 | General | incomplete | 3 | 4 | Of Table 89 the entries Width, Height, BitsPerComponent, ColorSpace, Decode, ImageMask, Mask, SMask, SMaskInData and OC are honored, while Intent, Interpolate, Alternates, Name, StructParent, ID, OPI and Metadata are ignored. |
| 8.9.5.2 | Decode Arrays | implemented | 6 | 7 | /Decode maps native samples, stencils and the Lab and Indexed defaults per Table 90 (crates/pdfboss-render/src/image.rs decode_samples, decode_stencil), maps DCTDecode samples per component through apply_decode (so a Photoshop-style CMYK JPEG with /Decode [1 0 1 0 1 0 1 0] comes out right), and is ignored for JPXDecode as 7.4.9 asks. Tests: image.rs decode_array_inverts_gray, dct_decode_array_applies_to_jpeg_samples and dct_decode_array_inverts_photoshop_cmyk. |
| 8.9.5.3 | Image Interpolation | implemented | 3 | 2 | image.rs ImageMeta reads /Interpolate (the inline /I is expanded at content.rs) and draw_rgba samples an interpolating image bilinearly (Rgba::bilinear) wherever it is drawn larger than itself, alpha included so stencil edges soften too; without the flag magnification stays nearest-neighbour and minification keeps its box average either way (image.rs interpolated_magnification_blends_between_samples, executor.rs interpolate_blends_a_magnified_image); the clause leaves the algorithm to the reader. |
| 8.9.5.4 | Alternate Images | not implemented | 1 | 0 | No code reads /Alternates; the base image is always drawn, which the clause permits a reader to do. |
| 8.9.6.1 | General | implemented | 1 | 3 | Stencil, explicit and colour key masking are dispatched from one place, with /SMask taking precedence over /Mask when both are present. |
| 8.9.6.2 | Stencil Masking | incomplete | 2 | 5 | /ImageMask true paints the nonstroking colour where the decoded sample is 0 (with /Decode [1 0] inverting and JPX stencils repacked to 1 bit), but a pattern nonstroking colour paints a mid-gray stand-in with a report entry. |
| 8.9.6.3 | Explicit Masking | implemented | 2 | 1 | A stream-valued /Mask must be an /ImageMask stencil, its 1 samples hide the base image and a mask of different dimensions is resampled onto the base's unit square; a non-stencil /Mask stream is reported; the test comment cites §8.9.6.4, which is not the -1 number for explicit masking. |
| 8.9.6.4 | Colour Key Masking | implemented | 2 | 1 | An array /Mask of 2n integer ranges hides samples whose raw values all fall inside, on native sample data only (DCT and JPX images decline with a report, since the clause warns the result is unpredictable there). |
| 8.9.7 | Inline Images | implemented | 5 | 8 | BI/ID/EI with the Table 93 key abbreviations, the /G /RGB /CMYK /I colour space abbreviations, the Table 94 filter abbreviations and /L are honored and a named colour space resolves through the /ColorSpace resource; the writer serializes inline images with /L. |
| 8.10.2 | Form Dictionaries | implemented | 2 | 6 | /Matrix, /BBox (as a clip) and /Resources (prepended to the lookup chain with page fallback; output lib.rs cites the clause) are honored to depth 16 (cases nested_forms, form_resource_shadowing), /Group /Ref /Metadata /PieceInfo /StructParent are ignored, and the writer emits form XObjects with /BBox and /Resources at pdf.rs. |
| 8.10.3 | Group XObjects | implemented | 3 | 4 | /Group is read on form XObjects and annotation appearance streams: /S /Transparency makes the form a transparency group (11.6.6) and any other subtype, or no /Group, paints as a plain form (executor.rs transparency_group; tests group_with_an_unknown_subtype_is_a_plain_form and non_isolated_group_blends_with_the_page_and_isolated_does_not in executor.rs); the soft-mask /G form also runs as a group (11.6.5.2). |
| 8.10.4.1 | General | incomplete | 1 | 0 | /Ref is never read, so a reference XObject paints its own proxy content as an ordinary form, which the clause permits when the referenced file is not consulted. |
| 8.10.4.2 | Printing Reference XObjects | out of scope | 0 | 0 | Printer-side substitution of the referenced page has no meaning for a rasterizer that never opens the referenced file. |
| 8.10.4.3 | Special Considerations | out of scope | 0 | 0 | The guidance on /Page and /ID matching against the referenced file does not apply while /Ref is not consulted. |
| 8.11.2.1 | General | implemented | 7 | 5 | A group is identified by its indirect reference and is off when the configuration's state says so; /Name, /Intent and /Usage are read as data by optional_content_groups_with (crates/pdfboss-core/src/oc.rs, test oc.rs groups_read_as_data), with Python and async twins. |
| 8.11.2.2 | Optional Content Membership Dictionaries | implemented | 2 | 6 | /OCGs (single or array, null entries ignored) under /P AnyOn, AllOn, AnyOff, AllOff and /VE And/Or/Not expressions with nesting all evaluate, /VE taking precedence. |
| 8.11.2.3 | Intent | implemented | 2 | 5 | A group's /Intent (a name or an array, default View) must share a name with the configuration's /Intent (default View, All matching every group) for its state to affect visibility, so an off Design group under a View configuration still paints, and an empty configuration array hides nothing (oc.rs retain_matching_intents; tests oc.rs a_group_of_another_intent_never_hides_content, the_configuration_intent_selects_the_groups_it_controls and executor.rs usage_application_and_intent_decide_what_paints). |
| 8.11.3.2 | Optional Content in Content Streams | implemented | 2 | 8 | BDC /OC with a named /Properties resource or an inline dictionary opens a span whose painting is suppressed while graphics state, text position and clipping still take effect, spans nest and EMC balances; text extraction applies the same gating. |
| 8.11.3.3 | Optional Content in XObjects and Annotations | implemented | 2 | 3 | An /OC entry on an image or form XObject or on an annotation dictionary hides it (counted, not reported as a drop); the code cites this as §8.11.3.5, which is not the -1 number. |
| 8.11.4.2 | Optional Content Properties Dictionary | implemented | 4 | 3 | /OCGs and the default /D configuration are read from the catalog; /Configs alternates are ignored because only /D applies when a document opens. |
| 8.11.4.3 | Optional Content Configuration Dictionaries | implemented | 3 | 4 | /BaseState, /ON and /OFF apply in specification order, /Intent selects the groups the configuration controls and /AS applies the usage application dictionaries of the requested event (oc.rs configured_off, apply_usage); /Name, /Creator, /Order, /ListMode, /RBGroups and /Locked are viewer UI entries with no rendering effect, and /Configs alternates are not read because only /D applies when a document opens (8.11.4.2). |
| 8.11.4.4 | Usage and Usage Application Dictionaries | implemented | 6 | 7 | The configuration's /AS dictionaries whose /Event is the requested event set each group in their /OCGs from the View, Print or Export entry of its /Usage that the /Category array names, ON turning the group on and OFF off, in array order with later dictionaries winning (oc.rs apply_usage; tests oc.rs view_usage_application_hides_a_view_state_off_group, each_event_applies_its_own_dictionaries, usage_without_application_or_category_changes_nothing). The Zoom, Language and User categories depend on a viewer's magnification, locale and user, which pdfboss has none of, and leave the state unchanged. Every Table 102 entry is read as data (oc.rs read_usage, test oc.rs groups_read_as_data). |
| 8.11.4.5 | Determining the State of Optional Content Groups | implemented | 4 | 10 | The state comes from /BaseState then /ON then /OFF of the default configuration, then the /AS dictionaries of one event, then the intent rule; a document without /OCProperties shows everything. Document::oc_state, which rendering and text extraction use, is the View event's state a viewer shows on screen (pdfium and pdf.js render the same), while oc_state_for(None) is the default configuration alone that the clause prescribes for printing and aggregating applications, and oc_state_for(Some(Print)) the printed state (tests executor.rs usage_application_and_intent_decide_what_paints, extract.rs usage_application_decides_which_layers_are_extracted, parity.rs optional_content_states_and_groups_agree). |
9 Text
| Clause | Feature | Status | Code citations | Test citations | Note |
|---|---|---|---|---|---|
| 9.2.2 | Basics of Showing Text | implemented | 2 | 4 | BT, Tf, Td, Tj and ET are parsed, executed for extraction and painted by the renderer; the writer emits the same five-operator sequence from Canvas::text (crates/pdfboss-write/src/canvas.rs). |
| 9.2.3 | Achieving Special Graphical Effects | implemented | 1 | 3 | The renderer paints every Tr mode of Table 106 for outline fonts: fills in the fill colour, strokes in the stroke colour through stroke_path with the graphics state's line parameters, and the clipping modes intersect the clip with the glyph outlines at ET so later content shows through the text (executor.rs clip_render_modes_intersect_the_clip_when_the_text_object_ends); SkippedKind::TextClip is no longer produced. |
| 9.2.4 | Glyph Positioning and Metrics | implemented | 3 | 3 | Widths are read in 1/1000 text-space units (Type3 through /FontMatrix), the horizontal displacement w0 advances the text matrix and vertical fonts use w1 with the position vector; the writer's Standard14::text_width sums AFM widths the same way (crates/pdfboss-write/src/font.rs). |
| 9.3.1 | General | implemented | 2 | 5 | The Table 104 text state parameters live in the graphics state on both the extraction and the render side, so q/Q restore them and BT/ET leave them alone; Tf takes the font resource name and size. |
| 9.3.2 | Character Spacing | implemented | 2 | 3 | Tc is added to every glyph displacement, scaled by Th horizontally and unscaled in vertical writing; only the render tests measure the Tc advance, extraction has no dedicated one. |
| 9.3.3 | Word Spacing | implemented | 3 | 4 | Tw applies to the single-byte code 32 only, including composite fonts whose CMap has a one-byte codespace covering it; the writer's justified paragraphs set Tw per line and reset it (crates/pdfboss-write/src/element.rs). |
| 9.3.4 | Horizontal Scaling | implemented | 2 | 2 | Tz/100 scales horizontal advances, TJ offsets and the glyph rendering matrix and is not applied to vertical displacements; only the extraction side has an advance test. |
| 9.3.5 | Leading | implemented | 1 | 2 | TL sets the leading, TD sets it to -ty, and T*, ' and " move the line matrix down by it. |
| 9.3.6 | Text Rendering Mode | implemented | 8 | 9 | executor.rs show_text and paint_glyph follow Table 106: modes 0, 2, 4, 6 fill, modes 1, 2, 5, 6 stroke each glyph after its fill in the stroke colour with the user-space line width, caps, joins and dash, modes 4 to 7 accumulate the outlines from BT (or from the Tr inside the text object) and ET intersects the clip with their nonzero union, no outlines meaning no clip, and Q restores it; mode 3 paints nothing; an operand outside the table is read by its bits (fill and stroke in the low two, clip in the third) as pdf.js reads it; a font that paints at no tier contributes no outlines, so a clipping mode over its text leaves later content unclipped; a Type3 font is affected by mode 3 only, so its glyphs paint in every other mode and never clip (executor.rs stroke_render_modes_paint_the_outline_in_the_stroke_colour, clip_render_modes_intersect_the_clip_when_the_text_object_ends, type3_text_ignores_every_mode_but_invisible, text_render_mode_is_restored_by_q); extraction marks modes 3 and 7 invisible. |
| 9.3.7 | Text Rise | implemented | 2 | 2 | Ts shifts the glyph origin in unscaled text space and is recorded on each TextSpan; the renderer applies it in the text rendering matrix without a dedicated test. |
| 9.3.8 | Text Knockout | not implemented | 0 | 0 | The ExtGState /TK flag is never read; glyphs are composited one at a time as they paint, which matches only the TK false behavior. |
| 9.4.1 | General | implemented | 3 | 2 | BT resets Tm and Tlm to identity and ET ends the object while the text state parameters survive across text objects; nested BT is not diagnosed. |
| 9.4.2 | Text-Positioning Operators | implemented | 2 | 4 | Td, TD, Tm and T* update Tlm and Tm per Table 108 in both consumers; the writer positions text with Td (crates/pdfboss-write/src/canvas.rs). |
| 9.4.3 | Text-Showing Operators | implemented | 2 | 4 | Tj, TJ (number offsets as -n/1000 times Tfs and Th, or ty in vertical mode), ' and " are all executed; TJ offsets and the two quote operators have parser tests only, no extraction or render test; the writer emits Tj. |
| 9.4.4 | Text Space Details | implemented | 6 | 4 | The rendering matrix [Tfs*Th 0 0 Tfs 0 Ts] x Tm x CTM and the tx/ty displacement formulas are applied per glyph, with the vertical position vector v shifting the glyph origin. |
| 9.5 | Introduction to Font Data Structures | implemented | 2 | 4 | Font dictionaries dispatch on /Subtype to the Type0, Type1/MMType1, TrueType and Type3 loaders on both consuming sides; the writer emits /Type /Font dictionaries for the standard 14 (crates/pdfboss-write/src/font.rs). |
| 9.6.2 | Type 1 Fonts | incomplete | 10 | 17 | Parent row: see 9.6.2.1 (implemented), 9.6.2.2 (incomplete) and 9.6.2.3 (incomplete). |
| 9.6.2.1 | General | implemented | 2 | 5 | Table 111 entries BaseFont, FirstChar, Widths, FontDescriptor, Encoding and ToUnicode are read (LastChar is implied by the Widths length); the writer emits Type, Subtype, BaseFont and Encoding for the standard 14 (crates/pdfboss-write/src/font.rs). |
| 9.6.2.2 | Standard Type 1 Fonts (Standard 14 Fonts) | incomplete | 6 | 12 | The twelve text faces (plus Arial, TimesNewRoman and CourierNew aliases) get AFM widths without /Widths (as rasterized advances and as extracted span widths) and Croscore substitute faces at GlyphPainting::Full (bundled only with the substitute-fonts feature, on by default in the CLI), and the writer emits them WinAnsi-encoded; Symbol and ZapfDingbats have their built-in encodings (Annex D.5 and D.6, crates/pdfboss-encoding/src/symbol.rs) but no metrics and no substitute face, so they stay unpainted and the writer encodes text for them without widths (crates/pdfboss-write/src/font.rs symbol_faces_encode_through_their_built_in_encodings). |
| 9.6.2.3 | Multiple Master Fonts | incomplete | 2 | 0 | /MMType1 is dispatched like /Type1 by the renderer and falls into the simple-font loader in extraction, but no test names an /MMType1 font. |
| 9.6.3 | TrueType Fonts | implemented | 2 | 4 | Simple /TrueType fonts with a FontFile2 program are parsed (head, maxp, loca, glyf, cmap, hmtx, post) and painted at every tier with /Widths taking precedence over hmtx; extraction reads the cmap platform ids to decide the WinAnsi gap fill. |
| 9.6.4 | Font Subsets | implemented | 5 | 2 | The six-uppercase-plus tag is stripped before standard-14 metric lookup, substitution and picture-font detection while extracted spans keep /BaseFont as written; the writer embeds no programs, so it never subsets. |
| 9.6.5 | Type 3 Fonts | implemented | 11 | 12 | FontMatrix, CharProcs, Encoding, Widths and the font's own Resources are read, CharProcs run as nested frames, d1 glyphs ignore their own color operators and the advance comes from /Widths through FontMatrix; FontBBox is not used, and several comments cite "9.6.5.2", a subclause number the 32000-1 outline does not have. |
| 9.6.6 | Character Encoding | incomplete | 21 | 30 | Parent row: see 9.6.6.1 (incomplete), 9.6.6.2 (incomplete), 9.6.6.3 (implemented) and 9.6.6.4 (incomplete). |
| 9.6.6.1 | General | incomplete | 4 | 6 | Named encodings and encoding dictionaries with /BaseEncoding and /Differences are applied for WinAnsiEncoding, MacRomanEncoding and StandardEncoding; MacExpertEncoding is not implemented and silently falls back to StandardEncoding. |
| 9.6.6.2 | Encodings for Type 1 Fonts | incomplete | 6 | 10 | An embedded program's built-in encoding (a Type 1 program's /Encoding dup/put entries or StandardEncoding token; a CFF program's Encoding table, standard or custom format 0/1 with supplements, expert not bundled) is the base when the PDF names none, /Differences apply over it, a named encoding outranks it, and a nonsymbolic or standard-14 font without /Encoding defaults to StandardEncoding; the built-in encodings of the non-embedded Symbol and ZapfDingbats faces are not available. |
| 9.6.6.3 | Encodings for Type 3 Fonts | implemented | 2 | 3 | The /Encoding dictionary's /Differences names select CharProcs (StandardEncoding names fill unmapped codes as a leniency); extraction decodes Type3 codes through the same simple-font encoding path. |
| 9.6.6.4 | Encodings for TrueType Fonts | implemented | 7 | 11 | crates/pdfboss-render/src/truetype.rs Cmap keeps the best Unicode subtable ((3,10), (3,1) or platform 0), the (3,0) symbol table and the (1,0) Macintosh table, each reachable on its own (gid_for_unicode, gid_for_symbol, gid_for_mac); glyph.rs load_simple builds the code table as the clause says: a glyph name from /Differences, else from the base encoding when the font names MacRoman or WinAnsi, is flagged Nonsymbolic, or carries any /Encoding without the Symbolic flag, StandardEncoding filling what the base leaves undefined; the name goes through the glyph list into the Unicode subtable, or without one back to a Mac OS Roman code (pdfboss-encoding mac_os_roman_code, Table 115 included) into the (1,0) subtable, and failing both through post; a code still unmapped takes the symbolic route, the (3,0) subtable at the byte and in the 0xF000, 0xF100 and 0xF200 ranges, else the (1,0) subtable at the byte, then this reader's own mapping, a Unicode subtable at the byte or 0xF000 plus the byte, and in a program without a cmap the byte as the glyph index (tests glyph.rs differences_name_paints_via_post, base_encoding_letter_still_paints, symbolic_font_codes_go_through_the_3_0_subtable_ranges, mac_roman_subtable_takes_the_name_mapped_to_a_mac_os_code, a_font_without_a_cmap_uses_the_code_as_the_glyph_index; pdfboss-encoding lib.rs mac_os_roman_codes_follow_table_115). A symbolic font with a (3,0) table is addressed by code first, whatever its /Encoding says, and its /Differences names reach glyphs only through post or by their glyph-list value in the (3,0) table, never through Mac OS Roman, since its (1,0) table is keyed by the font's own codes (pdf.js canvas.pdf; test a_symbolic_fonts_differences_names_do_not_go_through_mac_os_roman), the leniency the clause's NOTE 1 allows. |
| 9.7.2 | CID-Keyed Fonts Overview | implemented | 2 | 3 | The code to CID (CMap) to glyph (CIDToGIDMap or CFF charset) pipeline is in place for CIDFontType0 and CIDFontType2 descendants. |
| 9.7.3 | CIDSystemInfo Dictionaries | incomplete | 3 | 1 | Only /Ordering is read, to pick the Japan1, GB1, CNS1, Korea1 or KR CID-to-Unicode table; /Registry and /Supplement are ignored and the CMap-versus-CIDFont collection compatibility rule is not checked. |
| 9.7.4 | CIDFonts | implemented | 16 | 18 | Parent row: see 9.7.4.1, 9.7.4.2 and 9.7.4.3, all implemented (crates/pdfboss-render/src/glyph.rs load_type0 and load_cff_cid; tests there cover each child). |
| 9.7.4.1 | General | implemented | 2 | 3 | Table 117 entries Subtype, CIDSystemInfo, FontDescriptor, DW, W, DW2, W2 and CIDToGIDMap are read from the first descendant; BaseFont is used only as style evidence. |
| 9.7.4.2 | Glyph Selection in CIDFonts | implemented | 6 | 9 | CIDFontType2 honours /CIDToGIDMap as /Identity or a stream (crates/pdfboss-render/src/glyph.rs). CIDFontType0 maps CIDs through the CFF charset when the Top DICT carries CIDFont operators and uses the CID directly as the glyph index when it does not (glyph.rs load_cff_cid over cff.rs cid_to_gid, None for the second case). A FontFile3 of /Subtype /OpenType is read from the container's CFF table (cff.rs parse, cff_table), and so is a CIDFontType2 FontFile2 holding a CFF-flavoured OpenType program (OTTO), as macOS Quartz writes it (glyph.rs load_type0_truetype into cff_cid, at the CFF tier; the descendant's /CIDToGIDMap applies when the CFF has no CIDFont operators). Tests: glyph.rs cff_cid_font_paints_at_all_embedded_not_embedded_truetype_only, cff_cid_font_over_a_non_cid_program_uses_cids_as_gids, cff_cid_font_in_an_opentype_wrapper_paints, cff_opentype_in_a_cid_fontfile2_paints_at_the_cff_tier; cff.rs an_opentype_wrapped_cff_parses_from_its_cff_table. |
| 9.7.4.3 | Glyph Metrics in CIDFonts | implemented | 6 | 6 | Both forms of /W and /W2 are parsed, /DW defaults to 1000 also when neither /W nor /DW is present, never falling back to the program's hmtx (glyph.rs cid_widths; test a_cid_font_without_w_or_dw_advances_by_the_default_width), /DW2 to [880 -1000] and the default position vector is (w0/2, vy); ranges are capped at 65536 entries as a hostile-input guard. |
| 9.7.5 | CMaps | implemented | 14 | 11 | Parent row: see 9.7.5.1 to 9.7.5.4, all implemented. |
| 9.7.5.1 | General | implemented | 2 | 3 | CMaps split one to four byte codes by codespace range, map them to CIDs and carry the writing mode; the CID value domain is kept apart from the ToUnicode parser. |
| 9.7.5.2 | Predefined CMaps | implemented | 7 | 1 | All Table 118 names (20 Japan1, 14 GB1, 14 CNS1, 11 Korea1, Identity-H/V) plus UniAKR-UTF16-H load from packed Adobe data behind the predefined-cmaps feature, which is off in pdfboss-core by default but on in the CLI defaults and in pdfboss-py; without it every name but Identity falls back to the Identity assumption. |
| 9.7.5.3 | Embedded CMap Files | implemented | 2 | 3 | A CMap stream as /Encoding is parsed with its /WMode and /UseCMap chain (names or streams, depth bounded at 4) and the in-content usecmap operator; the stream dictionary's CMapName and CIDSystemInfo are not read. |
| 9.7.5.4 | CMap Example and Operator Summary | implemented | 1 | 4 | begincodespacerange, begincidchar, begincidrange, beginnotdefchar, beginnotdefrange and usecmap are executed and the remaining Table 119 operators (begincmap, endcmap, CMapName, def) are skipped as inert; the bf* operators belong to ToUnicode (9.10.3). |
| 9.7.6 | Type 0 Font Dictionaries | incomplete | 7 | 9 | Parent row: see 9.7.6.1 (implemented), 9.7.6.2 (implemented) and 9.7.6.3 (incomplete). |
| 9.7.6.1 | General | implemented | 2 | 3 | Table 121 entries Subtype, Encoding, DescendantFonts (first element) and ToUnicode are read; BaseFont feeds style detection only. |
| 9.7.6.2 | CMap Mapping | implemented | 3 | 3 | Codes are matched byte-wise against the codespace ranges (shortest match wins) and looked up by (length, value), so equal values of different lengths stay distinct; with no codespaces at all two-byte codes are assumed. |
| 9.7.6.3 | Handling Undefined Characters | incomplete | 2 | 3 | Notdef ranges are consulted after every real mapping, an invalid code consumes the shortest codespace whose first byte fits and maps to CID 0, and CID 0 still advances by /W or /DW; the renderer never draws the notdef glyph (gid 0 is skipped and reported as NoGlyph) and with no partial match one byte is consumed instead of the shortest codespace length. |
| 9.8.1 | General | implemented | 14 | 17 | Table 122 entries FontName, Flags, ItalicAngle, Ascent, Descent, CapHeight, StemV, FontWeight, MissingWidth and the three FontFile keys are consumed for metrics, style and substitution, and FontBBox stands in for an absent or zero Ascent or Descent in extracted span boxes (not for Type 3 fonts, whose descriptor is in FontMatrix space); XHeight, StemH, Leading, AvgWidth, MaxWidth and CharSet are not read. |
| 9.8.2 | Font Descriptor Flags | implemented | 12 | 8 | FixedPitch, Serif, Nonsymbolic, Italic and ForceBold (Table 123 bits 1, 2, 6, 7 and 19) drive span style, substitute face choice and the StandardEncoding default; Symbolic, Script, AllCap and SmallCap are not consulted. |
| 9.8.3 | Font Descriptors for CIDFonts | not implemented | 2 | 2 | Parent row: see 9.8.3.1 (incomplete), 9.8.3.2 (notImplemented) and 9.8.3.3 (notImplemented). |
| 9.8.3.1 | General | incomplete | 2 | 2 | The descendant's descriptor is read through the same Table 122 path as simple fonts (Flags, metrics, FontFile2 and FontFile3), but none of the Table 124 CIDFont-only entries (Style, Lang, FD, CIDSet) is consulted. |
| 9.8.3.2 | Style | not implemented | 0 | 0 | The /Style dictionary and its /Panose string are never read. |
| 9.8.3.3 | FD | not implemented | 0 | 0 | The /FD dictionary of per-class descriptors is never read; only the top-level descriptor metrics apply. |
| 9.9 | Embedded Font Programs | incomplete | 4 | 6 | FontFile (Type 1 as PFA or PFB, eexec decrypted), FontFile2 (TrueType) and FontFile3 as bare CFF (Type1C and CIDFontType0C) are parsed and painted; a CFF-flavoured OpenType program (OTTO) is read from its CFF table, in FontFile3 and in FontFile2 alike (cff.rs cff_table; glyph.rs load_simple and load_type0_truetype); the FontFile3 /Subtype is never read, Length1, Length2 and Length3 are not used, and the writer embeds no font programs. |
| 9.10.2 | Mapping Character Codes to Unicode Values | incomplete | 3 | 4 | ToUnicode is consulted first, simple fonts then map through the encoding's glyph names (Adobe Glyph List, uniXXXX and uXXXX[XX] forms, underscore ligatures, period variants) and composite fonts through the collection's Uni*-UTF16-H inverse keyed by /Ordering, and a marked-content sequence's /ActualText replaces the mapped text of everything shown inside it (14.9.4, 14.8.2.4.2); gXX or cidXX style glyph names are not resolved. |
| 9.10.3 | ToUnicode CMaps | implemented | 2 | 6 | begincodespacerange, beginbfchar and both beginbfrange forms are parsed with UTF-16BE destinations (multi-character and surrogate pairs) for simple and Type0 fonts alike; a lone U+FFFD destination is treated as no mapping and usecmap inside a ToUnicode CMap is not followed. |
10 Rendering
| Clause | Feature | Status | Code citations | Test citations | Note |
|---|---|---|---|---|---|
| 10.2 | CIE-Based Colour to Device Colour | implemented | 1 | 4 | CalRGB, CalGray, Lab and ICCBased colours convert through XYZ with Bradford adaptation into sRGB, the only output device; /BlackPoint is never read and the ri operator and /RI are parsed then discarded. |
| 10.3.2 | Conversion between DeviceGray and DeviceRGB | implemented | 3 | 2 | Gray to RGB replicates the value; RGB to gray uses the clause's 0.3/0.59/0.11 weights inside blend luminosity and Rec. 601 integer weights (77/150/29 over 256) for luminosity soft masks, and no gray output device exists. |
| 10.3.3 | Conversion between DeviceGray and DeviceCMYK | out of scope | 0 | 0 | The rasterizer targets only an RGB device, so gray never converts to or from CMYK. |
| 10.3.4 | Conversion from DeviceRGB to DeviceCMYK | out of scope | 0 | 0 | There is no CMYK output device, so RGB is never converted to CMYK. |
| 10.3.5 | Conversion from DeviceCMYK to DeviceRGB | implemented | 1 | 3 | Every CMYK fill, stroke and image sample converts to RGB, but on purpose with the multiplicative (1-c)(1-k) form rather than the clause's 1-min(1, c+k) so deep colours keep their hue. |
| 10.4 | Transfer Functions | out of scope | 0 | 0 | ExtGState /TR and /TR2 are output-device calibration hints a screen rasterizer ignores and they are never read; the soft-mask dictionary /TR is applied and assessed under 11.6.5.2. |
| 10.5.2 | Halftone Screens | out of scope | 0 | 0 | Halftoning is a printer feature; /HT is never read and the rasterizer paints continuous-tone anti-aliased RGB. |
| 10.5.3 | Spot Functions | out of scope | 0 | 0 | No halftone screens exist, so spot functions have nothing to drive. |
| 10.5.4 | Threshold Arrays | out of scope | 0 | 0 | No halftone screens exist, so threshold arrays are never read. |
| 10.5.5 | Halftone Dictionaries | out of scope | 0 | 0 | Halftone dictionaries of every type (1, 5, 6, 10, 16) are never parsed; the code does not distinguish 10.5.5.2 to 10.5.5.6. |
| 10.6.2 | Flatness Tolerance | incomplete | 2 | 2 | Curves flatten to a fixed 0.1 device-pixel error bound, which satisfies any permitted flatness, but the i operator is parsed then discarded and ExtGState /FL is never read. |
| 10.6.3 | Smoothness Tolerance | incomplete | 2 | 0 | Shadings are evaluated per device pixel, so any smoothness tolerance is met, but ExtGState /SM is never read. |
| 10.6.4 | Scan Conversion Rules | implemented | 1 | 10 | Anti-aliased analytic coverage (vertical subsamples, exact horizontal spans) under the nonzero and even-odd rules, clips applied as coverage masks, and open subpaths closed for filling. |
| 10.6.5 | Automatic Stroke Adjustment | not implemented | 0 | 0 | ExtGState /SA is never read; the 0.75 device-pixel minimum pen width at stroke.rs is a hairline floor, not stroke adjustment. |
11 Transparency
| Clause | Feature | Status | Code citations | Test citations | Note |
|---|---|---|---|---|---|
| 11.2 | Overview of Transparency | incomplete | 2 | 2 | Constant alpha, blend modes, soft masks and transparency groups (isolated or not, composited once with the alpha, blend mode and soft mask in force at their Do) are painted, but knockout groups, group colour spaces and separate shape and opacity values are absent. |
| 11.3.2 | Basic Notation for Compositing Computations | incomplete | 1 | 0 | Definitions only; the formula they name is assessed under 11.3.3. |
| 11.3.3 | Basic Compositing Formula | implemented | 2 | 3 | Union alpha, alpha-weighted colour and the backdrop-alpha weighting of the blend result ((1-ab)Cs + abB(Cb,Cs), raster.rs blended) all follow the formula; a non-Normal mode over a transparent backdrop passes the source colour through (raster.rs blend_over_a_transparent_backdrop_passes_the_source_through). |
| 11.3.4 | Blending Colour Space | incomplete | 1 | 0 | Blending always happens in 8-bit device sRGB after colour conversion; a group /CS is never read, so CMYK or Lab content blends in RGB rather than its own space. |
| 11.3.5 | Blend Mode | implemented | 9 | 13 | All sixteen Table 136 and 137 modes are parsed (name or array form, unknown names read as Normal) and painted on fills, strokes, glyphs, images, shadings and pattern tiles; the twelve separable formulas (32000-2 §11.3.5.2) are coded with Multiply and Screen checked against their formula, and the four non-separable modes (32000-2 §11.3.5.3) are checked against hand-computed vectors. |
| 11.3.6 | Interpretation of Alpha | incomplete | 2 | 1 | One alpha per paint (coverage times constant alpha times soft mask times image sample alpha) stands in for shape and opacity, which are never carried as separate values; the difference only shows in knockout groups (11.4.6). |
| 11.3.7 | Shape and Opacity Computations | incomplete | 5 | 5 | Source shape and opacity combine as the clause asks (11.3.7.2); result shape and opacity are not tracked (11.3.7.3). |
| 11.3.7.2 | Source Shape and Opacity | implemented | 2 | 4 | Source alpha is the product of anti-aliased object coverage, the graphics-state soft mask intersected with the clip, the constant alpha and any image sample alpha, matching fjfmqk with the constant shape fixed at 1. |
| 11.3.7.3 | Result Shape and Opacity | incomplete | 1 | 0 | composite_over unions alpha (as + ab*(1-as)) on the page and offscreen mask buffers, but result shape and opacity are never kept apart, so group results (11.4) cannot be formed. |
| 11.3.8 | Summary of Basic Compositing Computations | incomplete | 2 | 1 | Summary clause; the formulas it collects are assessed under 11.3.2 to 11.3.7, and raster.rs blended cites it for the backdrop-alpha weighting. |
| 11.4.2 | Notation for Group Compositing Computations | incomplete | 1 | 0 | Definitions only; the group compositing formulas they serve are assessed under 11.4.4 (raster.rs mix_group cites the clause). |
| 11.4.3 | Group Structure and Nomenclature | incomplete | 3 | 1 | Nested groups form a tree of frames, each painting into its own offscreen buffer whose initial backdrop is a copy of the group backdrop or, for an isolated group, transparent (executor.rs run, raster.rs copy_region), and the finished group composites onto the group backdrop once; the knockout alteration of the group backdrop (11.4.6) is absent. |
| 11.4.4 | Group Compositing Computations | incomplete | 3 | 2 | The non-isolated, non-knockout case runs as the clause's formulas reduce for one alpha per element: the group paints over a copy of its backdrop and raster.rs mix_group removes that backdrop's contribution so it counts once (Note 3), while an isolated group composites through raster.rs composite_group with the alpha, soft mask and blend mode at its Do; a non-isolated group invoked under a non-Normal blend mode is rendered isolated instead, shape and opacity are one value (/AIS is never read) and knockout is absent (11.4.6). |
| 11.4.5 | Isolated Groups | implemented | 3 | 2 | /Group /I true paints the group onto a transparent initial backdrop, so blend modes inside it see only the group's own objects (executor.rs group_kind and run; test non_isolated_group_blends_with_the_page_and_isolated_does_not in executor.rs); /CS is never read, so the flag alone decides and a group naming a blending colour space without /I true stays non-isolated. |
| 11.4.6 | Knockout Groups | not implemented | 1 | 1 | /Group /K is read and a true value is reported as an unsupported Knockout skip (executor.rs group_kind), but nothing knocks out: ExtGState /TK is never read and elements always composite over each other, glyphs one at a time through fill_spans. |
| 11.4.7 | Page Group | incomplete | 4 | 2 | The page renders onto an opaque white pixmap, matching a page group composited against white, and nested groups composite by the group formula (raster.rs mix_group, composite_group), but the page dictionary's /Group (/CS, /I, /K) is never read. |
| 11.4.8 | Summary of Group Compositing Computations | incomplete | 1 | 0 | Summary clause; the general formula is assessed under 11.4.4, the isolated initialization under 11.4.5 and the knockout backdrop choice under 11.4.6 (raster.rs composite_group cites it). |
| 11.5.2 | Deriving a Soft Mask from Group Alpha | implemented | 2 | 1 | The /G form renders on a transparent offscreen page as an isolated group and its alpha channel becomes the mask. |
| 11.5.3 | Deriving a Soft Mask from Group Luminosity | implemented | 2 | 2 | The group renders over an opaque black or /BC backdrop and Rec. 601 luma times alpha becomes the mask with /TR applied; /BC is read as one gray component whatever the group colour space and has no test, and luminosity is taken in device RGB rather than the group /CS. |
| 11.6.2 | Specifying Source and Backdrop Colours | implemented | 1 | 2 | The source colour is the current fill or stroke colour, image sample or shading value converted to RGB, and the backdrop is the pixel already on the page or offscreen mask buffer; no group backdrop exists (11.4). |
| 11.6.3 | Specifying Blending Colour Space and Blend Mode | incomplete | 2 | 2 | /BM as a name or array is honoured, but the blending colour space is never read from a group /CS and is always device RGB. |
| 11.6.4 | Specifying Shape and Opacity | implemented | 10 | 16 | Object coverage, the graphics-state soft mask and the constant alpha all feed one source alpha; see 11.6.4.2 to 11.6.4.4. |
| 11.6.4.2 | Object Shape and Opacity | implemented | 2 | 3 | Fills, strokes, glyph outlines and stencil masks contribute anti-aliased coverage as shape, and image sample alpha (/SMask, /Mask, JPX alpha) contributes opacity. |
| 11.6.4.3 | Mask Shape and Opacity | implemented | 2 | 4 | The ExtGState soft mask multiplies into the alpha of every paint (fills, strokes, glyphs, images, shadings, tiles) and follows q/Q; it is folded into one alpha rather than kept as mask opacity distinct from shape. |
| 11.6.4.4 | Constant Shape and Opacity | implemented | 4 | 7 | /ca and /CA are clamped to 0..=1 and applied to fills, glyphs, images, shadings and tiles (ca) and to strokes (CA); stroke alpha /CA has no test anywhere in the tree. |
| 11.6.5 | Specifying Soft Masks | incomplete | 9 | 16 | Soft-mask dictionaries are complete (11.6.5.2); soft-mask images lack /Matte handling (11.6.5.3). |
| 11.6.5.2 | Soft-Mask Dictionaries | implemented | 2 | 8 | /S (Alpha or Luminosity), /G rendered at the CTM in force at gs from a fresh graphics state, /BC, /TR (function, /Identity, /Default) and /SMask /None are handled with failures reported; /BC is approximated as gray and untested. |
| 11.6.5.3 | Soft-Mask Images | implemented | 5 | 6 | An image /SMask (with /Decode, any dimensions) and JPX /SMaskInData 1 and 2 give per-sample alpha; a /Matte is converted through the base image's colour space (image.rs matte_rgb) and image.rs Rgba::unmatted gives each sample its original colour back before compositing, exact for device gray and RGB and approximate through a non-linear conversion, while a mask whose dimensions differ from the base's is used as stored (image.rs matte_is_unblended_before_compositing, executor.rs matte_samples_are_unblended_before_painting). |
| 11.6.6 | Transparency Group XObjects | incomplete | 6 | 6 | A /Group /S /Transparency form (page content or annotation appearance) runs from alphas 1, blend Normal and no soft mask, renders offscreen and composites once with the alpha, blend mode and soft mask in force at its Do (executor.rs FrameKind::TransparencyGroup, tests transparency_group_composites_with_the_outer_alpha, nested_transparency_groups_multiply_their_alphas, non_isolated_group_blends_with_the_page_and_isolated_does_not). An isolated group renders onto a transparent backdrop, a non-isolated one onto a copy of its backdrop and mixes back by the constant alpha; a non-isolated group under a non-Normal blend mode at its Do renders isolated instead. /K true is reported as SkippedKind::Knockout and not honoured, and /CS is never read (blending stays device RGB). |
| 11.6.7 | Patterns and Transparency | incomplete | 2 | 6 | Shading patterns and tiling cells take the invoking state's constant alpha, blend mode and soft mask, but a tiling cell is not composited as one transparency group, so translucent or overlapping tiles composite tile by tile. |
| 11.7.2 | Colour Spaces for Transparency Groups | not implemented | 0 | 0 | Group /CS is never read and no group colour space exists; everything blends in device RGB. |
| 11.7.3 | Spot Colours and Transparency | out of scope | 0 | 0 | Separation and DeviceN colours pass through their tint transforms into RGB before compositing, so this screen device has no spot colourants to composite separately. |
| 11.7.4 | Overprinting and Transparency | out of scope | 0 | 0 | /OP, /op and /OPM are never read; overprint simulation is a print-preview feature the screen rasterizer does not attempt, which covers 11.7.4.2 to 11.7.4.5 alike. |
| 11.7.5 | Rendering Parameters and Transparency | not implemented | 0 | 0 | Neither halftone and transfer parameters (11.7.5.2) nor rendering intents (11.7.5.3) take part in compositing. |
| 11.7.5.2 | Halftone and Transfer Function | out of scope | 0 | 0 | No halftones or graphics-state transfer functions exist (10.4, 10.5), so there is nothing to apply after group compositing. |
| 11.7.5.3 | Rendering Intent and Colour Conversions | not implemented | 0 | 0 | ri and /RI are parsed then discarded (executor.rs), the ICC crate uses only a profile's default transform (icc lib.rs), and all colour converts to sRGB at paint time rather than at group compositing. |
12 Interactive Features
| Clause | Feature | Status | Code citations | Test citations | Note |
|---|---|---|---|---|---|
| 12.2 | Viewer Preferences | implemented | 7 | 2 | crates/pdfboss-core/src/viewer_preferences.rs viewer_preferences_with reads the catalog's /ViewerPreferences into ViewerPreferences with every entry of Table 150: the six flags (false by default), NonFullScreenPageMode, Direction, the four page-boundary names (ViewArea, ViewClip, PrintArea, PrintClip, CropBox by default), PrintScaling (AppDefault by default and for a name the standard does not list, as the table says), Duplex, PickTrayByPDFSize and NumCopies (None where the table leaves the default to the reader) and PrintPageRange as (first, last) pairs of positive integers, indirect values followed and a name the standard does not list left at the default; Document::viewer_preferences and AsyncDocument::viewer_preferences expose it, None for a catalog without the dictionary (tests viewer_preferences.rs every_viewer_preference_is_read, missing_and_unknown_viewer_preferences_take_the_defaults; crates/pdfboss-aio/tests/parity.rs). Nothing acts on the preferences yet: Direction does not turn the reading order and DisplayDocTitle does not change what info prints; the writer's Viewer struct at crates/pdfboss-write/src/pdf.rs emits the catalog's /PageLayout, /PageMode and /OpenAction, which belong to 7.7.2, and no /ViewerPreferences. |
| 12.3.2 | Destinations | incomplete | 16 | 16 | Explicit /XYZ destinations are written for links, outline items and /OpenAction; crates/pdfboss-core/src/destination.rs parses explicit destination arrays (12.3.2.2) and looks named destinations up in the /Names /Dests tree and the catalog /Dests dictionary (12.3.2.3), and Document::destination follows a /Dest or /D value of either kind; no writer API accepts a destination by name, no destination is followed to a page index, and the importer does not rewrite destinations. |
| 12.3.2.2 | Explicit Destinations | implemented | 7 | 10 | crates/pdfboss-core/src/destination.rs Destination::parse reads every Table 151 form: the page as a reference (DestinationPage::Object) or, for a remote go-to action, a 0-based number (DestinationPage::Number); Fit::Xyz, FitH, FitV, FitBH and FitBV keep a null or left-out coordinate as None (the viewer's current value) and read a zoom of 0 as null, FitR needs all four numbers, an unknown fit name or a page that is neither rejects the array; destination_with resolves an indirect array and indirect numbers while leaving the page reference a reference (tests destination.rs every_table_151_form_parses, null_zero_zoom_and_missing_parameters_keep_the_current_value, remote_destinations_carry_a_page_number, malformed_destinations_are_rejected, indirect_arrays_and_numbers_resolve). The writer emits [page /XYZ null null null] only, which the table allows; the importer copies a source /GoTo /D unchanged instead of rewriting it onto the imported page, a limitation of page assembly documented in docs/src/guide/assembling.md and pinned by crates/pdfboss-write/tests/importer.rs page_import_of_a_linked_source_carries_the_linked_page_along. |
| 12.3.2.3 | Named Destinations | implemented | 5 | 3 | crates/pdfboss-core/src/destination.rs named_destination_with looks a name up in the catalog's /Names /Dests tree (PDF 1.2, string keys, through tree.rs lookup) and then in the catalog's /Dests dictionary (PDF 1.1, name keys), taking a destination array or a dictionary whose /D holds one; named_destinations_with lists both sources sorted by name with the tree winning a duplicate; destination_value_with follows a /Dest or /D value that is an array, a name, a string or a dictionary with /D. Document::named_destination, named_destinations and destination on the sync side, AsyncDocument twins on the async side (tests destination.rs named_destinations_come_from_the_name_tree_and_the_catalog_dictionary, destination_values_follow_arrays_names_strings_and_d_dictionaries; crates/pdfboss-aio/tests/parity.rs names fixture). The writer still has no API that accepts a destination by name. |
| 12.3.3 | Document Outline | implemented | 7 | 7 | crates/pdfboss-core/src/outline.rs outline_with walks the catalog /Outlines through /First and /Next in display order and returns OutlineItem trees: /Title decoded as a text string, the destination from /Dest or from a /GoTo action's /D through destination.rs, open from a positive /Count (Table 153's recursive count is the writer's concern), /C as the title colour, the italic and bold bits of /F (Table 154) and /SE as a reference; a node seen twice or one that is not a dictionary ends its chain, 65536 items and 64 levels bound the walk. Document::outline and AsyncDocument::outline (tests outline.rs outline_items_nest_by_first_and_next, cycles_and_missing_outlines_end_quietly; crates/pdfboss-aio/tests/parity.rs outline fixture). The writer emits a complete outline tree (/Outlines with /First, /Last and /Count; items with /Title, /Parent, /Prev, /Next, /First, /Last, /Count and /Dest) with every item open and never writes /A, /SE, /C or /F, all optional (crates/pdfboss-write/src/pdf.rs Outline and Bookmark). |
| 12.3.4 | Thumbnail Images | implemented | 9 | 6 | crates/pdfboss-core/src/thumbnail.rs thumbnail_with reads a page's /Thumb image XObject into a Thumbnail with the five significant entries (Width, Height, ColorSpace as written, BitsPerComponent, Decode) and the stream itself (Document::thumbnail, AsyncDocument::thumbnail); crates/pdfboss-render/src/extract.rs page_thumbnail decodes it through the image decoder at its own size; pdfboss info counts the pages that carry one and pdfboss images --thumbnails writes them as page-N-thumb.png (tests thumbnail.rs reads_the_pages_thumbnail_and_its_significant_entries, extract.rs page_thumbnail_decodes_the_thumb_image_at_its_own_size, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs cmd_images_writes_thumbnails_with_the_flag). The writer emits no thumbnails, which the clause leaves optional. |
| 12.3.5 | Collections | not implemented | 0 | 0 | The catalog /Collection dictionary and collection schemas are never read or written (the only Collection identifiers in the code are CJK CMap character collections). |
| 12.4.2 | Page Labels | implemented | 9 | 9 | crates/pdfboss-core/src/page_label.rs holds LabelStyle (the five /S styles of Table 159, with numeral giving decimal, subtractive Roman and repeated-letter forms) and PageLabel (first page, style, /P prefix, /St start); page_labels_with reads the catalog /PageLabels number tree through tree.rs entries, skipping non-dictionary values, reading an unknown /S as no style and a /St below 1 as 1; page_label picks the last range at or before a page and falls back to the 1-based page number for a page before the first range, which the clause forbids but files do. Document::page_labels and page_label, AsyncDocument twins (tests page_label.rs styles_number_pages_as_table_159_says, the_clauses_example_labels_its_pages, ranges_without_a_style_or_a_page_zero_still_label; crates/pdfboss-aio/tests/parity.rs outline fixture). The writer re-exports the same types and emits the /PageLabels tree with /S, /P and /St per range, validating the page-0 range, duplicate first pages and start_at (crates/pdfboss-write/src/pdf.rs page_labels_dict). The CLI, TUI and Python bindings do not show labels yet. |
| 12.4.3 | Articles | implemented | 12 | 4 | crates/pdfboss-core/src/article.rs articles_with reads the catalog's /Threads array into ArticleThread records (Table 160: the /I information dictionary through the shared metadata reader, the beads from /F along /N in chain order until the chain returns to a seen bead or breaks, each Bead with its Table 161 /P page and normalized /R rectangle), and page_beads_with reads a page's /B array in drawing order; Document::articles and page_beads and their AsyncDocument twins expose them, and pdfboss info counts threads and beads (tests article.rs walks_each_threads_beads_in_order_and_reads_its_information and a_broken_chain_stops_and_a_document_without_threads_has_none, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_counts_article_threads). The writer emits no threads, which the clause leaves optional. |
| 12.4.4 | Presentations | implemented | 8 | 4 | crates/pdfboss-core/src/presentation.rs presentation_with reads a page's /Dur display duration and its /Trans transition dictionary into a Presentation whose Transition carries every Table 162 entry with the table's defaults filled in (TransitionStyle from /S with R as the default, /D, Dimension from /Dm, Motion from /M, TransitionDirection from /Di as an angle or the name None, /SS, /B); Document::presentation and AsyncDocument::presentation expose it and pdfboss info counts the pages that are slides (tests presentation.rs reads_the_duration_and_the_transition_with_the_tables_defaults and pages_without_the_entries_have_no_presentation_and_odd_values_fall_back, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_counts_slide_pages). Playing a presentation is viewer behaviour, /PresSteps belongs to 12.4.4.2, and the writer emits no /Dur or /Trans, which the clause leaves optional. |
| 12.4.4.2 | Sub-page Navigation | out of scope | 0 | 0 | Navigation nodes (/PresSteps) drive keyboard-stepped presentation playback, which needs a viewer; pdfboss neither reads nor writes them. |
| 12.5.2 | Annotation Dictionaries | implemented | 15 | 9 | crates/pdfboss-core/src/annotation.rs annotations_with reads every Table 164 entry of a page's /Annots in order (Subtype, Rect normalized, Contents, P, NM, M as written and parsed, F, the presence of /AP, AS, Border with its dash array, C, StructParent, OC) into Annotation, exposed as Document::annotations, AsyncDocument::annotations and Page.annotations in Python, and pdfboss info counts annotations by subtype; the renderer reads /Subtype, /Rect, /F, /AP, /AS, /OC and /C for painting, the element listings expose only /Subtype, and the writer emits /Type, /Subtype, /Rect, /Border and /A for links. Tests: annotation.rs reads_the_entries_common_to_every_annotation and direct_malformed_and_missing_annotations. |
| 12.5.3 | Annotation Flags | implemented | 10 | 6 | Hidden (bit 2) and NoView (bit 6) suppress painting, Invisible (bit 1) suppresses it for annotations outside Table 169's standard types, NoRotate (bit 5) turns the appearance back by the page /Rotate about the upper-left corner of /Rect so it stays upright where the rotation put that corner (paint_annotations in crates/pdfboss-render/src/executor.rs), and /OC visibility is honoured. Print, NoZoom and the interactive flags (ReadOnly, Locked, ToggleNoView, LockedContents) have no effect on a raster of the on-screen view at a chosen resolution. Tests: invisible_flag_hides_only_unknown_annotation_types and no_rotate_annotations_stay_upright_on_a_rotated_page in crates/pdfboss-render/src/executor.rs. |
| 12.5.4 | Border Styles | implemented | 3 | 2 | Border::read in crates/pdfboss-render/src/annot.rs takes /BS (/W, /S, and /D under /S /D) or else /Border ([h v w [dash]]): Line, Square, Circle, Polygon, PolyLine and Ink appearances use the width and dash pattern, and a Link without /AP gets its border drawn inside /Rect in /C by Paint::link_border with the /Border corner radii, the U style as a line along the bottom edge, and a width past half the rectangle falling back to 1 as pdf.js does. The B and I styles are drawn solid, and a Link declaring neither /Border nor /BS draws no border, as Acrobat and pdf.js do rather than the clause's 1-point default. The writer puts /Border [0 0 0] on every link so no border shows. Test: link_borders_without_appearance_follow_the_border_style in crates/pdfboss-render/src/executor.rs. |
| 12.5.5 | Appearance Streams | implemented | 3 | 6 | The /AP /N normal appearance is selected (through /AS for a state dictionary, a single-state dictionary needing no /AS), its /BBox transformed by /Matrix is fitted onto the normalized /Rect per the clause's algorithm and run as a form XObject, and a declared appearance that cannot be read or placed is reported; /R and /D are pointer states a rasterizer does not show. |
| 12.5.6 | Annotation Types | incomplete | 51 | 41 | Assessed as a group: every type paints through its /AP /N appearance, /Popup annotations are skipped, /AS state selection serves Widget check boxes and radio buttons, and pdfboss-write emits /Link annotations; without /AP only Line, Square, Circle, Polygon, PolyLine, Ink and the four text markup types get an appearance synthesized from their own entries and a Link gets the border its /Border or /BS declares (crates/pdfboss-render/src/annot.rs), every other type paints nothing; text extraction reads the /QuadPoints of the four text markup subtypes (12.5.6.10) into span flags. |
| 12.5.6.2 | Markup Annotations | implemented | 4 | 2 | Every Table 170 entry (/T, /Popup, /CA with its default 1, /RC as a string or a text stream, /CreationDate, /IRT, /Subj, /RT with its default R, /IT) is read into Markup for the seventeen markup subtypes of Table 169, and a Link, Popup or Widget carries none (crates/pdfboss-core/src/annotation.rs read_markup; test reads_the_markup_entries); the renderer folds /CA into the appearances it builds for annotations without /AP and leaves an existing appearance stream as written (Paint::read in crates/pdfboss-render/src/annot.rs; test constant_opacity_applies_to_synthesized_appearances_only in executor.rs). /ExData, which only 3D markup uses, is not read. |
| 12.5.6.3 | Annotation States | implemented | 1 | 1 | A text annotation's /StateModel and /State are read into AnnotationState, the state defaulting to Unmarked under Marked and None under Review when only the model is given, with the /IRT reference to the annotation whose state changes carried in its Markup (crates/pdfboss-core/src/annotation.rs read_state; test reads_the_state_a_reply_sets); no per-user state is aggregated across a reply thread, which is a viewer's comments pane. |
| 12.5.6.4 | Text Annotations | incomplete | 2 | 1 | /Open, /Name (the icon), /State and /StateModel are read into Annotation (crates/pdfboss-core/src/annotation.rs read_annotation; test reads_the_state_a_reply_sets); painting goes only through the group /AP path (tested with /Stamp and Widget fixtures, not /Text), no icon is synthesized without /AP, and NoRotate keeps the icon upright on a rotated page (12.5.3) but the fixed-size NoZoom behaviour the clause expects is not applied. |
| 12.5.6.5 | Link Annotations | implemented | 6 | 11 | Link annotations with /Rect, /Border [0 0 0] and a /URI or /GoTo action are written from Rust, Python, Markdown and the TOML manifest; on read the annotation reader resolves /Dest, explicit or named, into a Destination and /A into an Action chain (crates/pdfboss-core/src/annotation.rs read_annotation; test reads_link_destinations_and_popups), and a Link without /AP paints only the border its /Border or /BS entry declares (12.5.4); /H, /PA and /QuadPoints are not read, and text extraction does not report link targets. |
| 12.5.6.6 | Free Text Annotations | incomplete | 1 | 0 | Painted only through the group /AP path; /DA, /Q, /RC, /DS, /CL, /IT, /BE and /RD are ignored and no appearance is generated from them. |
| 12.5.6.7 | Line Annotations | implemented | 4 | 1 | A Line without /AP is painted from its own entries by Paint::line in crates/pdfboss-render/src/annot.rs: the /L segment stroked in /C with the /BS or /Border width and dash pattern, moved sideways by /LL with the leader lines, their /LLE extensions and /LLO gaps, and the /LE endings of Table 176 drawn at the line width with the closed ones filled in /IC; the appearance is placed in default user space, so an empty or wrong /Rect neither moves nor clips it. /Cap captions with /CO and /CP, /IT and /Measure are not drawn. Test: line_annotations_without_appearance_are_drawn in crates/pdfboss-render/src/executor.rs. |
| 12.5.6.8 | Square and Circle Annotations | implemented | 3 | 1 | A Square or Circle without /AP is painted from its own entries by synthesized in crates/pdfboss-render/src/annot.rs: the rectangle or the inscribed ellipse inside /Rect shrunk by /RD, stroked in /C with the /BS or /Border width and dash pattern and filled with /IC, at the /CA opacity; an /AP still takes precedence and a /BE cloudy border is drawn plain. Test: square_and_circle_annotations_without_appearance_are_drawn in crates/pdfboss-render/src/executor.rs. |
| 12.5.6.9 | Polygon and Polyline Annotations | implemented | 3 | 1 | A Polygon or PolyLine without /AP is painted from its own entries by Paint::polyline in crates/pdfboss-render/src/annot.rs: the /Vertices joined by straight lines in /C at the /BS or /Border width and dash pattern, a polygon closed back to its first vertex and filled with /IC, a polyline left open with the /LE endings of Table 176 at its first and last vertex; the appearance is placed in default user space by its own box. /BE cloudy borders are drawn plain and /IT and /Measure are not read. Test: polygon_and_polyline_annotations_without_appearance_are_drawn in crates/pdfboss-render/src/executor.rs. |
| 12.5.6.10 | Text Markup Annotations | implemented | 5 | 10 | A Highlight, Underline, StrikeOut or Squiggly without /AP is painted from its own entries by Paint::markup in crates/pdfboss-render/src/annot.rs: each /QuadPoints quadrilateral is taken by its extent (writers disagree on the corner order), a Highlight filling it in /C with the Multiply blend mode so the text under it stays legible, an Underline stroking a 1-unit line along its bottom edge, a StrikeOut one through its middle and a Squiggly a zigzag a quarter of its height along the bottom, all at the /CA opacity and placed in default user space by their own box. Text extraction reads the same /QuadPoints (each quadrilateral by its extent, /Rect without them) into the span flags underline, strikethrough and highlight, with the /C color on highlights and Hidden, NoView and /OC-hidden annotations skipped (pdfboss-text decorations.rs markup_annotations, tested by the annotation tests in pdfboss-text lib.rs). Test: text_markup_annotations_without_appearance_are_drawn in crates/pdfboss-render/src/executor.rs. |
| 12.5.6.11 | Caret Annotations | incomplete | 1 | 0 | Painted only through the group /AP path; /RD and /Sy are ignored. |
| 12.5.6.12 | /Stamp annotations | implemented | 2 | 5 | /Stamp fixtures are the group's main test subject: the /AP appearance is fitted onto /Rect, hidden and OC-gated ones stay silent, and unreadable ones are reported; the /Name icon is not synthesized when /AP is absent. |
| 12.5.6.13 | Ink Annotations | implemented | 2 | 1 | An Ink without /AP is painted from its own entries by Paint::ink in crates/pdfboss-render/src/annot.rs: every /InkList path stroked through its points with straight segments, round caps and joins in /C at the /BS or /Border width and dash pattern, a single point becoming a dot of the line width, at the /CA opacity; the appearance is placed in default user space by its own box, so an unnormalized /Rect neither moves nor clips it. Curve smoothing between the points (permitted by ISO 32000-2) is not done. Test: ink_annotations_without_appearance_are_drawn in crates/pdfboss-render/src/executor.rs. |
| 12.5.6.14 | Pop-up Annotations | implemented | 2 | 2 | Pop-up annotations are skipped by the renderer even when they carry an /AP, which matches a static page render where the pop-up window is viewer UI; the annotation reader exposes /Parent and /Open (crates/pdfboss-core/src/annotation.rs read_annotation; test reads_link_destinations_and_popups). |
| 12.5.6.15 | File Attachment Annotations | incomplete | 2 | 1 | The annotation reader carries /FS as a FileSpec and /Name as the icon name, and Document::file_spec_data decodes the embedded stream (crates/pdfboss-core/src/annotation.rs read_annotation; test reads_a_file_attachment_and_its_data); painting goes only through the group /AP path and no icon is synthesized without /AP. |
| 12.5.6.16 | Sound Annotations | incomplete | 1 | 0 | Painted only through the group /AP path; /Sound and /Name are ignored and playback is viewer behavior. |
| 12.5.6.17 | Movie Annotations | incomplete | 1 | 0 | Painted only through the group /AP path; /Movie, /A and /T are ignored and playback is viewer behavior. |
| 12.5.6.18 | Screen Annotations | incomplete | 1 | 0 | Painted only through the group /AP path; /MK, /A and /AA are ignored and media playback is viewer behavior. |
| 12.5.6.19 | Widget Annotations | implemented | 4 | 1 | A widget's appearance comes from /AP /N with /AS selecting among the state dictionary's streams, and an /AS naming no state is reported as a dropped annotation; /H, /BS and the /MK colours and rotation are not read, so no appearance is generated for a widget lacking /AP. The /MK captions and icons reach the form fields (12.7.4.2.2). |
| 12.5.6.20 | Printer’s Mark Annotations | incomplete | 1 | 0 | Painted through the group /AP path like any other annotation; /MN is ignored and there is no print-versus-display distinction, so marks always show. |
| 12.5.6.21 | Trap Network Annotations | incomplete | 1 | 0 | Painted through the group /AP path; /LastModified, /Version, /AnnotStates and /FontFauxing are ignored and the page /LastModified consistency rule is not checked. |
| 12.5.6.22 | Watermark Annotations | incomplete | 1 | 0 | Painted through the group /AP path with the page; the /FixedPrint dictionary (/Matrix, /H, /V) is ignored, and the overlay command in crates/pdfboss-write/src/update.rs adds page content rather than a /Watermark annotation. |
| 12.5.6.23 | Redaction Annotations | incomplete | 1 | 0 | Painted through the group /AP path; /QuadPoints, /IC, /RO, /OverlayText, /Repeat, /DA and /Q are ignored, and applying a redaction (removing the content beneath) is not offered. |
| 12.6.2 | Action Dictionaries | implemented | 7 | 5 | crates/pdfboss-core/src/annotation.rs action_with reads an action dictionary's /S and its /Next entry, a single dictionary or an array, into an Action tree in order, a visited set over references stopping a chain that points back at an earlier action and a budget of 256 dictionaries stopping a runaway tree (tests follows_next_chains_once and a_runaway_next_chain_stops_at_the_budget); the writer emits /URI and /GoTo dictionaries without /Next, and no action is ever executed. |
| 12.6.3 | Trigger Events | implemented | 10 | 5 | The additional-actions dictionaries of annotations (Table 194, with the field triggers of Table 196 a merged widget and field dictionary carries), pages (Table 195) and the catalog (Table 197) are read into TriggeredAction lists in table order (crates/pdfboss-core/src/annotation.rs triggered_actions, page_additional_actions_with and document_additional_actions_with; test reads_trigger_events_on_annotations_pages_and_the_catalog); the writer emits the catalog /OpenAction as an explicit destination to the open_to page, and firing a trigger is viewer behaviour. |
| 12.6.4 | Action Types | implemented | 31 | 18 | Every Table 198 type is read as data and none is executed: GoTo, GoToR, GoToE, Launch, URI, Named and JavaScript into their own ActionKind entries, the other ten kinds with their dictionary kept as written (crates/pdfboss-core/src/annotation.rs read_action_kind; test reads_every_action_type); the writer emits only /URI and /GoTo. |
| 12.6.4.2 | Go-To Actions | implemented | 4 | 3 | /GoTo actions with /S /GoTo and an explicit /D destination are written for page-target links with the target page range-checked; on read the destination, explicit or named, resolves through the document's destinations into ActionKind::GoTo, the name kept beside it (crates/pdfboss-core/src/annotation.rs destination_entry; test reads_link_destinations_and_popups), a /GoTo is never followed (viewer behavior) and the importer copies it unchanged. |
| 12.6.4.3 | Remote Go-To Actions | implemented | 2 | 2 | /GoToR actions read their /F file specification, an explicit /D whose first element is a 0-based page number of the other document or a named /D kept as the name, and /NewWindow (crates/pdfboss-core/src/annotation.rs read_action_kind and destination_entry; test reads_remote_go_to_actions); nothing writes or follows one. |
| 12.6.4.4 | Embedded Go-To Actions | implemented | 8 | 1 | /GoToE actions read /F, /D, /NewWindow and the /T target chain, each Table 202 step with /R, /N, /P (a page number or a named destination) and /A (an annotation index or name), nested through /T to a depth of 32 (crates/pdfboss-core/src/annotation.rs read_target; test reads_embedded_go_to_targets); nothing writes or follows one. |
| 12.6.4.5 | Launch Actions | implemented | 6 | 1 | /Launch actions read /F, /NewWindow and the Windows parameters of /Win (Table 204: /F, /D, /O with its default open, /P) as data (crates/pdfboss-core/src/annotation.rs read_windows_launch; test reads_launch_actions); /Mac and /Unix are undefined by the standard, nothing writes a launch action, and launching an application is viewer behaviour. |
| 12.6.4.6 | Thread Actions | out of scope | 0 | 0 | Thread actions step through article beads inside a viewer; pdfboss executes no actions and does not model article threads. |
| 12.6.4.7 | URI Actions | implemented | 4 | 6 | /URI actions with /S /URI and the URI as a text string are written for external links from every writer front end; on read the annotation reader carries /URI and /IsMap in ActionKind::Uri (crates/pdfboss-core/src/annotation.rs read_action_kind; test reads_every_action_type); the catalog-level /URI base dictionary is not handled, and text extraction does not report URIs. |
| 12.6.4.8 | Sound Actions | out of scope | 0 | 0 | Sound playback needs a viewer with audio output; pdfboss executes no actions. |
| 12.6.4.9 | Movie Actions | out of scope | 0 | 0 | Movie playback needs a viewer with media output; pdfboss executes no actions. |
| 12.6.4.10 | Hide Actions | out of scope | 0 | 0 | Hiding or showing annotations at run time is viewer state; the renderer honors only the static Hidden flag. |
| 12.6.4.11 | Named Actions | out of scope | 1 | 1 | NextPage, PrevPage, FirstPage and LastPage are viewer navigation; pdfboss executes no actions. |
| 12.6.4.12 | Set-OCG-State Actions | out of scope | 0 | 0 | Toggling optional content groups at run time is viewer state; the renderer applies only the document's default /OCProperties /D configuration. |
| 12.6.4.13 | Rendition Actions | out of scope | 0 | 0 | Multimedia rendition playback needs a viewer; pdfboss executes no actions. |
| 12.6.4.14 | Transition Actions | out of scope | 0 | 0 | Page transitions are presentation playback inside a viewer; pdfboss executes no actions. |
| 12.6.4.15 | Go-To-3D-View Actions | out of scope | 0 | 0 | 3D artwork views need a 3D-capable viewer; pdfboss neither renders 3D streams nor executes actions. |
| 12.6.4.16 | JavaScript Actions | out of scope | 1 | 1 | pdfboss has no JavaScript engine and never executes document scripts. |
| 12.7.2 | Interactive Form Dictionary | implemented | 8 | 2 | Document::interactive_form reads the catalog /AcroForm dictionary: /Fields and /CO as reference lists, /NeedAppearances, the /SigFlags bits SignaturesExist and AppendOnly, /DR, /DA as written, /Q as a quadding and whether /XFA is present; absent or ill-typed entries take the Table 218 defaults. Nothing writes the dictionary. |
| 12.7.3 | Field Dictionaries | implemented | 15 | 11 | Document::form_fields (crates/pdfboss-core/src/form.rs form_fields_with) walks the /Fields tree depth first into one FormField per field dictionary: /FT, /Ff, /V and /DV are taken from the nearest ancestor when the field lacks them, /T, /TU and /TM are decoded as text strings, /AA is kept as written, a /Kids entry that is a /Subtype /Widget dictionary without a /T is recorded as one of the field's Widget records (reference, /AS, on state) and a merged field is its own widget; a reference seen before, a non-dictionary kid and nesting past 64 levels are skipped. Only the render path uses fields so far, and nothing writes them. |
| 12.7.3.2 | Field Names | implemented | 1 | 2 | FormField::name is the fully qualified name (crates/pdfboss-core/src/form.rs qualified_name): the partial /T names from the root field down joined by a PERIOD, a field without /T sharing its parent's name, an unnamed root the empty name. /T itself is kept as partial_name. Nothing checks that a partial name is free of periods. |
| 12.7.3.3 | Variable Text | incomplete | 3 | 4 | FormField::default_appearance and quadding (crates/pdfboss-core/src/form.rs) carry a field's /DA and /Q from its own dictionary, its nearest ancestor or the interactive form's document-wide defaults, left-justified when none sets /Q; DefaultAppearance::parse reads the Tf font resource name and size and the g, rg or k fill colour out of the string; default_style and rich_text hold /DS and /RV as text, a /RV text stream read into a string. No appearance stream is generated for text-bearing fields, so /NeedAppearances documents still show only pre-existing /AP streams. |
| 12.7.3.4 | Rich Text Strings | not implemented | 0 | 0 | /RV reaches FormField::rich_text as text and /DS FormField::default_style (12.7.3.3); the rich text markup and the style string are not parsed. |
| 12.7.4 | Field Types | implemented | 18 | 21 | FieldType (crates/pdfboss-core/src/form.rs) names the four field types of Table 220 and each has its row: buttons by kind and state (12.7.4.2), text with its text and MaxLen (12.7.4.3), choice with options and selection (12.7.4.4), signature as a signature dictionary (12.7.4.5); the widget-level /AS state selection that check boxes and radio buttons rely on is handled by the render path. No appearance stream is generated for any type (12.7.3.3). |
| 12.7.4.2 | Button Fields | implemented | 10 | 12 | FormField::button_kind (crates/pdfboss-core/src/form.rs) tells pushbuttons, check boxes and radio button fields apart by the Table 226 Pushbutton and Radio bits, FieldFlags exposes no_toggle_to_off, radio, pushbutton and radios_in_unison, and FormField::state returns the /V appearance-state name of a check box or radio button field (none for a pushbutton, which keeps no value); the render path selects each widget's appearance through /AS. Matching /V to the individual widgets' states is the radio button row's. |
| 12.7.4.2.2 | Pushbuttons | implemented | 1 | 2 | Each Widget carries its /MK appearance characteristics (crates/pdfboss-core/src/form.rs AppearanceCharacteristics, Table 189): the normal, rollover and alternate captions decoded as text strings, the normal, rollover and alternate icon references and the /TP caption position; FormField::button_kind names a pushbutton by its flag and FormField::state gives it no value. No appearance is generated from these entries: a pushbutton still paints only through a pre-existing /AP. |
| 12.7.4.2.3 | Check Boxes | implemented | 4 | 3 | FormField::checked (crates/pdfboss-core/src/form.rs) reports a check box as checked when its /V state is a name other than Off, an absent value being the off state; each Widget carries its /AS and the on state read as the one non-Off key of its /AP /N dictionary, FormField::on_widgets lists the widgets whose on state is the field's state, and the Table 227 /Opt export values are the field's options, indexed like the widgets. The render path selects the on or off appearance by /AS. |
| 12.7.4.2.4 | Radio Buttons | implemented | 2 | 4 | FormField::on_widgets (crates/pdfboss-core/src/form.rs) picks the kid widgets whose on state, the non-Off key of their /AP /N, is the parent field's /V, several at once for a RadiosInUnison group; FormField::state is Off for a radio button field without /V, the clause's default; the Table 227 /Opt export values are the field's options, indexed like the widgets; NoToggleToOff and RadiosInUnison are FieldFlags bits. Toggling is viewer interaction, which pdfboss has none of; the render path selects each widget's appearance by /AS. |
| 12.7.4.3 | Text Fields | implemented | 1 | 3 | FormField::text (crates/pdfboss-core/src/form.rs) decodes a text field's /V as a text string, a PDF 1.5 stream value being read into a string during the walk; FormField::max_len reads the inheritable /MaxLen of Table 229; FieldFlags exposes the Table 228 bits multiline, password, file_select, do_not_spell_check, do_not_scroll, comb and rich_text. No appearance stream is generated (12.7.3.3) and /RV is not parsed (12.7.3.4), so a text field still shows only a pre-existing /AP. |
| 12.7.4.4 | Choice Fields | implemented | 4 | 3 | FormField::options (crates/pdfboss-core/src/form.rs) reads /Opt into ChoiceOption export_value/name pairs, a lone text string naming both; top_index reads /TI, selected_indices reads /I as written, and FormField::selected returns the /V name or names of a choice field; FieldFlags exposes the Table 230 bits combo, edit, sort, multi_select and commit_on_sel_change (do_not_spell_check is shared with text fields). /I is not reconciled with /V and no appearance stream is generated. |
| 12.7.4.5 | Signature Fields | implemented | 1 | 2 | FormField::signature (crates/pdfboss-core/src/form.rs) reads a signature field's /V as a Signature record with the Table 252 entries Filter, SubFilter, ByteRange pairs, Contents bytes, Name, M as written, Location, Reason and ContactInfo, and FormField::lock and seed_value hold the Table 232 /Lock and /SV references. The lock and seed value dictionaries' entries are not read and nothing is signed or verified (12.8). |
| 12.7.5 | Form Actions | out of scope | 0 | 0 | Submit, reset and import-data actions need a viewer, a form-filling state and a network; pdfboss executes no actions. |
| 12.7.5.2 | Submit-Form Action | out of scope | 0 | 0 | Submitting field data over HTTP is viewer behavior; pdfboss executes no actions. |
| 12.7.5.3 | Reset-Form Action | out of scope | 0 | 0 | Resetting fields to their defaults needs a form-filling state; pdfboss executes no actions. |
| 12.7.5.4 | Import-Data Action | out of scope | 0 | 0 | Importing FDF data into a form needs a form-filling state; pdfboss executes no actions. |
| 12.7.6 | Named Pages | not implemented | 0 | 0 | The /Names /Pages and /Names /Templates name trees are never read or written. |
| 12.7.7 | Forms Data Format | not implemented | 0 | 0 | No FDF file is parsed or written anywhere in the code. |
| 12.7.7.2 | FDF File Structure | not implemented | 0 | 0 | The document loader recognizes only %PDF headers and never reads an FDF header, body or trailer. |
| 12.7.7.2.2 | FDF Header | not implemented | 0 | 0 | The %FDF header is not recognized. |
| 12.7.7.2.3 | FDF Body | not implemented | 0 | 0 | FDF body objects are never parsed. |
| 12.7.7.2.4 | FDF Trailer | not implemented | 0 | 0 | An FDF trailer (/Root only, no xref) is never parsed. |
| 12.7.7.3 | FDF Catalog | not implemented | 0 | 0 | The FDF catalog and its /FDF dictionary are never read or written. |
| 12.7.7.3.2 | FDF Fields | not implemented | 0 | 0 | FDF field dictionaries are never read or written. |
| 12.7.7.3.3 | FDF Pages | not implemented | 0 | 0 | FDF page and template dictionaries are never read or written. |
| 12.7.7.3.4 | FDF Annotation Dictionaries | not implemented | 0 | 0 | FDF annotation dictionaries with /Page are never read or written. |
| 12.7.8 | XFA Forms | not implemented | 0 | 0 | The /AcroForm /XFA entry and XFA packets are never read, so an XFA document renders only its PDF page content. |
| 12.8.1 | General | incomplete | 5 | 0 | The Signature record (crates/pdfboss-core/src/form.rs) reads Table 252's Filter, SubFilter, ByteRange, Contents, Name, M, Location, Reason and ContactInfo as data through FormField::signature; Cert, Reference, Changes and the Prop_ entries are not read, nothing is written, and there is no signing or verification. |
| 12.8.2 | Transform Methods | not implemented | 0 | 0 | Signature reference dictionaries and their /TransformMethod are never read. |
| 12.8.2.2 | DocMDP | not implemented | 0 | 0 | DocMDP transform parameters (/P, /V) are never read. |
| 12.8.2.2.2 | Validating Signatures That Use the DocMDP Transform Method | not implemented | 0 | 0 | No DocMDP validation exists, and the incremental-update writer in crates/pdfboss-write/src/update.rs appends changes without checking for a certifying signature. |
| 12.8.2.3 | UR | not implemented | 0 | 0 | Usage-rights transform parameters are never read. |
| 12.8.2.4 | FieldMDP | not implemented | 0 | 0 | FieldMDP transform parameters (/Action, /Fields) are never read. |
| 12.8.3 | Signature Interoperability | not implemented | 0 | 0 | No /SubFilter value is recognized and no signature bytes are decoded. |
| 12.8.3.2 | PKCS#1 Signatures | not implemented | 0 | 0 | adbe.x509.rsa_sha1 signatures and /Cert chains are never decoded or verified. |
| 12.8.3.3 | PKCS#7 Signatures as used in ISO 32000 | not implemented | 0 | 0 | PKCS#7 signature containers are never decoded or verified. |
| 12.8.3.3.2 | Revocation Information | not implemented | 0 | 0 | Embedded CRL and OCSP data are never read. |
| 12.8.4 | Permissions | incomplete | 2 | 4 | crates/pdfboss-core/src/permission.rs permission_handlers_with reads the catalog's /Perms dictionary (Table 258) into PermissionHandlers: the /DocMDP and /UR3 signature dictionaries as Signature records through the shared Signature::from_dict (Table 252 as data); Document::permission_handlers and AsyncDocument::permission_handlers expose them and pdfboss info names the handlers present (tests permission.rs reads_the_doc_mdp_and_usage_rights_handlers and missing_or_malformed_permissions_read_as_absent, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_lists_permission_handlers). The signatures are not verified and the permissions their DocMDP and UR transform parameters carry (12.8.2.2, 12.8.2.3) are neither read nor enforced; the /Perms handling in crates/pdfboss-core/src/crypt.rs is the AES-256 encryption dictionary entry of §7.6.3.3. |
| 12.8.5 | Legal Content Attestations | implemented | 6 | 4 | crates/pdfboss-core/src/legal.rs legal_attestation_with reads the catalog's /Legal dictionary (Table 259) into LegalAttestation: every count entry as an integer, 0 when absent or not a non-negative integer, and the /Attestation text; Document::legal_attestation and AsyncDocument::legal_attestation expose it and pdfboss info prints the non-zero counts by key and whether an attestation is present (tests legal.rs reads_the_legal_attestation_counts and missing_or_malformed_legal_reads_as_absent, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_lists_the_legal_attestation). The counts are taken as written and not recomputed from the document, and pdfboss-write emits no /Legal dictionary (Legal elsewhere in the code is a page size). |
| 12.9 | Measurement Properties | implemented | 12 | 5 | crates/pdfboss-core/src/measure.rs viewports_with reads a page's /VP array (Table 260) into Viewport records with the normalized /BBox, the /Name and the /Measure dictionary, which measure_from reads (Tables 261 and 262) as a Measure with the /Subtype (RL default, others kept by name), the /R scale ratio, the /X, /Y, /D, /A, /T and /S number-format chains (Table 263, every entry with the table's default), the /O origin and /CYX; a viewport without a four-number /BBox and a number format without /U or /C are skipped, and array order is kept so the last viewport containing a point wins as the clause says. Document::viewports and AsyncDocument::viewports expose them and pdfboss info counts the viewports and the pages holding them (tests measure.rs reads_viewports_with_their_measures_and_the_tables_defaults, reads_the_optional_measure_entries and missing_or_malformed_viewports_read_as_none, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_counts_viewports). The /Measure entry a line annotation may carry (Table 175) is not read there, and pdfboss-write emits no viewport. |
| 12.10.1 | General | implemented | 2 | 4 | crates/pdfboss-core/src/requirement.rs requirements_with reads the catalog's /Requirements array (Table 266) into Requirement records carrying the /S type and the /RH handlers (12.10.2), in array order, skipping an entry that is no dictionary or has no /S name; Document::requirements and AsyncDocument::requirements expose them and pdfboss info prints the types (tests requirement.rs reads_the_requirements_in_order and missing_or_malformed_requirements_read_as_none, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_lists_requirements). Whether the reader meets a requirement is the consumer's call; pdfboss-write emits no /Requirements, which the clause does not ask of a writer. |
| 12.10.2 | Requirement Handlers | implemented | 2 | 3 | crates/pdfboss-core/src/requirement.rs handlers_with reads a requirement's /RH entry, one handler dictionary or an array of them (Table 267), into RequirementHandler records with the /S kind (JS or NoOp, other names kept as written) and the /Script name of the document-level JavaScript, skipping a handler that is no dictionary or has no /S (tests requirement.rs reads_the_requirement_handlers and missing_or_malformed_handlers_read_as_none, crates/pdfboss-aio/tests/parity.rs). The handler is data: pdfboss has no JavaScript engine, so running the named script is viewer behavior. |
13 Multimedia Features
| Clause | Feature | Status | Code citations | Test citations | Note |
|---|---|---|---|---|---|
| 13.1 | General | out of scope | 0 | 0 | pdfboss parses, extracts, renders and creates PDFs and is not a viewer, so multimedia playback has no place in it. |
| 13.2 | Multimedia | out of scope | 1 | 0 | No crate reads a rendition, media clip or media player dictionary; a grep for those key names finds nothing in the Rust sources. |
| 13.2.1 | General | out of scope | 0 | 0 | A Screen annotation's /AP /N appearance still paints generically through crates/pdfboss-render/src/executor.rs, but its /A rendition action and /MK are never read because playback is a viewer concern. |
| 13.2.2 | Viability | out of scope | 0 | 0 | Viability (the /MH must-honor and /BE best-effort dictionaries) only matters when choosing a player to run, which pdfboss never does. |
| 13.2.3 | Renditions | out of scope | 1 | 0 | Rendition dictionaries are never read because pdfboss does not play media. |
| 13.2.3.1 | General | out of scope | 1 | 0 | Same as 13.2.3: no rendition dictionary is read. |
| 13.2.3.2 | Media Renditions | out of scope | 0 | 0 | Media rendition dictionaries (/S /MR) are never read because pdfboss does not play media. |
| 13.2.3.3 | Selector Renditions | out of scope | 0 | 0 | Selector renditions (/S /SR) are never read because pdfboss does not play media. |
| 13.2.4 | Media Clip Objects | out of scope | 0 | 0 | Media clip dictionaries are never read because pdfboss does not play media. |
| 13.2.4.1 | General | out of scope | 0 | 0 | Same as 13.2.4: no media clip dictionary is read. |
| 13.2.4.2 | Media Clip Data | out of scope | 0 | 0 | Media clip data (/S /MCD) and its file specification are never read; pdfboss neither extracts nor plays embedded media. |
| 13.2.4.3 | Media Clip Section | out of scope | 0 | 0 | Media clip sections (/S /MCS) are never read because pdfboss does not play media. |
| 13.2.5 | Media Play Parameters | out of scope | 0 | 0 | Play parameters (volume, repeat, duration) only apply during playback, which pdfboss never does. |
| 13.2.6 | Media Screen Parameters | out of scope | 0 | 0 | Screen parameters (window, floating window, background) describe a viewer's playback window and are never read. |
| 13.2.6.1 | General | out of scope | 0 | 0 | Same as 13.2.6: no screen parameter dictionary is read. |
| 13.2.6.2 | Media Offset Dictionary | out of scope | 0 | 0 | Media offsets (time, frame, marker) are only meaningful during playback and are never read. |
| 13.2.6.3 | Timespan Dictionary | out of scope | 0 | 0 | Timespan dictionaries are only meaningful during playback and are never read. |
| 13.2.7 | Other Multimedia Objects | out of scope | 0 | 0 | Media players, player info, software identifiers and monitor specifiers describe a viewer's environment and are never read. |
| 13.2.7.1 | General | out of scope | 0 | 0 | Same as 13.2.7: none of these dictionaries is read. |
| 13.2.7.2 | Media Players Dictionary | out of scope | 0 | 0 | Player selection lists (/MU, /A, /NU) are never read because pdfboss does not select or run players. |
| 13.2.7.3 | Media Player Info Dictionary | out of scope | 0 | 0 | Player info dictionaries are never read because pdfboss does not select or run players. |
| 13.2.7.4 | Software Identifier Dictionary | out of scope | 0 | 0 | Software identifiers are never read because pdfboss does not select or run players. |
| 13.2.7.4.1 | General | out of scope | 0 | 0 | Same as 13.2.7.4: no software identifier is read. |
| 13.2.7.4.2 | Software URIs | out of scope | 0 | 0 | Software URIs are never read because pdfboss does not select or run players. |
| 13.2.7.4.3 | Version arrays | out of scope | 0 | 0 | Player version arrays are never read because pdfboss does not select or run players. |
| 13.2.7.5 | Monitor Specifier | out of scope | 0 | 0 | Monitor specifiers describe the viewer's display and are never read. |
| 13.3 | Sounds | out of scope | 0 | 0 | Sound objects and a Sound annotation's /Sound stream are never read; the annotation's /AP /N appearance paints generically through crates/pdfboss-render/src/executor.rs and crates/pdfboss-core/src/elements.rs lists it by subtype like any other annotation. |
| 13.4 | Movies | out of scope | 0 | 0 | Movie dictionaries and movie activation dictionaries are never read; a Movie annotation's /AP /N appearance paints generically through crates/pdfboss-render/src/executor.rs. |
| 13.5 | Alternate Presentations | out of scope | 0 | 0 | The /AlternatePresentations name tree and SlideShow dictionaries are never read because pdfboss does not present documents. |
| 13.6.1 | General | out of scope | 0 | 0 | 3D artwork (3D annotations, 3D streams, U3D data) is never read because pdfboss neither renders nor extracts 3D content. |
| 13.6.3.1 | General | out of scope | 0 | 0 | 3D stream dictionaries (/Type /3D) are never read; pdfboss does no 3D work. |
| 13.6.4.1 | General | out of scope | 0 | 0 | 3D view dictionaries are never read; pdfboss does no 3D work. |
| 13.6.4.2 | Projection Dictionaries | out of scope | 0 | 0 | 3D projection dictionaries are never read; pdfboss does no 3D work. |
| 13.6.5 | Coordinate Systems for 3D | out of scope | 0 | 0 | 3D coordinate systems are never used; pdfboss does no 3D work. |
14 Document Interchange
| Clause | Feature | Status | Code citations | Test citations | Note |
|---|---|---|---|---|---|
| 14.2 | Procedure Sets | not implemented | 0 | 0 | Nothing reads or emits /ProcSet: the reader ignores the resource (which the clause allows) and pdfboss-write never adds a /ProcSet array to the resource dictionaries it writes. |
| 14.3.2 | Metadata Streams | incomplete | 2 | 4 | The writer emits an unfiltered /Type /Metadata /Subtype /XML packet on the catalog and rewrites it on update, and the reader honors EncryptMetadata false for that stream, but no reader path parses XMP (Document::metadata reads only /Info) and metadata streams on pages, XObjects or other components are neither read nor written. |
| 14.3.3 | Document Information Dictionary | incomplete | 4 | 5 | Title, Author, Subject, Keywords, Creator, Producer, CreationDate and ModDate are read as decoded text strings and written back (with the XMP packet kept in step on update), but the /Trapped entry of Table 317 is neither read nor written. |
| 14.4 | File Identifiers | implemented | 2 | 4 | The writer emits a two-element /ID of 16-byte SHA-256-derived strings, an incremental update keeps the first half and replaces the second, the strings stay unencrypted, and the reader consumes the trailer /ID for the encryption key. |
| 14.5 | Page-Piece Dictionaries | implemented | 10 | 4 | crates/pdfboss-core/src/piece_info.rs piece_info_with reads a dictionary's /PieceInfo (Table 318) into one PagePiece per product with the Table 319 entries, /LastModified as written and parsed on request through PagePiece::last_modified_parsed and /Private kept as the object written; it serves the catalog (document_piece_info_with, Document::piece_info, AsyncDocument::piece_info), a page (Document::page_piece_info, AsyncDocument::page_piece_info) and, applied to a form dictionary, a form XObject. pdfboss info lists the product names (tests piece_info.rs reads_each_products_data_dictionary_of_a_page_piece_dictionary and a_pages_piece_info_reads_the_same_way_and_a_missing_entry_is_empty, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_lists_page_piece_products). The writer emits no /PieceInfo, the clause making it optional. |
| 14.6.1 | General | implemented | 3 | 2 | MP, DP, BMC, BDC and EMC (Table 320) parse into distinct operators, and the text and render executors keep a nesting stack per content stream so EMC pops the matching open. |
| 14.6.2 | Property Lists | implemented | 2 | 3 | BDC and DP operands are accepted as an inline dictionary or as a name looked up in the resource chain's /Properties subdictionary, and the named form keeps the indirect-reference identity the optional-content code keys on. |
| 14.6.3 | Marked Content and Clipping | incomplete | 2 | 0 | Marked-content nesting is tracked on its own stack and EMC never touches the graphics state, so a clip set inside a sequence persists as the clause requires, but no test exercises a clipping path or q/Q pair that straddles a marked-content sequence. |
| 14.7.2 | Structure Hierarchy | incomplete | 2 | 4 | The catalog's /StructTreeRoot is loaded and elements are walked through /K (single or array) and /P up to the root, but /IDTree, /ParentTreeNextKey and the element entries /T, /ID and /ActualText are never read (/S and /RoleMap are, under 14.7.3, /A, /C, /R and /ClassMap under 14.7.5, /Alt under 14.9.3, /Lang under 14.9.2 and /E under 14.9.5), and pdfboss-write neither emits a structure tree nor carries a base document's tree through assembly. |
| 14.7.3 | Structure Types | implemented | 6 | 3 | crates/pdfboss-core/src/structure.rs reads each element's /S while placing a page's marked-content sequences (Walk::key_of) and the root's /RoleMap once per document (role_map_of, an indirect map resolved); StructureTree::mapped_type follows the map from a type name until a name it has no entry for, a chain in case a mapped name is itself mapped, a cycle leaving the name as written; StructureTree::place_with returns both names per sequence as Placement { rank, structure_type, mapped_type }, and ranks_with stays the rank-only view (tests structure.rs structure_types_are_read_and_mapped_through_the_role_map, an_element_without_a_type_places_with_none). Nothing yet classifies the mapped name as one of the standard types of 14.8.4. |
| 14.7.4 | Structure Content | implemented | 25 | 25 | Both kinds of content item are located: marked-content sequences through the parent tree's arrays (14.7.4.2, 14.7.4.4) and whole objects through the parent tree's element under their /StructParent and the object reference among the element's kids (14.7.4.3). crates/pdfboss-core/src/structure.rs ContentItem names the two kinds, StructureTree::content_items_with enumerates a page's sequences and annotations in one shared rank space (test the_page_enumeration_skips_null_entries_and_needs_a_parent_tree), place_items_with places any set of items, and Document::content_items, the async twin and Python Page.content_items expose them; the pdfboss info tagged line counts what the parent tree files for each page (content_item_counts_with) without reading the elements. Element dictionaries, paths and ancestries are cached on the tree across pages, and the parent tree's entries are read once at load; the depth-4 rows give the detail. |
| 14.7.4.2 | Marked-Content Sequences as Content Items | implemented | 3 | 4 | Bare integer MCIDs (matched against the element's /Pg) and marked-content reference dictionaries are matched, and MCIDs are collected from BDC in page and form content. A reference's own /Pg overrides the element's, as Table 324 says, and its /Stm is read: the named stream's /StructParents must be the key the sequence was filed under, so a form's sequence numbered like the page's no longer stands in for it (crates/pdfboss-core/src/structure.rs Walk::is_reference_for, test a_reference_naming_a_stream_matches_only_that_streams_sequence). /StmOwn only says which object owns the stream and is not needed to match. |
| 14.7.4.3 | PDF Objects as Content Items | implemented | 5 | 8 | An object reference dictionary (Table 325) is matched by its /Obj against the object's reference, direct kids first and then indirect ones, with its own /Pg overriding the element's and the element's used when it has none (crates/pdfboss-core/src/structure.rs Walk::object_key_of and Walk::object_index; tests an_annotation_takes_its_place_between_the_sequences, an_indirect_object_reference_is_read_after_the_direct_kids, the_object_reference_page_overrides_the_elements, a_link_element_orders_its_sequence_and_annotation_by_kid_index). The element itself comes from the parent tree under the object's /StructParent (14.7.4.4). StructureTree::content_items_with enumerates a page's annotations from /Annots; an XObject referenced this way is placed when handed to place_items_with (test a_form_xobject_is_placed_when_handed_in), since nothing enumerates the XObjects a page draws without running its content. A direct annotation dictionary in /Annots has no reference an OBJR could name and is skipped. |
| 14.7.4.4 | Finding Structure Elements from Content Items | implemented | 9 | 10 | The /ParentTree number tree (/Nums, /Kids, /Limits) is searched under the /StructParents key of the page or of a form XObject that declares its own, its array indexed by MCID (crates/pdfboss-core/src/structure.rs Walk::parent_array, test a_form_key_ranks_alongside_the_page), and under the singular /StructParent key of a whole object, whose entry is a reference to the element itself (Walk::parent_element; test an_annotation_the_tree_does_not_hold_is_no_content_item covers a key the tree lacks, an object without the key and an element without a reference to the object). /ParentTreeNextKey is a writer's concern and is not read. |
| 14.7.5 | Structure Attributes | implemented | 14 | 4 | crates/pdfboss-core/src/structure.rs Walk::attribute_objects reads every element's attribute objects while placing a page's marked-content sequences: the objects its /C classes name first, then its direct /A objects (Walk::direct_attributes: one dictionary or an array of dictionaries, indirect ones resolved), each as AttributeObject { element, owner (/O), revision, entries }; Placement::attributes lists them for the element and its ancestors, the root's child first, and Placement::attribute(element, owner, key) answers with the last object that has the key, so a direct object overrides a class's (test structure.rs placements_carry_the_elements_attribute_objects). What the standard owners' entries mean is the 14.8.5 rows' business. |
| 14.7.5.2 | Attribute Classes | implemented | 4 | 1 | StructureTree loads the root's /ClassMap once (class_map_of) and Walk::class_attributes looks up each name an element's /C gives, one name or an array, taking the class's attribute objects as if they were the element's own and putting them before the direct /A objects, so those override; a name the map lacks brings nothing (test placements_carry_the_elements_attribute_objects: /C [/Wide 4] with /A overriding the class's ColSpan). |
| 14.7.5.3 | Attribute Revision Numbers | implemented | 2 | 1 | An element's /R lands on StructureElement::revision (0 when absent); in an /A array an integer after an attribute object is that object's revision, in a /C array an integer after a class name is the revision of every object the class brought, both on AttributeObject::revision (Walk::direct_attributes, Walk::class_attributes; test placements_carry_the_elements_attribute_objects checks /R 1, a revision 2 in /A and a revision 4 in /C). No consumer compares revisions yet. |
| 14.7.5.4 | User Properties | incomplete | 1 | 0 | A /O /UserProperties attribute object is read like any other (structure.rs attribute_object) and reaches Placement::attributes with its owner, but its /P array of property dictionaries (/N, /V, /F, /H) is not interpreted. |
| 14.7.6 | Example of Logical Structure | out of scope | 0 | 0 | A worked example of a structure tree with no requirement of its own; the structure it illustrates is assessed under 14.7.2 to 14.7.5. |
| 14.8.2 | Tagged PDF and Page Content | incomplete | 30 | 30 | Structure-tree reading order is offered on every extraction entry point and ranks the page's marked-content sequences by the tree (14.8.2.3); artifacts are marked on their spans (14.8.2.2), reversed show strings put back in logical order (14.8.2.3.3) and ActualText substituted for the shown glyphs (14.9.4), and annotations take their place among the sequences through Page::content_items (14.8.2.3.2). The font characteristics come from the descriptor: style from its flags, weight, stem and the name, and the family, stretch and weight as written (14.8.2.4.3); the depth-3 and depth-4 rows give the detail. |
| 14.8.2.2 | Real Content and Artifacts | implemented | 6 | 2 | crates/pdfboss-text/src/extract.rs marks every span shown inside an /Artifact marked-content sequence: a bare BMC gives ArtifactKind::Unspecified, a BDC's property list (inline or named through /Properties) gives the /Type of Table 330 (Pagination, Layout, Page, Background) and the /Subtype (Header, Footer, Watermark), on TextSpan::artifact (Mark::artifact, marked_artifact; test lib.rs artifact_marks_reach_the_spans); the Python Span exposes artifact and artifact_subtype. crates/pdfboss-output/src/structure.rs lays pagination artifacts out apart from the real content and gives them the PageHeader or PageFooter role by their position against the page's text midline, so Markdown drops a producer-marked running head or folio on a single page while text keeps it (attach_pagination_artifacts; test lib.rs tagged_pagination_artifacts_are_page_headers_and_footers_on_one_page); the repetition heuristic still covers untagged files. /BBox and /Attached are not read, and layout, page and background artifacts are marked but not treated differently from real content. |
| 14.8.2.3 | Page Content Order | implemented | 9 | 10 | Under ReadingOrder::StructureTree each marked-content sequence is ranked by its depth-first position in the tree and untagged content keeps its place (crates/pdfboss-text/src/extract.rs structure_order), a /ReversedChars sequence's show strings are put back in logical order under every order (14.8.2.3.3), and the annotations the tree holds are ranked in the same order as the sequences by StructureTree::content_items_with (14.8.2.3.2), which is where an annotation's slot among the text is read from, the span list carrying no annotation text. |
| 14.8.2.3.2 | Sequencing of Annotations | implemented | 1 | 2 | An annotation's place in the page content order is read off the structure tree: crates/pdfboss-core/src/structure.rs StructureTree::content_items_with ranks the page's marked-content sequences and the annotations held through object references in one depth-first order, so an annotation whose element sits between two sequences' elements comes out between them and a Link, Form or Annot element's own sequence and annotation come out in kid order (tests an_annotation_takes_its_place_between_the_sequences, a_link_element_orders_its_sequence_and_annotation_by_kid_index; Python tests/test_content_items.py). Text extraction itself returns no annotation text, so the slot is read from the items rather than from the span list; docs/src/guide/text.md gives the recipe. |
| 14.8.2.3.3 | Reverse-Order Show Strings | implemented | 4 | 3 | crates/pdfboss-text/src/extract.rs marks a /ReversedChars sequence (BMC or BDC, Mark::reversed) and puts every show string inside it back in logical order as it is emitted (reversed_chars): the characters reversed, a space at the beginning or end of the string kept in place, and TextSpan::glyph_x reversed with the characters; a sequence's /ActualText still wins as replacement text (test crates/pdfboss-text/src/lib.rs reversed_chars_sequences_put_their_strings_back_in_order). The span's geometry stays the visual one, and the span is marked TextSpan::logical so crates/pdfboss-output/src/bidi.rs, which puts right-to-left lines in reading order, does not reverse it a second time (test crates/pdfboss-output/src/lib.rs reversed_chars_and_actual_text_are_not_reversed_twice). |
| 14.8.2.4 | Extraction of Character Properties | implemented | 8 | 12 | Unicode values come from ToUnicode, the encoding's glyph names, the CID collection's CMap and a marked-content sequence's ActualText (14.8.2.4.2), font style from the FontDescriptor and the base font name, and the family, stretch and weight from the descriptor as written (14.8.2.4.3); the depth-4 rows give the detail. |
| 14.8.2.4.2 | Unicode Mapping in Tagged PDF | implemented | 3 | 4 | The clause's methods in its order: crates/pdfboss-text/src/font.rs decode_into maps a code through the font's /ToUnicode first (load_to_unicode, an empty CMap treated as absent), a simple font then through its encoding's glyph names (the Adobe Glyph List with uniXXXX and uXXXX forms, underscore ligatures and period variants; crates/pdfboss-encoding), a composite font through its /CIDSystemInfo ordering's Uni*-UTF16-H CMap inverted (cid_to_unicode), the same relation as the Registry-Ordering-UCS2 CMaps the clause names; and crates/pdfboss-text/src/extract.rs emit puts a marked-content sequence's /ActualText in place of everything shown inside it (14.9.4), so the property overrides the mapping as the clause requires (tests font.rs tounicode_beats_encoding, differences_agl_ligatures_and_variants_decode, type0_predefined_rksj_maps_codes_to_cids, crates/pdfboss-text/src/lib.rs actual_text_replaces_the_shown_glyphs). A code none of the methods maps comes out as U+FFFD, the reader's choice the clause leaves open; gXX and cidXX style glyph names are not resolved (9.10.2). |
| 14.8.2.4.3 | Font Characteristics | implemented | 3 | 6 | crates/pdfboss-text/src/font.rs Font::style reads the descriptor's /FontFamily (a byte string, decoded as a text string), /FontStretch (the nine names of Table 122, FontStretch::from_name) and /FontWeight as written onto TextSpan::font_family, font_stretch and font_weight, next to the bold, italic, serif and fixed-pitch readings of /Flags, /FontWeight, /StemV, /ItalicAngle and the /BaseFont name (tests crates/pdfboss-text/src/lib.rs descriptor_family_stretch_and_weight_reach_the_span, descriptor_without_font_characteristics_gives_none, font_stretch_names_round_trip_in_table_order; Python tests/test_spans.py test_style_attributes). A name outside the table gives no stretch. |
| 14.8.2.5 | Identifying Word Breaks | implemented | 3 | 4 | Words are found the way the clause has it, in the Unicode character stream: a space glyph decodes into the text wherever it is shown, the split into show strings carrying no meaning (crates/pdfboss-text/src/extract.rs show; test lib.rs a_space_glyph_before_a_word_gap_is_one_space), and a sequence's /ActualText replaces the shown glyphs with its spaces included (emit, 14.9.4; test lib.rs actual_text_spaces_are_word_breaks). For files whose producer left the spaces out, which the clause forbids in Tagged PDF but untagged files do freely, a horizontal gap between spans becomes a space (crates/pdfboss-output/src/structure.rs push_span; test structure.rs shrunk_justified_word_gaps_still_become_spaces), the positional guess NOTE 2 says a reader need not make. Splitting the text into words is left to the consumer, as NOTE 4 allows; no UAX #29 boundary analysis is done. |
| 14.8.3 | Basic Layout Model | incomplete | 2 | 1 | Under ReadingOrder::StructureTree the page is laid out on the clause's model: crates/pdfboss-core/src/structure.rs StandardKind sorts the standard types into grouping, block-level, inline-level and illustration elements (14.8.4), crates/pdfboss-output/src/structure.rs run_of gives every span the outermost block-level element above it, and push_tagged_blocks stacks those elements as blocks in the tree's order, each block's text laid out in lines by position with its inline-level elements flowing inside (Span, Code as inline code under 14.8.4.4); a list's items and a table's rows and cells nest inside their block (tests crates/pdfboss-output/src/lib.rs tagged_block_elements_decide_the_markdown_under_structure_tree_order). The Placement and WritingMode attributes that re-class an element or turn the progression directions (14.8.5.4) are not read, so the classification comes from the standard type alone and every page is laid out left to right, top to bottom; a block-level element inside a table cell or a list body, a nested list apart, is laid out within that cell's line or item's lines rather than as a block of its own; content the tree does not reach goes through the layout heuristics. |
| 14.8.4 | Standard Structure Types | implemented | 19 | 14 | crates/pdfboss-core/src/structure.rs StandardType names the 49 standard types with the standard's own spelling, StandardType::from_name recognizes a role-mapped name (case-sensitive, a document's own types staying None), StandardType::kind sorts each into the clause that defines it (StandardKind::Grouping, BlockLevel, InlineLevel, Illustration), and Placement::standard_type and Placement::path carry the result, with the standard-typed ancestry, for every placed marked-content sequence (tests structure.rs standard_types_are_recognized_by_name_and_grouped_by_clause, placements_carry_the_standard_typed_ancestry). What each set means for extraction is the depth-4 rows' business: the block-level elements are the blocks of structure-tree order (14.8.4.3), the grouping elements quote, caption and nest them (14.8.4.2), Code is the inline-level element with an effect of its own (14.8.4.4) and the illustration elements are laid out apart from the text around them (14.8.4.5). |
| 14.8.4.2 | Grouping Elements | implemented | 4 | 3 | Under ReadingOrder::StructureTree the paragraphs inside a BlockQuote take Role::Quote (crates/pdfboss-output/src/structure.rs in_block_quote) and the Markdown adapter renders them as a block quote, every line opened with > (markdown.rs quote); a Caption or TOCI holding text with no block-level element inside is a block of its own (block_element), so a table of contents comes out one entry per block and a figure's caption never merges with its neighbour; Sect, Part and Art nesting sets the level of an H heading (14.8.4.3); Document, Div, TOC, Index, NonStruct and Private hold blocks rather than being one and leave their content as shown, Private included since its text is on the page (tests crates/pdfboss-output/src/lib.rs block_quote_paragraphs_render_as_markdown_quotes, toc_entries_are_their_own_blocks). Headings and lists inside a BlockQuote are not quoted. |
| 14.8.4.3 | Block-Level Structure Elements | implemented | 4 | 7 | Under ReadingOrder::StructureTree every span the tree reaches carries TextSpan::structure (crates/pdfboss-text/src/lib.rs Structure: the holding element's standard type and the standard-typed ancestry from Placement::path, read by crates/pdfboss-core/src/structure.rs Walk::ancestry) and crates/pdfboss-output/src/structure.rs push_tagged_blocks builds the page's blocks from it: consecutive spans of one outermost block-level element are one block, H1 to H6 a heading at that level, H a heading as deep as its Part, Art and Sect ancestors nest, L a list with an item per LI and the Lbl text as the marker (digits closed by . or ) are the number, anything else a bullet; the label stays in the item's first line and the Markdown adapter strips it by length; an item's label is its own Lbl, not a nested list's; an item without a Lbl reads its marker off its first line as an untagged page does, and a first line that is nothing but the marker stays in the layout for plain text while the Markdown adapter opens the item on the line after it), Table a table with a row per TR (THead, TBody, TFoot transparent) and a cell per TH or TD (a cell's lines joined with a space), every other block-level element a paragraph, so two P elements a line apart stay two paragraphs; spans with no block-level element go through the layout heuristics, and ruled grids are not consulted on a tagged segment (tests crates/pdfboss-output/src/lib.rs tagged_block_elements_decide_the_markdown_under_structure_tree_order, a_tagged_list_without_labels_reads_its_markers_off_the_lines, a_nested_list_keeps_its_labels_out_of_the_outer_marker, the_same_page_in_content_order_reads_by_the_layout_heuristics; crates/pdfboss-text/src/lib.rs structure_tree_order_attaches_each_span_s_standard_typed_ancestry). ListNumbering is read under 14.8.5.5 and ColSpan and RowSpan under 14.8.5.7; a nested list's content stays inside the item holding it; a label that does not open its item's first line stays in the text as a bullet item; content order, the default, still infers headings, lists and tables from layout. |
| 14.8.4.4 | Inline-Level Structure Elements | incomplete | 4 | 2 | Under ReadingOrder::StructureTree text shown inside a Code element becomes its own inline run (crates/pdfboss-output/src/structure.rs in_code, Inline::code) and the Markdown adapter sets it as a CommonMark code span, backticks one longer than any run inside and no emphasis (markdown.rs push_code; test crates/pdfboss-output/src/lib.rs code_elements_render_as_inline_code); Span, Quote, Reference, BibEntry and Annot flow into their block as shown, which is all text and Markdown have for them. Not acted on: Link keeps its text without a target (object references are unread, 14.7.4.3), Note stays in place with no footnote syntax, and Ruby and Warichu keep every part (RB, RT, RP, WT, WP) in reading order. |
| 14.8.4.5 | Illustration Elements | incomplete | 2 | 1 | Under ReadingOrder::StructureTree the text inside a Figure, Formula or Form with no block-level element inside forms an illustration run (crates/pdfboss-output/src/structure.rs Run::Illustration, run_of, push_tagged_blocks): the layout heuristics lay it out on its own, adjacent illustrations together so a table a producer drew as a row of figures is still read as a table, and none of it joins the untagged text or the tagged blocks around it; a Figure's /Alt reaches its spans (14.9.3; test crates/pdfboss-output/src/lib.rs illustration_elements_are_their_own_blocks). A figure that shows no text has no span and so no block, and no adapter renders a description in its place; image extraction works from XObjects, not from the tree. |
| 14.8.5 | Standard Structure Attributes | incomplete | 10 | 6 | Attribute objects reach Placement::attributes and TextSpan::structure with their owner and entries (14.7.5) and the standard owners are told apart (14.8.5.2); of the standard attributes themselves the Table owner's ColSpan and RowSpan (14.8.5.7) and the List owner's ListNumbering (14.8.5.5, inherited down the tree as 14.8.5.3 says) are interpreted, the layout and PrintField attributes are not (14.8.5.4, 14.8.5.6). |
| 14.8.5.2 | Standard Attribute Owners | implemented | 3 | 1 | crates/pdfboss-core/src/structure.rs StandardOwner names the eleven owners of Table 331 (Layout, List, PrintField, Table and the XML-1.00, HTML-3.2, HTML-4.01, OEB-1.0, RTF-1.05, CSS-1.00 and CSS-2.00 formats) with the standard's spelling; StandardOwner::from_name recognizes an /O name case-sensitively, a producer's own owner and UserProperties (14.7.5.4) staying None, and AttributeObject::standard_owner applies it, which is how the tagged table code asks for the Table owner's spans (test structure.rs standard_attribute_owners_are_recognized_by_name). |
| 14.8.5.3 | Attribute Values and Inheritance | incomplete | 2 | 1 | The clause's priority list is followed for the attributes that are read: crates/pdfboss-core/src/structure.rs Placement::attribute resolves a key for one element with a direct /A object beating the classes its /C entry names and a later class an earlier one (steps b and c; 14.7.5.2), and crates/pdfboss-output/src/structure.rs list_numbering resolves ListNumbering, the one inheritable attribute read (Table 347), as step d says: the L element's own value, else the nearest ancestor's, an explicit None stopping the inheritance (test crates/pdfboss-output/src/lib.rs list_numbering_is_inherited_from_an_ancestor_unless_the_list_says_none); ColSpan and RowSpan are not inheritable and take the default 1 of step e (14.8.5.7). The export-format owners of step a are told apart (14.8.5.2) but never consulted, text and Markdown being none of their formats, and an ancestor without a standard type is skipped on the way up. |
| 14.8.5.4 | Layout Attributes | not implemented | 0 | 0 | Placement, WritingMode, BBox, spacing, color and column attributes are never read. |
| 14.8.5.5 | List Attribute | implemented | 3 | 3 | crates/pdfboss-output/src/structure.rs list_numbering reads a tagged list's ListNumbering from the L element's attribute objects of owner List (the last object with the key winning). When it names a numbering system (Decimal, UpperRoman, LowerRoman, UpperAlpha, LowerAlpha) push_tagged_list numbers the items: a label is read in that system (numbered_label: digits; a letter of the system's case, repeated past Z; Roman numerals of the system's case; closed by . or ) or by nothing), an unlabelled item takes the number after the previous item's, and a label outside the system (a lettered sub-item inside a Decimal list, as the 049 corpus tags them) keeps the marker its text gives (numbered_marker; tests crates/pdfboss-output/src/lib.rs list_numbering_numbers_the_items_of_a_numbered_list, structure.rs labels_are_read_in_the_list_s_numbering_system). None, Disc, Circle, Square and a missing attribute leave the markers to the labels and lines, which already read as bullets; Markdown writes every ordered item with a decimal number, and the Lang-dependent alphabet of the alphabetic systems is not consulted. |
| 14.8.5.6 | PrintField Attributes | not implemented | 0 | 0 | Role, checked and Desc attributes of form-field elements are never read. |
| 14.8.5.7 | Table Attributes | incomplete | 1 | 1 | crates/pdfboss-output/src/structure.rs table_span reads a tagged cell's ColSpan and RowSpan from the TH or TD element's attribute objects of owner Table (TextSpan::structure carries them, the last object with the key winning, values clamped to 1..255) onto Cell::colspan and Cell::rowspan, and the Markdown adapter renders a table with either span as HTML with colspan and rowspan attributes (test crates/pdfboss-output/src/lib.rs tagged_cells_carry_their_column_and_row_spans). Headers, Scope and Summary are not read, and an untagged table's structure is still inferred from column gaps and rulings. |
| 14.9.2 | Natural Language Specification | implemented | 12 | 6 | /Lang is read at all three levels: crates/pdfboss-core/src/language.rs language_with reads the catalog's as the document language (Document::language, AsyncDocument::language, Python Document.language and AsyncDocument.language), crates/pdfboss-core/src/structure.rs Walk::ancestry reads each structure element's and Placement::lang carries the nearest, and crates/pdfboss-text/src/extract.rs reads a BDC's property-list /Lang (inline or named) onto every span shown inside as TextSpan::lang, a span with none taking the nearest element's under ReadingOrder::StructureTree (Python Span.lang); every value is a text string decoded, the tag itself kept as written (tests language.rs the_catalog_lang_is_the_document_language, structure.rs placements_carry_the_nearest_language, crates/pdfboss-text/src/lib.rs natural_languages_reach_the_spans, crates/pdfboss-aio/tests/parity.rs). No output adapter uses the language yet. |
| 14.9.2.2 | Language Identifiers | implemented | 3 | 1 | crates/pdfboss-core/src/language.rs LanguageTag::parse reads a /Lang value in the RFC 3066 form the clause prescribes: a primary subtag (an ISO 639 code, or i for a registered and x for a private-use tag) and hyphen-separated subtags of one to eight ASCII letters (digits allowed after the first), the second subtag a country code when it has two letters; case carries no meaning, so the language comes out lower-case and the country upper-case, the remaining subtags as written, and an empty or overlong subtag, a character outside ASCII or a separator other than the hyphen makes the tag malformed (None). Document::language_tag applies it to the catalog's /Lang (test language.rs language_tags_split_into_language_and_country). The codes themselves are not checked against the ISO 639 and ISO 3166 lists. |
| 14.9.2.3 | Language Specification Hierarchy | incomplete | 1 | 2 | The two inner levels chain per span: a marked-content sequence's /Lang wins over the enclosing structure elements', the nearest of which wins over its ancestors' (TextSpan::lang, Placement::lang, test natural_languages_reach_the_spans); the outermost level, the catalog's /Lang, is Document::language and is not folded into a span whose sequence and elements declare none, so a caller takes it as the default itself. |
| 14.9.2.4 | Multi-language Text Arrays | not implemented | 0 | 0 | Multi-language text arrays are not parsed. |
| 14.9.3 | Alternate Descriptions | implemented | 4 | 3 | crates/pdfboss-text/src/extract.rs reads /Alt from a BDC's property list (inline or named through /Properties, decoded as a text string like /ActualText, Executor::marked_text_string) onto every span shown inside the sequence as TextSpan::alt, the shown text untouched, and under ReadingOrder::StructureTree a span with no description of its own takes the nearest structure element's /Alt, which crates/pdfboss-core/src/structure.rs Walk::ancestry reads on the way to the root and Placement::alt carries; the Python Span exposes alt (tests structure.rs placements_carry_the_nearest_alternate_description, crates/pdfboss-text/src/lib.rs alternate_descriptions_from_property_lists_reach_the_spans, alternate_descriptions_from_structure_elements_reach_the_spans). No output adapter renders a description yet, and a Figure that shows no text has no span to carry its /Alt. |
| 14.9.4 | Replacement Text | implemented | 5 | 5 | crates/pdfboss-text/src/extract.rs reads /ActualText from a BDC's property list (inline or named through /Properties, any tag, decoded as a text string) and treats it as a character substitution for the sequence: the first string shown inside becomes one span carrying the replacement, every later string only widens that span's extent, and an empty replacement leaves the sequence without text (Mark::actual, ActualText, emit, marked_actual_text; tests lib.rs actual_text_replaces_the_shown_glyphs with the clause's Drucker example, actual_text_covers_the_whole_sequence_and_may_be_empty). /ActualText on a structure element dictionary is not read; the language escape sequences of 7.9.2.2 are not interpreted. |
| 14.9.5 | Expansion of Abbreviations and Acronyms | implemented | 1 | 3 | crates/pdfboss-text/src/extract.rs reads /E from a BDC's property list (inline or named through /Properties, decoded as a text string like /Alt, Executor::marked_text_string) onto every span shown inside the sequence as TextSpan::expansion, the shown abbreviation untouched, and under ReadingOrder::StructureTree a span with no expansion of its own takes the nearest structure element's /E, which crates/pdfboss-core/src/structure.rs Walk::ancestry reads on the way to the root and Placement::expansion carries; the Python Span exposes expansion (tests structure.rs placements_carry_the_nearest_expansion, crates/pdfboss-text/src/lib.rs expansions_from_property_lists_reach_the_spans, expansions_from_structure_elements_reach_the_spans). No output adapter renders an expansion. |
| 14.10.2 | Web Capture Information Dictionary | out of scope | 0 | 0 | pdfboss reads, renders and writes PDFs and is not a web-capture tool, so /SpiderInfo is ignored. |
| 14.10.3 | Content Database | out of scope | 0 | 0 | Web capture is outside pdfboss's scope; the /IDS and /URLS name trees are ignored. |
| 14.10.4 | Content Sets | out of scope | 0 | 0 | Web capture is outside pdfboss's scope; /SPS and /SIS content sets are ignored. |
| 14.10.5 | Source Information | out of scope | 0 | 0 | Web capture is outside pdfboss's scope; source information dictionaries are ignored. |
| 14.10.6 | Object Attributes Related to Web Capture | out of scope | 0 | 0 | Web capture is outside pdfboss's scope; the /ID entry on page and XObject dictionaries is ignored (the trailer /ID is a different key and is handled under 14.4). |
| 14.11.2 | Page boxes | implemented | 6 | 5 | MediaBox, CropBox, BleedBox, TrimBox and ArtBox are read with the clause's defaults (crop falls back to media, the other three to crop), clipped to the media box and treated as non-inheritable, in the sync, async and Python APIs. |
| 14.11.2.2 | Display of page boxes | incomplete | 1 | 0 | Rendering maps the CropBox (turned by /Rotate) onto the output image as the clause's default visible area, but /BoxColorInfo is never read, no option renders the media, bleed, trim or art box instead, and no test renders a page whose CropBox is narrower than its MediaBox. |
| 14.11.3 | Printer’s Marks | not implemented | 0 | 0 | Nothing recognizes the PrinterMark annotation subtype or its /MN entry; such an annotation paints only incidentally through the generic appearance-stream path at crates/pdfboss-render/src/executor.rs, which skips only Popup, Hidden and NoView annotations. |
| 14.11.4 | Separation Dictionaries | implemented | 7 | 4 | crates/pdfboss-core/src/separation_info.rs separation_info_with reads a page's /SeparationInfo dictionary (Table 364) into SeparationInfo: the /Pages references of the separation set, the /DeviceColorant as a name or text string and the /ColorSpace array as written; a dictionary naming no colorant reads as absent. Document::separation_info and AsyncDocument::separation_info expose it and pdfboss info counts the pre-separated pages with their colorants (tests separation_info.rs reads_the_separation_dictionary and missing_or_malformed_separation_info_reads_as_absent, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_counts_separations). Nothing recombines separations into a composite page, and pdfboss-write emits no separation dictionary. |
| 14.11.5 | Output Intents | incomplete | 8 | 4 | crates/pdfboss-core/src/output_intent.rs output_intents_with reads the catalog's /OutputIntents array into OutputIntent records with the Table 365 entries (S, OutputCondition, OutputConditionIdentifier, RegistryName, Info and the DestOutputProfile reference), skipping entries without the required /S; Document::output_intents and AsyncDocument::output_intents expose them and pdfboss info prints one line per intent (tests output_intent.rs reads_every_entry_of_the_output_intent_dictionaries, crates/pdfboss-aio/tests/parity.rs, crates/pdfboss-cli/src/main.rs info_text_lists_output_intents). Rendering never applies an intent's destination profile even though pdfboss-icc could; colours are converted from their source spaces only. |
| 14.11.6 | Trapping Support | out of scope | 0 | 0 | Trapping is a prepress step pdfboss does not perform; /TrapNet annotations get no special reading or painting (an appearance stream would paint like any other annotation). |
| 14.11.7 | Open Prepress Interface (OPI) | out of scope | 0 | 0 | OPI proxy substitution is a prepress workflow pdfboss does not take part in; /OPI on image and form XObjects is ignored and the XObject itself is used. |
Annexes
| Clause | Feature | Status | Code citations | Test citations | Note |
|---|---|---|---|---|---|
| A.2 | PDF Content Stream Operators | implemented | 2 | 19 | Every operator in Table A.1 parses into an Op (BI, ID and EI become one InlineImage op; f and F share Op::Fill) and none is unparsed; the rasterizer treats ri and i as an explicit no-op (executor.rs) and lets MP, DP, BX, EX, ET and d0 fall to the empty arm at executor.rs, while d1 is only checked for presence at executor.rs to choose the uncolored Type3 glyph path. |
| B.2 | Arithmetic Operators | implemented | 2 | 4 | All 21 arithmetic operators (abs add atan ceiling cos cvi cvr div exp floor idiv ln log mod mul neg round sin sqrt sub truncate) are recognized at shading.rs and evaluated by run_calculator; the tests feed add, mul, exp, log, sqrt, div, ln, atan, idiv, mod, round, truncate, floor, ceiling and cvi, leaving abs, neg, sub, sin, cos and cvr without a direct test. |
| B.3 | Relational, Boolean and Bitwise Operators | implemented | 2 | 3 | and bitshift eq false ge gt le lt ne not or true xor are all recognized; and, or, xor and not split on boolean versus integer operands, bitshift shifts in both directions, and eq/ne handle mixed-type operands. |
| B.4 | Conditional Operators | implemented | 1 | 1 | if and ifelse compile procedure blocks into forward jumps, nested conditionals branch correctly, and a block not followed by if or ifelse is a load error (shading.rs). |
| B.5 | Stack Operators | implemented | 2 | 2 | copy dup exch index pop roll are recognized, roll rotates in both directions, and the operand stack is capped at 100 entries (CALC_STACK, shading.rs) as §7.10.5.1 requires, with a 10,000-step runaway limit. |
| C.2 | Architectural Limits | implemented | 2 | 8 | The parser meets or exceeds every Table C.1 minimum (integers are i64 and reals f64 with integer overflow degrading to a real, no cap on string, name, array or dictionary size, CID range 0 to 0xFFFF) and sets its own caps above the spec's minima: object nesting 128 (spec 28), q/Q depth 64 (spec 28), indirect-reference chain 32 (MAX_RESOLVE_DEPTH), page-tree depth 256, form nesting 16; it never rejects a file for exceeding Table C.1. |
| C.3 | Memory Limits | implemented | 2 | 2 | Memory is bounded per stream rather than per document: each decoded stream is capped at 256 MiB (MAX_DECODED_LEN) so a decompression bomb is an error, CCITT checks its packed size against that cap and JBIG2 budgets its regions up front, and the aio tail scan stops at 64 KiB; there is no document-wide object-count or total-memory cap. |
| D.2 | Latin Character Set and Encodings | incomplete | 9 | 11 | The StandardEncoding, MacRomanEncoding and WinAnsiEncoding columns exist as code-to-Unicode tables (glyph-name tables only for Standard and WinAnsi), the PDFDocEncoding column decodes text strings (crates/pdfboss-core/src/object.rs pdf_doc_char) and pdfboss-write encodes text as WinAnsi; missing are a MacRoman glyph-name table, the WinAnsi footnote mapping unused codes above 0x27 to bullet (0x81, 0x8D, 0x8F, 0x90 and 0x9D return None), and /StandardEncoding named explicitly is only honored because it falls through the default arm at font.rs. |
| D.3 | PDFDocEncoding Character Set | implemented | 2 | 5 | The full PDFDocEncoding table decodes text strings without a BOM (crates/pdfboss-core/src/object.rs pdf_doc_char: accents at 0x18-0x1F, punctuation and ligatures at 0x80-0x9E, Euro at 0xA0, undefined codes to U+FFFD); the writer emits text strings as ASCII or UTF-16BE, which the clause allows, so no encoder is needed. |
| D.4 | Expert Set and MacExpertEncoding | not implemented | 0 | 0 | No MacExpertEncoding table exists anywhere; a font naming /MacExpertEncoding falls through the default arm at crates/pdfboss-text/src/font.rs and is decoded as StandardEncoding. |
| D.5 | Symbol Set and Encoding | implemented | 7 | 2 | crates/pdfboss-encoding/src/symbol.rs SYMBOL holds the 189 code-to-glyph-name pairs of the annex (no apple at 0o360), written by a script from the standard's text and cross-checked against unicode.org's Adobe Symbol mapping; lib.rs symbol_glyph_name and symbol expose them, the character coming through the Adobe Glyph List, so Omega, Delta and mu read as the ohm, increment and micro signs as pdf.js reads them (test lib.rs symbol_encoding_follows_annex_d5). Text extraction takes the table as the base encoding of a font whose /BaseFont family is Symbol when the file names no encoding and embeds no Type 1 program, /Differences on top (crates/pdfboss-text/src/font.rs standard_symbol_encoding, test symbol_and_zapf_dingbats_decode_through_their_built_in_encodings); the writer encodes Symbol text through the same table (crates/pdfboss-write/src/font.rs Standard14::decoder, test symbol_faces_encode_through_their_built_in_encodings). Rendering still leaves non-embedded Symbol unpainted, there being no license-clean substitute face (crates/pdfboss-render/src/substitute.rs), and no metrics are bundled for it (9.6.2.2). |
| D.6 | ZapfDingbats Set and Encoding | implemented | 6 | 2 | crates/pdfboss-encoding/src/symbol.rs ZAPF_DINGBATS holds the 188 codes of the annex (space and 187 dingbats; the annex leaves 0o200 to 0o215 out) with their glyph names and, since the Adobe Glyph List does not carry the a1 to a191 names, the Unicode characters of unicode.org's Adobe ZapfDingbats mapping; lib.rs zapf_dingbats_glyph_name and zapf_dingbats expose them (test lib.rs zapf_dingbats_encoding_follows_annex_d6). Text extraction and the writer use the table the same way as Symbol's (crates/pdfboss-text/src/font.rs standard_symbol_encoding, crates/pdfboss-write/src/font.rs Standard14::decoder). Rendering still leaves non-embedded ZapfDingbats unpainted and no metrics are bundled for it (9.6.2.2). |
| E.2 | Name Registry | out of scope | 0 | 0 | pdfboss neither registers names nor checks second-class or third-class prefixes; unknown dictionary keys pass through as plain names, and registration is a publishing process rather than a parser feature. |
| F.2 | Background and Assumptions | out of scope | 0 | 0 | Design background for linearization with no requirement of its own; the file structure it motivates is assessed under F.3 and F.4. |
| F.3 | Linearized PDF Document Structure | incomplete | 10 | 6 | crates/pdfboss-core/src/linearization.rs linearization_dictionary reads the linearization parameter dictionary of Table F.1 from the first object within the first 1024 bytes (Linearized, L, the H offset and length pairs, O, E, N, T, and P with its default of 0); Document::linearization and AsyncDocument::linearization return it as written, the async document from the header window it fetches at open, and is_linearized applies the /L rule of Table F.1: a length that no longer matches the file, as after an appended update, makes the file ordinary PDF (tests linearization.rs a_document_is_linearized_only_while_the_declared_length_matches, crates/pdfboss-aio/tests/parity.rs linearization_dictionaries_agree). The CLI's info prints the result (crates/pdfboss-cli/src/main.rs info_text). Nothing acts on the dictionary: pdfboss-aio still locates objects by scanning the tail for startxref and following the xref chain (crates/pdfboss-aio/src/document.rs find_tail) instead of reading the first-page cross-reference table and first-page section it points to, and pdfboss-write emits no /Linearized dictionary or first-page section (crates/pdfboss-write/src/writer.rs writes a plain header). |
| F.4 | Hint Tables | not implemented | 0 | 0 | No hint stream is parsed or produced; the /H offsets and the page offset and shared object hint tables are never read. |
| I | PDF Versions and Compatibility | incomplete | 2 | 5 | The %PDF-M.m header is parsed within the first 1 KiB with one to three digit components, a missing or malformed header silently defaults to 1.4, any parsed version including future ones is accepted without a warning, unknown operators and keys are skipped as Annex I asks, and pdfboss-write emits a configurable header; missing is the catalog /Version override, which no crate reads. |