Skip to content

Latest commit

Β 

History

History
440 lines (361 loc) Β· 19.5 KB

File metadata and controls

440 lines (361 loc) Β· 19.5 KB

pdfree-core API (Phase 0 + 1 + 2 + 3)

The engine works on bytes, not file paths, so the identical code path runs on native platforms and in the browser (where there is no filesystem). Convenience constructors read files where a filesystem exists.

Opening a document

use pdfree_core::{Document, RenderOptions};

// From a file...
let doc = Document::open("contract.pdf")?;
// ...or from bytes (browser, network, DB blob):
let doc = Document::from_bytes(bytes, None)?;          // None = no password
let doc = Document::open_with_password("locked.pdf", "hunter2")?;

Inspecting

doc.page_count();          // u16
let m = doc.metadata();    // &Metadata { title, author, subject, creator, producer, page_count }
m.title.as_deref();        // Option<&str>

Rendering a page

// 0-based page index. DPI: 72 = 1x, 150 = screen default, 300 = print.
let png: Vec<u8> = doc.render_page(0, &RenderOptions::with_dpi(150.0))?;
std::fs::write("page-1.png", png)?;

Free-function equivalents also exist:

let doc = pdfree_core::open_document("contract.pdf")?;
let png = pdfree_core::render_page(&doc, 0, &RenderOptions::default())?; // default 150 DPI

Forms: reading and filling AcroForm fields

use pdfree_core::forms::{self, FieldKind, FillValue};

// Enumerate every interactive field with its kind, current value, and the
// page + widget rect a shell needs to pre-render an input affordance for it
// without any manual box placement.
let fields = forms::fields(&pdf_bytes)?;
for f in &fields {
    println!(
        "{:?} {} = {:?} (page {}, {}x{} at {},{})",
        f.kind, f.name, f.value, f.page, f.width, f.height, f.x, f.y
    );
}

// Fill by field name. Every name must exist and must pair with a FillValue
// its kind accepts, or the call errors instead of silently dropping it.
let filled: Vec<u8> = forms::fill(
    &pdf_bytes,
    &[
        ("topmostSubform[0].Page1[0].f1_01[0]".to_string(), FillValue::Text("Wesley".into())),
        ("topmostSubform[0].Page1[0].c1_1[0]".to_string(), FillValue::Checkbox(true)),
    ],
)?;

FillValue is scoped to what pdfium-render 0.8 actually exposes a setter for: Text(String) and Checkbox(bool). Dropdowns, list boxes, radio button groups, and signature fields are readable via forms::fields (their FieldKind and current value come back fine) but not writable through forms::fill β€” that call returns PdfError::UnsupportedFieldFill { name, kind } rather than silently no-opping. See CLAUDE.md's Phase 1 entry for why. Radio groups specifically were investigated in depth (not just doc-read): PdfFormRadioButtonField::set_checked() compiles and looks like a real setter, but verified against a real fixture that it only echoes a widget's already-current /AS up to the shared /V β€” it can't establish a new selection from a byte-in/byte-out call with no prior interactive click. Each radio widget's FormField.radio_group_index (its position within the group) is still exposed, since reading which option is currently selected and displaying the group's options is unaffected by that gap.

Known gap: fill() has no way to bake in a deterministic "fit once" font size for a text field β€” confirmed against pdfium-render 0.8.37's source that setting a widget's /DA (default appearance) string is only reachable through a pub(crate)-only trait the crate deliberately doesn't expose, so there is no annotation handle or dictionary-key setter available from outside the binding. Filled text is sized entirely by PDFium's own form-render behavior at export time. See CLAUDE.md's Phase 1 entry for why.

Overlaying text on a non-interactive PDF

For a PDF with no AcroForm at all β€” a plain scanned form, a flat template β€” stamp text directly onto the page instead:

use pdfree_core::forms::{self, TextOverlay};

let stamped = forms::overlay_text(
    &pdf_bytes,
    &[TextOverlay {
        page: 0,
        x: 72.0,          // PDF points from the left edge
        y: 700.0,         // PDF points from the bottom edge
        text: "Jane Doe".to_string(),
        font_size: 12.0,
    }],
)?;

Signing: placing a visual signature image

use pdfree_core::signatures::{self, SignaturePlacement};

let signed: Vec<u8> = signatures::place_signature(
    &pdf_bytes,
    &signature_png,   // drawn, typed-and-rendered, or uploaded β€” the shell's choice
    SignaturePlacement { page: 0, x: 72.0, y: 450.0, width: 150.0, height: 60.0 },
)?;

