API — Pdf::from_html_css (#248)
let font = std::fs::read("DejaVuSans.ttf")?;
let mut pdf = Pdf::from_html_css(
"<h1>Hello</h1><p>World</p>",
"h1 { color: blue; font-size: 24pt }",
font,
)?;
pdf.save("out.pdf")?;The whole feature: pass HTML + CSS + font bytes, get a paginated PDF
back. Pure Rust, MIT/Apache only (no MPL transitive deps),
extract_text round-trips byte-equal so produced PDFs participate in
the existing test infrastructure.
End-to-end test suite at tests/test_html_to_pdf_e2e.rs covers
simple paragraph, multi-paragraph, nested HTML, CSS-styled text, and
Unicode (Latin + Latin-Extended + Cyrillic + symbols) round-trips.
Phase FONT — embedded TTF/OTF subsystem
- Subsetter wrapper around the
subsettercrate (Typst's,
MIT/Apache):crate::fonts::subset_font_bytes(bytes, used_glyphs)
produces a subset face, andEmbeddedFonttracks used glyph IDs
via theFontSubsettertype. The writer path currently embeds the
full font face inFontFile2(full-face embedding + Identity-H is
valid PDF 1.7 and round-trips correctly); switching to the
subsetter's output requires remapping glyph IDs in the already-
emitted content streams, which lands as a later follow-up. The
standalone API + glyph tracking still ship so callers that use the
subsetter directly (e.g. CLI tools shelling out tosubset_font_bytes)
get the size benefit today. - Type 0 / CIDFontType2 / Identity-H / ToUnicode emission wired
intoPdfWritersoadd_embedded_text(text, x, y, "EFn", size)
produces a font dict graph that PDF readers handle correctly.
Round-trip viaextract_textreturns the input string for Latin,
Cyrillic, Greek, Hebrew, Arabic. - System font discovery via
fontdb(RazrFalcon, MIT). New
system-fontsfeature gates discovery + shaping; default-on for
language bindings, off for WASM and the bare Rust crate. - Text shaping via
rustybuzz(HarfBuzz port, MIT). Returns
positioned glyph runs withclusterinfo so the inline formatter
can map glyphs back to source bytes.
Phase CSS — hand-rolled engine
10 modules, ~6,500 LoC, no MPL anywhere:
- Tokenizer (CSS Syntax L3) with full token coverage including
CDO/CDC, hex+named entities resolution in url(), source locations. - Parser producing
Stylesheet { rules: Vec<Rule> }with
forgiving recovery per spec. - Selectors L3 + L4 subset:
:is/:where/:not/:has,
structural pseudo-classes, attribute matchers withi/sflags,
specificity computation packed into a sortable u32. - Matcher with
Elementtrait so the engine isn't tied to one
DOM implementation. - Cascade with origin/specificity/source-order sorting,
inheritance from parent for the spec's inherited-property list,
inline-style merge, custom-property storage. calc()/min()/max()/clamp()evaluator with mixed-
unit math against aCalcContext.var()substitution with DFS cycle detection.- Typed property values for colour (~150 named, hex, rgb/rgba/
hsl), length (every CSS Values L4 unit), display, font-size/
weight/style/family, margin/padding shorthand expansion, line-
height, etc. - At-rules:
@media printalways-true +(min/max-width)
predicates,@pagewith:first/:left/:right/:blank
selectors and margin boxes,@font-facedescriptor extraction,
@importURL forwarding,@supportsagainst our supported set. - Counters (
counter/counters/counter-reset/-increment/
-setwith Roman/Greek/alpha numbering) and pseudo-element
content evaluation.
Phase HTML
- HTML5 tokenizer with attribute parsing (quoted/unquoted/bare),
void-element implicit self-closing,<style>/<script>raw-text
contexts, named + numeric entity decoding, comments, DOCTYPE. - Flat arena DOM implementing the CSS-4
Elementtrait so the
cascade matches against real document nodes. Implicit close
handling for the common<p>and<li>cases. - Stylesheet extraction:
<style>blocks,<link rel="stylesheet">(URL forwarded;mediaattribute preserved),
per-element inlinestyle="...". - Resource extraction:
<img>with srcset DPR selection,
<picture>/<source>first-match,<a href>(internal anchor
detection).
Phase LAYOUT
- Box tree from DOM × ComputedStyles with display-split
(outer/inner), anonymous-block insertion per CSS 2.1 §9.2.1.1,
display: none/contentshandling, UA default display table for
common HTML elements. - Taffy integration for block / flex / grid layout (Dioxus, MIT,
default-features-off + only the features we need). - Inline formatting with greedy line breaker via UAX #14
(unicode-linebreak),text-align/white-spacemodes, hard
breaks, atomic inline boxes. - Float scaffolding with line-shortening helpers.
- Margin collapsing per CSS 2.1 §8.3.1.
- Multi-column distribution (
column-count/column-width/
column-gapwith greedy line distribution). - Tables with auto + fixed column-width algorithms, row-group
classification (header/body/footer for paginator repetition).
Phase PAGINATE
- Slices a positioned box tree across pages at
floor(box.y / content_height)boundaries. - Multi-page boxes emit one PaginatedBox per page with the visible
y-slice; preserves source IDs so PAINT can look up styles. - A4 portrait (96dpi) and Letter (8.5×11) page presets.
Phase PAINT
- Walks each PageFragment and emits text + borders into the existing
PdfWriter/PageBuilder. - HTML→PDF Y-flip applied once at emission time so all internal
coordinates stay top-down.
Corner-case fixes and follow-ups
After the initial cut of the HTML+CSS pipeline, corner-case
validation surfaced a set of regressions and missing features. All
of the below also ship in v0.3.37:
- Tokenizer char-boundary safety. The CSS tokenizer's
ignore_caselookahead indexed raw byte offsets on multi-byte
characters, panicking on any CSS source that put non-ASCII inside a
keyword-adjacent position. Fixed. - Block sizing for inline-text flow. Block boxes with only-inline
children were given zero intrinsic height, so paint-time
y-coordinates collapsed; multi-paragraph documents dropped every
paragraph but the first, and long single paragraphs retained only
~20 % of their words.run_layoutnow reserves intrinsic height
from the body font size and the inline run count. - Arabic / RTL shaping. Paint now routes RTL paragraphs through
the rustybuzz shaper (featuresystem-fonts) so contextual forms,
ligatures, and visual reordering all work. - Multi-font cascade. New
Pdf::from_html_css_with_fonts(html, css, Vec<(family, bytes)>).
CSSfont-familyon any element resolves against the registered
families (case-insensitive, with/without quotes); unknown families
fall back to the first registered font. Walks up the box tree so
inline children inherit their ancestor's family. - Page breaks.
page-break-before: alwaysand
page-break-after: alwaysnow open a fresh page, both via CSS
rules and via inlinestyle="...". Multiple breaks accumulate. ::before/::aftergenerated content. New
cascade::pseudo_content_for(ss, element, PseudoKind::{Before,After}).
Literal strings,attr(name), andopen-quote/close-quoteall
resolve.- Opacity +
transform: translate*().opacity <= 0.01on any
ancestor hides an element and all its text descendants.
transform: translateX/Y/translate(…)applies as a pre-paint
offset on the box's x/y. <img>data-URI embedding.<img src="data:image/png;base64,…">
(anddata:image/jpeg;…, percent-encoded plain payloads) now
decode to a real PDF Image XObject. The paint pipeline emits
/Dooperators against a per-page/XObjectresource dictionary
whichPdfWriter::finish()now serializes — the missing
resource-dict wiring was why priorpage.add_element(Image(…))
calls rendered as silent no-ops. External URLs / filesystem paths
returnNonefromdecode_image_srcso callers can resolve those
themselves.- List markers.
<ul>items get•(U+2022) and<ol>items
getN.numbering, painted in the gutter to the left of the
<li>'s content box. Nested lists work on both levels. <a href>link annotations. Every anchor box with a non-empty
hrefemits a PDF/Linkannotation carrying a/URIaction;
inline text inside the anchor inherits the link by walking up the
box tree. Anchors with nohrefemit no annotation.- Embedded fonts via
DocumentBuilder(#382). New
DocumentBuilder::register_embedded_font(name, EmbeddedFont).
Text emitted through the fluent builder
(FluentPageBuilder::font(name, size).text(...), or any
ContentElement::TextwhoseFontSpec.namematches a registered
embedded font — including template headers/footers) is now routed
through the Type-0 / CIDFontType2 path instead of silently falling
back to Helvetica. CJK, Cyrillic, Greek, Hebrew, Arabic text
emitted via the high-level API now actually embeds and renders.
Unregistered font names continue to resolve against the base-14
set. Reported by @sparkyandrew.
Bug fixes surfaced during pre-release review
- Base-14 bold text rendered non-bold. The page
/Resources /Font
dictionary keyed entries with dashes stripped (HelveticaBold)
while content streams emittedTf /Helvetica-Bold. PDF readers
silently fell back to the default font, so every bold or italic
base-14 run came out regular. Resource-dict keys now match the
Tfoperator names exactly. - TTC system fonts (Helvetica.ttc, msgothic.ttc, …).
fontdb
surfaces collection fonts asSource::SharedFile(path, …), which
the resolver previously rejected asNoPath. SharedFile entries
are now read the same way as regular files, so a huge swathe of
macOS/Windows system fonts become resolvable. - Unquoted multi-word
font-family.font-family: DejaVu Sans, sans-seriftokenises as two separateIdents, so the registered-
family lookup never matched them as a single name. The resolver
now collects consecutive idents (whitespace-separated) into one
candidate and flushes at top-level commas, so quoted and unquoted
forms behave the same. - Memory leak in
Pdf::from_html_css/from_html_css_with_fonts.
The factories leaked the combined CSS source, parsed stylesheet,
DOM, and family map on every call (fourBox::leaksites). Long-
running processes (HTTP servers, batch converters) grew unbounded.
The downstream APIs all accept non-'static references; the
function now holds them in locals scoped to the call. - PNG alpha / soft-mask now renders.
ImageData::from_png
already decoded and compressed the alpha channel, but
ImageContenthad no field for it and the XObject emitter hard-
codedSMask = None.ImageContentgains asoft_mask, the
html_css paint pipeline propagates it, and the XObject path
actually emits a/SMaskstream. - Shaped text round-trips via
extract_text. The shaped path
(add_shaped_embedded_text) only recorded glyph IDs in the
subsetter, leaving shaped runs absent from the ToUnicode CMap and
uncopy-paste-able. The newencode_shaped_runmaps glyph clusters
back to source codepoints so the ToUnicode entries are complete
for simple scripts and exact-leading-char for ligatures. - Reproducible PDF output.
PdfWriter::finishiterated
embedded_fontsdirectly from the HashMap, randomising object-ID
order across runs. Embedded fonts are now emitted in registration
order via an explicitembedded_font_ordervector. - Embedded-font name collisions. Registering two fonts with the
same display name silently overwrote the first.embedded_fonts
is keyed by itsEFnresource name (unique, monotonic) so
registrations are independent regardless of display name. - fontdb Mutex serialised on slow disks.
SystemFontDb::resolve
held the fontdb lock across the font-bytesfs::read.
Concurrent resolve calls are now lock-free during I/O — the lock
is released once the face path + PostScript metadata are picked. - Misleading docs corrected. Module documentation previously
claimedbackground-colorrendered as a filled rect (currently a
no-op stub) and that the writer embedded a subset of the face
(currently embeds the full face + Identity-H, subsetter output is
a later follow-up). Both are now reflected accurately in the
relevant docstrings.
Tests added in the corner-case pass
- E2E (
tests/test_html_to_pdf_e2e.rs): 36 tests (was 14),
covering every feature above plus a kitchen-sink document that
exercises::before, list markers, page-break, opacity, translate,
and<a href>in a single round-trip. - Unit: 4 cascade pseudo-element tests, 7 paint tests (opacity /
translate / data-URI decode), 3 inline-text sizing tests, 1 RTL
shaper test, 1 multi-font cascade test, 1 tokenizer multi-byte
regression test. - Total test count: 4772 lib + 36 e2e; 168 integration suites all
green, 0 regressions on the existing corpus.
Limits
The supported CSS surface is documented in detail in
docs/HTML_TO_PDF_GUIDE.md. Out of
scope: CSS filters, 3D transforms, animations, SVG-in-HTML (every
viable Rust SVG crate is MPL), MathML, hyphens: auto,
shape-outside, JavaScript execution, full-matrix transform
(scale/rotate), gradients, and box-shadow.
Licence audit
cargo deny check licenses passes with zero MPL transitive
dependencies. The Mozilla CSS stack (cssparser, selectors,
html5ever, lightningcss, stylo) is all MPL-2.0; v0.3.37 hand-
rolls the equivalents to keep pdf_oxide entirely under MIT/Apache.
Community Contributors
- @jmriebold — Filed
#248
("CSS support"). That single issue is the root of this release's
entire HTML+CSS→PDF pipeline — the hand-rolled CSS engine, the
HTML5 tokenizer + arena DOM, Taffy-backed layout, the::before/
::after,page-break-*,<img>data-URI, multi-font cascade,
opacity / transform,<a href>link, and RTL shaping work all
exist because he asked for it. Thank you.
Installation
Rust (crates.io)
cargo add pdf_oxidePython (PyPI)
pip install pdf_oxideJavaScript/WASM (npm)
npm install pdf-oxide-wasmCLI (Homebrew)
brew install yfedoseev/tap/pdf-oxideCLI (Scoop — Windows)
scoop bucket add pdf-oxide https://github.com/yfedoseev/scoop-pdf-oxide
scoop install pdf-oxideCLI (Shell installer)
curl -fsSL https://raw.githubusercontent.com/yfedoseev/pdf_oxide/main/install.sh | shCLI (cargo-binstall)
cargo binstall pdf_oxide_cliMCP Server (for AI assistants)
cargo install pdf_oxide_mcpPre-built Binaries
Download archives for Linux, macOS, and Windows from the assets below. Each archive includes both pdf-oxide (CLI) and pdf-oxide-mcp (MCP server).
Platform Support
| Platform | Architecture | Archive |
|---|---|---|
| Linux | x86_64 (glibc) | pdf_oxide-linux-x86_64-*.tar.gz
|
| Linux | x86_64 (musl) | pdf_oxide-linux-x86_64-musl-*.tar.gz
|
| Linux | ARM64 | pdf_oxide-linux-aarch64-*.tar.gz
|
| macOS | x86_64 (Intel) | pdf_oxide-macos-x86_64-*.tar.gz
|
| macOS | ARM64 (Apple Silicon) | pdf_oxide-macos-aarch64-*.tar.gz
|
| Windows | x86_64 | pdf_oxide-windows-x86_64-*.zip
|
Changelog
See CHANGELOG.md for full details.