This is the "basic e-sign" path from CLAUDE.md's v1 spec: stamp an image onto the page, no cryptography involved. signatures::sign_with_certificate (PKCS#12 digital certificate signing) is deliberately PdfError::NotImplemented β€” see CLAUDE.md's Phase 2 entry for why.

Annotations: highlight, underline, strikeout, sticky notes

use pdfree_core::annotations::{self, Annotation, AnnotationKind, Color};

let annotated: Vec<u8> = annotations::annotate(
    &pdf_bytes,
    &[
        Annotation {
            page: 0, kind: AnnotationKind::Highlight,
            x: 72.0, y: 600.0, width: 300.0, height: 20.0,
            color: None,                    // None = kind's default color
            note: Some("check this".into()),
        },
        Annotation {
            page: 0, kind: AnnotationKind::Note,
            x: 400.0, y: 700.0, width: 24.0, height: 24.0,
            color: None,
            note: Some("reviewer comment".into()),
        },
    ],
)?;

// Read every highlight/underline/strikeout/note back out, e.g. to render an
// annotation list UI or support deleting one.
let found = annotations::list(&annotated)?;

Known gap: highlight/underline/strikeout write correct, spec-compliant /QuadPoints//Rect//C data β€” verified by annotations::list reading it straight back β€” that most real-world viewers (Acrobat, Preview, browsers) render correctly per the PDF spec's default-appearance-synthesis rule. But pdfium-render 0.8.37 doesn't expose a way to attach an explicit appearance stream (/AP) to those three annotation types, and PDFium's own rendering doesn't synthesize one either β€” so they won't show in pdfree-core's own render preview yet. AnnotationKind::Note is unaffected: PDFium synthesizes a sticky-note icon appearance natively.

Editing: font-preserving in-place text replacement

use pdfree_core::editor;

// "Detect font of clicked text": enumerate every text run with its font and
// bounds, or hit-test a specific point (both in PDF points).
let runs = editor::text_runs(&pdf_bytes)?;
let hit = editor::text_run_at_point(&pdf_bytes, 0, 80.0, 705.0)?; // Option<TextRun>

// Replace text in place. The matching text object's own content is mutated,
// not recreated, so its font carries over automatically β€” no font-matching
// heuristic involved.
let edited: Vec<u8> = editor::replace_text(&pdf_bytes, 0, "page one", "chapter one")?;

If a text run contains find more than once, every occurrence in that run is replaced together β€” there's no character-offset-precise "replace just this one instance" within a run yet. replace_text errors (PdfError::TextNotFound) rather than silently no-opping if nothing on the page matches.

Pages: merge, split, rotate, extract, reorder, Bates numbering

use pdfree_core::pages::{self, Rotation};

let merged: Vec<u8> = pages::merge(&[doc_a_bytes, doc_b_bytes])?;
let pieces: Vec<Vec<u8>> = pages::split(&pdf_bytes, &[(0, 2), (3, 5)])?; // inclusive 0-based ranges
let rotated: Vec<u8> = pages::rotate(&pdf_bytes, 0, Rotation::Clockwise90)?;

// extract() pulls the given 0-based pages, in exactly the order given, into
// a new document β€” which is also how reorder() is implemented: give it a
// full permutation of the document's page indices.
let extracted: Vec<u8> = pages::extract(&pdf_bytes, &[2, 0, 1])?;
let reordered: Vec<u8> = pages::reorder(&pdf_bytes, &[1, 0])?; // swap a 2-page doc

pages::bates_number (Phase 4 quick win) stamps a sequential <prefix><zero-padded number><suffix> onto every page β€” the legal/discovery convention β€” reusing the same stamped-text-object primitive as forms::overlay_text, just looped per page with a computed string:

use pdfree_core::pages::{self, BatesOptions, StampCorner};

let stamped: Vec<u8> = pages::bates_number(&pdf_bytes, &BatesOptions {
    prefix: "ACME-".to_string(),
    suffix: String::new(),
    start: 1,
    digits: 6,                       // "ACME-000001", "ACME-000002", ...
    corner: StampCorner::BottomRight,
    margin: 24.0,                    // PDF points from the page edge
    font_size: 9.0,
})?;

A right-aligned corner (TopRight/BottomRight) measures each page's stamped label after placing it (via the text object's own rendered bounds) and shifts it left by that exact width, rather than estimating text width β€” so the stamp's right edge always lands precisely at margin.

Converting: text extraction and image β†’ PDF

use pdfree_core::convert;

let text: String = convert::to_text(&pdf_bytes)?;           // every page, joined
let pdf: Vec<u8> = convert::from_image(&png_bytes, 96.0)?;  // dpi controls the resulting page size

convert::to_docx/convert::from_docx are PdfError::NotImplemented β€” PDFium has no DOCX support, and faithful PDF↔DOCX conversion needs a document layout engine this workspace doesn't have yet. See CLAUDE.md's open questions.

Boxes: detecting every fillable box on a page (Phase 4 add-on)

Scanned or flattened forms often draw each fillable box as a stroked rectangle, or a table's cell borders as ruled lines β€” sometimes a fully closed rectangle, sometimes just side dividers with no top or bottom rule at all (very common: a labeled blank gets dividers but the box would be visually redundant with the row above/below) β€” instead of a real AcroForm field. boxes::boxes_on_page looks at the page's vector graphics (not text, not form fields) to reconstruct every such box at once, meant to be called as a page loads so a shell can highlight every fillable area up front:

use pdfree_core::boxes;

for found in boxes::boxes_on_page(&pdf_bytes, 0)? {
    // found.x/y/width/height are in PDF points β€” hand them straight to
    // forms::overlay_text as the place to stamp typed text.
}

boxes::box_at_point(&pdf_bytes, page, x, y) is a convenience wrapper for a single point-driven lookup (e.g. a manual click that isn't inside any already-scanned box) β€” it's boxes_on_page filtered down to the smallest box enclosing that point.

Detection runs three tiers, each skipping anything that duplicates a box a higher tier already found:

  1. Closed cells β€” four rulings (or ruling-clusters, since one visual line is often drawn as several abutting strokes) that together bound a rectangle. Most reliable: every side is confirmed by an actual line.
  2. Open cells β€” a pair of adjacent vertical dividers that both meet the same horizontal ruling but have nothing closing the far side (the "labeled blank with side dividers, no box" case above).
  3. Lone rectangles β€” a single stroked rectangle path not part of any grid (standalone checkboxes, signature boxes).

Two implementation details worth knowing if this ever needs revisiting:

  • pdfium-render's PdfPagePathObjectSegments yields each segment's untransformed, raw coordinates β€” real-world PDFs routinely place a path via a non-identity object matrix (translation at minimum), so this code always applies path.matrix() via .segments().transform(matrix) before reading points. Skipping that step silently reads every line's position wrong (confirmed against a real IRS 1040: reading raw segments found only small unrelated boxes and missed the entire ruled-line grid).
  • When pairing "adjacent" dividers to form a cell, adjacency must be judged only among the dividers relevant to the row/column in question (their spans must actually reach that row's y-range, or touch that ruling's y). Pairing by raw x-order across the whole page pairs dividers from unrelated rows whenever their x positions happen to interleave β€” a real bug hit while building this, not a hypothetical. Returns None if neither strategy finds an enclosing box β€” the caller decides the fallback (a fixed-size overlay, typically).

Fields: label-aware fillable-field detection

boxes::boxes_on_page above is geometry only β€” it finds every drawn rectangle and ruled cell, which on a real form over-detects (decorative rules, layout frames, table borders a human would never fill) and, being blind to the form dictionary, misses AcroForm fields that have no box drawn around them. fields::fillable_fields is the list a shell should actually highlight, computed in a single document parse (so a shell should prefer it over calling boxes_on_page and forms::fields separately per page):

use pdfree_core::fields::{self, FieldSource};

for field in fields::fillable_fields(&pdf_bytes, 0)? {
    // field.x/y/width/height in PDF points; field.label is what named it;
    // field.signature_kind routes signing fields to the sign flow.
    match field.source {
        FieldSource::AcroForm => { /* a real interactive widget */ }
        FieldSource::Detected => { /* a labeled box on a flat form */ }
    }
}

The rule, in one line: a field must be either a real AcroForm widget or a drawn box with a human-readable label next to it. Concretely:

  1. AcroForm widgets are always reported (text/checkbox/radio/dropdown/ list-box/signature β€” push buttons and unknowns are skipped). They're author-declared fields, so dropping one is exactly the "missed a fillable field" bug; each is paired with a nearby label for display where one is found.
  2. Detected boxes (from boxes_on_page) are kept only when a text run sits immediately to their left on the same line (Name: ____) or just above them (a column header), and only when they don't duplicate a widget already reported. A drawn box with no label near it is not a field β€” this is what stops decorative/layout rectangles from being highlighted, and what makes the detector work on flat/scanned forms with no AcroForm at all.

A detected box whose label reads like a signature/initials line ("Sign here", "Initials", …) is classified into the sign flow the same way an AcroForm signature field is (Core UX Principle #3), so a flat form's signature line routes to signing rather than a text caret.

The label-matching itself (best_label) is a pure function over plain run geometry, unit-tested without PDFium; see fields.rs. render_page and page_size also exist as free byte-slice functions (mirrored in pdfree-ffi and pdfree-wasm) so a shell can render and measure a page off its main thread without holding a Document handle β€” PDFium is not safe to drive from two threads at once, so keeping every call on one background queue matters.

Search: in-document text search (Phase 4 quick win β€” "⌘F")

use pdfree_core::search;

// Every text run containing "invoice", across the whole document.
let hits = search::find_text(&pdf_bytes, "invoice", false)?; // false = case-insensitive
for hit in &hits {
    // hit.x/y/width/height are the containing run's bounding box, in PDF
    // points β€” enough to draw a highlight rect and jump to hit.page.
    println!("page {}: {:?} ({}x)", hit.page, hit.text, hit.occurrences);
}

Reuses editor::text_runs rather than a second text-walking pass. Known scope boundary: a match's bounds are the whole containing run's bounding box, not a tight box around just the matched substring β€” same character-offset-precision boundary editor::replace_text already documents. SearchMatch::occurrences reports how many times the query appears within that run, so a shell can show a count instead of pretending there was only one hit. An empty query returns an empty list rather than every run.

Bookmarks: document outline (Phase 4 quick win)

use pdfree_core::bookmarks;

// A tree, not a flat list β€” walk `children` to render a nested outline panel.
let outline = bookmarks::outline(&pdf_bytes)?;
for top_level in &outline {
    println!("{} -> page {:?}", top_level.title, top_level.page);
}

Wraps pdfium-render's already-bound PdfBookmarks/PdfBookmark read API; pdfree-core doesn't add any new PDFium capability here, just a plain, Send-able tree a shell can render without touching PDFium types. Most PDFs have no outline at all β€” that's Ok(vec![]), not an error. A bookmark whose destination PDFium can't resolve to a page reports page: None rather than being dropped, so the shell can still show its title. Depth and total-node traversal are capped (mirroring the MAX_EDGE_PIXELS guard in renderer.rs) against a pathological or cyclic bookmark tree.

Errors

All fallible calls return pdfree_core::Result<T> (Err is PdfError):

Variant When
PdfiumUnavailable { searched, .. } PDFium library could not be located/loaded; lists every path tried
Pdfium(..) PDFium reported an error opening/working with the document
PageOutOfRange { index, count } Requested a page that doesn't exist
InvalidRenderTarget(..) Non-positive DPI, or a render that would exceed the pixel-size guard
UnknownFormField(name) forms::fill was asked to fill a name not present in the document
UnsupportedFieldFill { name, kind } forms::fill was asked to fill a field with a value kind it can't accept (wrong value type, or a dropdown/list-box/radio/signature field)
InvalidOverlay(..) forms::overlay_text or pages::bates_number given a non-positive/non-finite font_size
InvalidAnnotation(..) annotations::annotate given a non-positive/non-finite width/height
InvalidSignaturePlacement(..) signatures::place_signature given a non-positive/non-finite width/height
InvalidPageRange(..) pages::merge/split/extract given an empty or inverted range/list
InvalidPageOrder(..) pages::reorder given a list that isn't exactly a permutation of the document's pages
TextNotFound { page, find } editor::replace_text found no occurrence of find on page
Io(..) / Image(..) Filesystem, PNG-encoding, or signature/image-decoding failure
NotImplemented(name) signatures::sign_with_certificate or convert::to_docx/from_docx β€” the two capabilities deliberately deferred pending open decisions

PDFium binding

pdfree_core::pdfium::bind() loads PDFium, searching $PDFIUM_DYNAMIC_LIB_PATH, then vendor/pdfium/, then the system path. See docs/pdfium-bundling.md.

Implementation note: never call bind() twice within one call chain β€” two live PDFium bindings in the same process hangs (confirmed empirically while building pages::reorder). Every public function binds exactly once; pages::extract/pages::reorder share one binding through a private extract_with(&Pdfium, ...) helper rather than one calling the other's public entry point.

Status

Phases 0–3 are complete, plus two Phase 4 add-ons: boxes::box_at_point (driven by the macOS app's double-click-to-fill-a-box feature) and four viewer/pages quick wins from the 2026-07-03 feature research pass β€” search::find_text, bookmarks::outline, and pages::bates_number here, plus pdfree_ai::confidence::ground_check (see docs/ai-design.md). pdfree-core's only remaining deliberate gaps are signatures::sign_with_certificate (PKCS#12) and convert::to_docx/from_docx β€” both PdfError::NotImplemented pending open decisions in CLAUDE.md, not missing engineering. The rest of Phase 4 (platform shells β€” these four quick wins aren't wired into any shell's UI yet) and Phases 5–7 (pdfree-ai) are next.