Files
pdf-odin-llm/page.odin
T
2026-09-25 23:32:29 +02:00

601 lines
24 KiB
Odin

package pdf
import "core:math"
import "core:mem"
import "core:mem/virtual"
// The public rendering API: what a user of this library calls once a Document
// is open.
//
// doc, _ := pdf.open_file("thesis.pdf")
// page, _ := pdf.page_get(doc, 0)
// bmp, _ := pdf.render_page(&page, {ctm = pdf.scale_matrix(2, 2)})
// defer pdf.bitmap_destroy(&bmp)
//
// Everything below this file is already written: `content.odin` walks the
// operators, the three devices turn them into pixels, text or a display list.
// What is here is the small amount that only a *page* knows -- where the page
// sits in device space, how big a bitmap it needs, which scratch memory the
// run may use, and that a page is its content stream *plus* its annotations.
//
// ---------------------------------------------------------------------------
// THE BASE MATRIX
//
// This is the piece everything else depends on and the easiest to get subtly
// wrong, so it is derived once, in `page_base_matrix`, and every entry point
// here goes through it. That is what makes a rendered bitmap, extracted text
// and a display list agree about where things are instead of being three
// nearly-identical derivations that disagree in one corner.
//
// PDF user space is y-UP with its origin at the bottom-left of the CROP box.
// A bitmap is y-DOWN with its origin at the top-left. So the base transform,
// in order:
//
// 1. translates the crop box's lower-left corner to the origin;
// 2. scales by /UserUnit, the size in points of one unit of page space;
// 3. flips y and applies /Rotate, mapping the page onto a device rectangle
// whose top-left corner is (0,0);
// 4. applies the caller's `ctm`.
//
// Steps 1-3 are fixed by the page. Step 4 is the caller's zoom, and is what
// `Render_Options.ctm`, `run_page`'s `ctm` and `page_display_list`'s `ctm` all
// are: a transform applied ON TOP of the flip and the rotation, never instead
// of them. `MATRIX_IDENTITY` therefore renders the page upright at one device
// pixel per point, which is what a caller expects to get by passing nothing.
//
// The four rotations are written out as constants rather than built with
// `rotate_matrix`, because cos(pi/2) is 6.1e-17 and not 0: a page with
// /Rotate 90 must land on exact pixel boundaries, not 6.1e-17 off them.
//
// ---------------------------------------------------------------------------
// MEMORY
//
// Three kinds, and they must not be confused:
//
// * The Document's arena holds everything parsed out of the file -- fonts,
// images, colour spaces, the decoded content bytes. It is never freed
// before `close`, so nothing per-render may go in it.
// * Per-page scratch -- the operand stack, paths under construction, glyph
// runs, tile lattices, the strings the content lexer materialises -- goes
// in a `virtual.Arena` that is a LOCAL of the entry point that started the
// render. It is created when the call starts and destroyed when it
// returns, so a 900-page loop peaks at one page's scratch rather than
// growing without bound. That is the arena ARCHITECTURE.md asks for, and
// `page_scratch_begin`/`page_scratch_end` are where it lives.
// * The result -- `render_page`'s Bitmap, `extract_text`'s string,
// `page_display_list`'s list -- belongs to the CALLER, comes from the
// allocator the caller named, and is freed by the caller.
//
// The devices themselves allocate from `context.allocator` and free everything
// in their own `*_destroy`; they are built and torn down inside one call.
//
// ---------------------------------------------------------------------------
// WHAT A PAGE IS
//
// A page is its content stream AND its annotations. `render_page`,
// `extract_text` and `page_display_list` all draw both, because a form field's
// value and a stamp are as much a part of what the page looks like as the text
// under them -- /Annots is in half the corpus. `annot.odin` owns the placement
// and this file owns the order: content first, annotations over it, in
// /Annots order. `run_page` is the one exception and says so.
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
// Render_Options tunes one call to `render_page` or `render_page_to`. The zero
// value is the sensible default for all of them: an opaque white page, no
// extra transform, annotations drawn.
Render_Options :: struct {
// Applied on top of the page flip and /Rotate, never instead of them. The
// zero Matrix is not a transform, so it is read as MATRIX_IDENTITY.
ctm: Matrix,
// RGBA, straight alpha. An all-zero value means "unset" -- which is what a
// zeroed Render_Options carries -- and is filled in as opaque white for an
// opaque page and left fully transparent when `alpha` is set. Any other
// value is used as given, with its alpha forced to 255 unless `alpha` is
// set, because an opaque page cannot start out see-through.
background: [4]u8,
// Start the page transparent and keep whatever alpha the content leaves.
// The bitmap is RGBA8 either way; this decides what is underneath.
alpha: bool,
// Draw the content stream only. The default draws the page's annotation
// appearance streams over it, which is what a viewer shows.
no_annotations: bool,
// Where the returned Bitmap's pixels come from. Zero means
// context.allocator. It is not used for any scratch memory.
allocator: mem.Allocator,
}
// ---------------------------------------------------------------------------
// The base matrix
// ---------------------------------------------------------------------------
// page_base_matrix maps the page's user space to device space, with `ctm`
// applied on top.
//
// It is the transform every entry point in this file renders through, and the
// one a caller needs to relate what came out of `render_page` to what is in
// the file: `transform_point(page_base_matrix(&page, ctm), p)` is where user
// point `p` lands in the bitmap, and the inverse (via `matrix_invert`) turns a
// pixel a user clicked on back into a point on the page.
//
// The origin is the CROP box's lower-left corner, not the media box's: the
// crop box is the rectangle a viewer displays, and `render_page` sizes its
// bitmap to it. /UserUnit and /Rotate are both folded in, so a page with
// /Rotate 90 comes back landscape and the right way up.
page_base_matrix :: proc(page: ^Page, ctm := MATRIX_IDENTITY) -> Matrix {
m := ctm
if !matrix_is_finite(m) {
// A caller-supplied matrix is not file data, but it can be arithmetic
// on file data. A NaN here would silently blank the page.
m = MATRIX_IDENTITY
}
if page == nil {
return m
}
crop := page_bounds(page)
unit := page.user_unit
if !is_finite(unit) || unit <= 0 {
unit = 1
}
// The page's extent in device units before rotation.
w := rect_width(crop) * unit
h := rect_height(crop) * unit
if !is_finite(w) || !is_finite(h) {
return m
}
// Crop origin to (0,0), then /UserUnit.
base := Matrix{unit, 0, 0, unit, -crop.x0 * unit, -crop.y0 * unit}
// The flip and the rotation in one step. Each maps the [0,w] x [0,h] y-up
// rectangle onto the y-down device rectangle a viewer would show, whose
// top-left corner is the origin. Every one has determinant -1: the y-flip
// reverses orientation once, and a rotation does not reverse it again.
flip: Matrix
switch page.rotate {
case 90:
// Clockwise: the page's left edge becomes the top of the image, so the
// device is h wide and w tall.
flip = Matrix{0, 1, 1, 0, 0, 0}
case 180:
flip = Matrix{-1, 0, 0, 1, w, 0}
case 270:
flip = Matrix{0, -1, -1, 0, h, w}
case:
flip = Matrix{1, 0, 0, -1, 0, h}
}
return matrix_mul(matrix_mul(base, flip), m)
}
// page_render_size is the pixel size `render_page` allocates for `ctm`.
//
// The bitmap spans device space from (0,0) to the far corner of the page under
// the full transform, so for the transforms callers actually use -- a scale,
// or nothing -- it is exactly `page_size`, /Rotate and /UserUnit included. A
// `ctm` that pushes content to negative coordinates pushes it off the bitmap
// rather than moving the bitmap: that is what makes `render_page_to` usable
// for tiled rendering, where each tile's `ctm` carries the translation that
// selects it.
@(private)
page_render_size :: proc(page: ^Page, ctm: Matrix) -> (width, height: int, err: Error) {
if page == nil {
return 0, 0, .Invalid_Argument
}
box := transform_rect(page_base_matrix(page, ctm), page_bounds(page))
if !rect_is_finite(box) {
return 0, 0, .Invalid_Argument
}
// Rounded outward with the same epsilon page_size uses: a page 100.2
// pixels wide needs 101 columns, but an exact 200 that floating point
// rendered as 200.00000000000003 must not claim a 201st.
limit := f64(MAX_BITMAP_PIXELS)
if box.x1 > limit || box.y1 > limit {
return 0, 0, .Bitmap_Too_Large
}
width = max(1, int(math.ceil(box.x1 - 1e-9)))
height = max(1, int(math.ceil(box.y1 - 1e-9)))
if width > MAX_BITMAP_PIXELS / height {
return 0, 0, .Bitmap_Too_Large
}
return width, height, .None
}
// ---------------------------------------------------------------------------
// Scratch memory
// ---------------------------------------------------------------------------
// Page_Scratch is the per-page arena. It is held by value in the caller's
// frame so that it dies with the call: see the memory note at the top.
@(private)
Page_Scratch :: struct {
arena: virtual.Arena,
alloc: mem.Allocator,
}
@(private)
page_scratch_begin :: proc(s: ^Page_Scratch) -> Error {
if virtual.arena_init_growing(&s.arena) != nil {
return .Out_Of_Memory
}
s.alloc = virtual.arena_allocator(&s.arena)
return .None
}
@(private)
page_scratch_end :: proc(s: ^Page_Scratch) {
virtual.arena_destroy(&s.arena)
s^ = {}
}
// ---------------------------------------------------------------------------
// Running a page into a device
// ---------------------------------------------------------------------------
// run_page runs the page's content stream into `dev`.
//
// `ctm` is applied on top of the page flip and /Rotate, exactly like
// `Render_Options.ctm`: pass MATRIX_IDENTITY to draw the page upright at one
// device pixel per point. This is the entry point for a caller with a device
// of their own -- a GPU backend, a bounding-box collector, an SVG writer.
//
// Annotations are NOT drawn: this runs the page, and an annotation is a
// separate object with its own appearance stream. There is no option here to
// say otherwise, which is deliberate -- a caller that wants a page's
// annotations as device calls records the whole page with `page_display_list`
// and replays it, and a caller that wants only the content stream has this.
//
// The device's `close` hook is called when the page is finished, which is what
// balances any group or clip the content left open. Nothing about a malformed
// content stream is fatal: everything that could not be run is recorded as a
// Repair on the Document and the rest still draws.
run_page :: proc(page: ^Page, dev: ^Device, ctm: Matrix) -> Error {
if page == nil || page.doc == nil {
return .Invalid_Argument
}
m := page_ctm_or_identity(ctm)
if !matrix_is_finite(m) {
return .Invalid_Argument
}
scratch: Page_Scratch
page_scratch_begin(&scratch) or_return
defer page_scratch_end(&scratch)
return page_run(page, dev, m, &scratch, false, false)
}
// page_run is the one implementation the four public entry points share: run
// the content, then the annotations, then close the device.
//
// `text` selects the settings text extraction wants and rendering does not --
// chiefly drawing optional content the catalog's default configuration hides,
// because hidden text is still text.
@(private)
page_run :: proc(page: ^Page, dev: ^Device, ctm: Matrix, scratch: ^Page_Scratch,
annots: bool, text: bool) -> Error {
doc := page.doc
base := page_base_matrix(page, ctm)
// The soft-mask and inline-image budgets are the page's, not each run's:
// the annotations below are separate `run_content` calls and would
// otherwise each get a fresh one. This lives in the page's scratch arena,
// which is exactly the lifetime `Content_Options.shared` asks for.
shared := new(Content_Shared, scratch.alloc)
opts := Content_Options{
allocator = scratch.alloc,
ignore_optional_content = text,
shared = shared,
}
// The content bytes are the concatenation of /Contents, which is scratch:
// a page whose content is 40 MB of Flate must not leave 40 MB in the
// Document's arena for the rest of the document's life.
content, cerr := page_content(page, scratch.alloc)
err := cerr
if cerr == .None && len(content) > 0 {
rerr := run_content(doc, dev, content, page.resources, base, opts)
if rerr != .None {
err = rerr
}
}
// Annotations are `annot.odin`'s: an appearance stream is a form XObject
// placed on the annotation's /Rect, and it runs through the same
// interpreter under the same CTM the content stream had. Sharing `opts`
// shares this page's scratch arena and its token budget with them.
if annots {
annot_run_page(doc, page, dev, base, opts)
}
dev_close(dev)
return err
}
// ---------------------------------------------------------------------------
// Rendering to a bitmap
// ---------------------------------------------------------------------------
// render_page rasterises a page and returns the bitmap.
//
// The result is RGBA8 with STRAIGHT (un-premultiplied) alpha, sized to the
// page's crop box under `opts.ctm` with /Rotate and /UserUnit already applied,
// so a page with /Rotate 90 comes back landscape and the right way up. A
// request larger than MAX_BITMAP_PIXELS is refused with .Bitmap_Too_Large
// rather than attempted.
//
// THE BITMAP BELONGS TO THE CALLER. It is allocated from `opts.allocator`
// (context.allocator when that is zero) and must be released with
// `bitmap_destroy`. Nothing else the call touches survives it: the scratch
// arena and the raster device are both gone by the time it returns.
//
// A page that will not draw still returns a bitmap. Malformed content records
// Repairs on the Document and draws what it can, so a page that is 90%
// drawable comes back 90% drawn; an error return means the bitmap could not be
// made at all.
render_page :: proc(page: ^Page, opts := Render_Options{}) -> (Bitmap, Error) {
if page == nil || page.doc == nil {
return {}, .Invalid_Argument
}
ctm := page_ctm_or_identity(opts.ctm)
if !matrix_is_finite(ctm) {
return {}, .Invalid_Argument
}
width, height, serr := page_render_size(page, ctm)
if serr != .None {
return {}, serr
}
alloc := opts.allocator
if alloc.procedure == nil {
alloc = context.allocator
}
bmp, berr := bitmap_make(width, height, 4, alloc)
if berr != .None {
return {}, berr
}
resolved := opts
resolved.ctm = ctm
if rerr := render_page_to(page, &bmp, resolved); rerr != .None {
// The bitmap is still the caller's -- it is background-filled and
// holds whatever was drawn before the failure. Handing it back with
// the error is what lets a caller show a blank page instead of
// nothing.
return bmp, rerr
}
return bmp, .None
}
// render_page_to rasterises a page into a bitmap the caller already has.
//
// It paints the background and produces exactly the picture `render_page`
// would, so the two can be checked against each other; the only difference is
// who allocated. Any component count is accepted (1 grey, 2 grey+alpha, 3 RGB,
// 4 RGBA) and the bitmap is left with straight alpha.
//
// `page_size(page, scale)` is the size `render_page` allocates for
// `scale_matrix(scale, scale)`, so a caller that wants the whole page allocates
// that and gets the same picture.
//
// The bitmap is NOT resized: what falls outside it is clipped. That is what
// makes tiled rendering work -- give each tile the transform that selects it,
//
// opts.ctm = matrix_mul(scale_matrix(4, 4), translate_matrix(-tx, -ty))
//
// and each call fills one tile.
render_page_to :: proc(page: ^Page, bmp: ^Bitmap, opts := Render_Options{}) -> Error {
if page == nil || page.doc == nil || bmp == nil {
return .Invalid_Argument
}
if bmp.width <= 0 || bmp.height <= 0 || len(bmp.pixels) == 0 || bmp.n < 1 || bmp.n > 4 {
return .Invalid_Argument
}
ctm := page_ctm_or_identity(opts.ctm)
if !matrix_is_finite(ctm) {
return .Invalid_Argument
}
page_fill_background(bmp, page_background(opts))
scratch: Page_Scratch
if serr := page_scratch_begin(&scratch); serr != .None {
// The background is painted, so the bitmap is a usable blank page; it
// must still leave here with straight alpha like every other path.
bitmap_unpremultiply(bmp)
return serr
}
defer page_scratch_end(&scratch)
rd, derr := raster_device_make(bmp, Raster_Clip{0, 0, bmp.width, bmp.height},
context.allocator, page.doc)
if derr != .None || rd == nil {
// The background is painted and the bitmap is valid; only the drawing
// is missing.
bitmap_unpremultiply(bmp)
return derr == .None ? .Out_Of_Memory : derr
}
defer raster_device_destroy(rd)
err := page_run(page, raster_device_as_device(rd), ctm, &scratch, !opts.no_annotations, false)
// Everything below the Device line works in premultiplied alpha; a caller
// uploading this to a texture wants it straight (bitmap.odin says so).
bitmap_unpremultiply(bmp)
return err
}
// page_background resolves the options' background to an RGBA colour.
@(private)
page_background :: proc(opts: Render_Options) -> [4]u8 {
bg := opts.background
if bg == ([4]u8{0, 0, 0, 0}) {
// Unset, which is what a zeroed Render_Options carries. An opaque page
// defaults to white; an alpha page defaults to nothing at all.
return opts.alpha ? [4]u8{0, 0, 0, 0} : [4]u8{255, 255, 255, 255}
}
if !opts.alpha {
bg[3] = 255
}
return bg
}
// page_fill_background paints `rgba` and leaves the bitmap PREMULTIPLIED,
// which is the state every bitmap below the Device line is in.
//
// The colour goes through `blend_source_for` and `blend_quantize` rather than
// being reduced here, so the background of a greyscale target is the same grey
// a content stream painting that colour into the same bitmap would produce.
// Two answers to "how bright is this colour" is exactly the kind of seam this
// library has had to fix once already.
@(private)
page_fill_background :: proc(bmp: ^Bitmap, rgba: [4]u8) {
a := f64(rgba[3]) / 255
src := blend_source_for(bmp.n, [3]f64{
f64(rgba[0]) / 255, f64(rgba[1]) / 255, f64(rgba[2]) / 255,
})
c0 := blend_quantize(src[0] * a)
c1 := blend_quantize(src[1] * a)
c2 := blend_quantize(src[2] * a)
fill: [4]u8
switch bmp.n {
case 1: fill = {c0, 0, 0, 0}
case 2: fill = {c0, rgba[3], 0, 0}
case 3: fill = {c0, c1, c2, 0}
case: fill = {c0, c1, c2, rgba[3]}
}
bitmap_fill(bmp, fill)
bmp.premultiplied = true
}
// page_ctm_or_identity reads a caller's transform. The zero value of a Matrix is not a
// transform at all -- it collapses the page to a point -- so it is read as
// MATRIX_IDENTITY, which makes a zeroed Render_Options mean "no extra
// transform" rather than "draw nothing". It is named for this file rather
// than for Matrix because it is a convention of this API, not of the geometry.
@(private)
page_ctm_or_identity :: proc "contextless" (m: Matrix) -> Matrix {
zero: Matrix
return m == zero ? MATRIX_IDENTITY : m
}
// ---------------------------------------------------------------------------
// Text extraction
// ---------------------------------------------------------------------------
// extract_text returns the page's text, reconstructed in reading order.
//
// It is the same interpreter run `render_page` performs, into a Text_Device
// instead of a rasteriser, which is why the text a page extracts to always
// agrees with the text it draws: invisible OCR text under a scan comes out,
// text drawn as paths does not, and a glyph's position is the position it
// would have been painted at. Spans are joined with nothing, lines with a
// newline and blocks with a blank line, and there is no trailing newline.
//
// Annotation appearance streams are included: the value a form field shows is
// text on the page as far as a reader is concerned. Optional content that the
// catalog's default configuration hides is included too -- hidden text is
// still text.
//
// THE STRING BELONGS TO THE CALLER: it is allocated with `allocator` and is
// released with `delete(s, allocator)`.
extract_text :: proc(page: ^Page, allocator := context.allocator) -> (string, Error) {
if page == nil || page.doc == nil {
return "", .Invalid_Argument
}
td, terr := text_device_make(context.allocator)
if terr != .None || td == nil {
return "", terr == .None ? .Out_Of_Memory : terr
}
defer text_device_destroy(td)
scratch: Page_Scratch
if serr := page_scratch_begin(&scratch); serr != .None {
return "", serr
}
defer page_scratch_end(&scratch)
// The base matrix is the same one render_page uses, so the device sees
// glyphs where a reader would see them: x to the right, y down the page.
// Reading order falls out of that and must not be re-derived here.
err := page_run(page, text_device_as_device(td), MATRIX_IDENTITY, &scratch, true, true)
return text_device_result(td, allocator), err
}
// ---------------------------------------------------------------------------
// Display lists
// ---------------------------------------------------------------------------
// page_display_list records the page's drawing calls instead of performing
// them, for a caller with a backend of their own: replaying the list into a
// device produces what running the page into that device directly would.
//
// `ctm` is applied on top of the page flip and /Rotate, exactly as everywhere
// else here, so a list recorded with MATRIX_IDENTITY is in the same device
// space `render_page` rasterises into and `display_list_bounds` can be
// compared with `page_size`. Annotation appearances are recorded too, the way
// `render_page` draws them: the list is the whole page, not just its content
// stream.
//
// THE LIST BELONGS TO THE CALLER. Everything in it, and the Display_List
// itself, comes from `allocator`:
//
// dl, _ := pdf.page_display_list(&page, pdf.MATRIX_IDENTITY)
// defer { pdf.display_list_destroy(dl); free(dl) }
//
// The Fonts, Images, Shadings and Colorspaces it points at belong to the
// Document and stay valid for as long as it is open.
page_display_list :: proc(page: ^Page, ctm: Matrix, allocator := context.allocator) -> (^Display_List, Error) {
if page == nil || page.doc == nil {
return nil, .Invalid_Argument
}
m := page_ctm_or_identity(ctm)
if !matrix_is_finite(m) {
return nil, .Invalid_Argument
}
ld, lerr := list_device_make(allocator)
if lerr != .None || ld == nil {
return nil, lerr == .None ? .Out_Of_Memory : lerr
}
dl, aerr := new(Display_List, allocator)
if aerr != nil || dl == nil {
list_device_destroy(ld)
return nil, .Out_Of_Memory
}
scratch: Page_Scratch
if serr := page_scratch_begin(&scratch); serr != .None {
free(dl, allocator)
list_device_destroy(ld)
return nil, serr
}
defer page_scratch_end(&scratch)
err := page_run(page, list_device_as_device(ld), m, &scratch, true, false)
// The list is moved out of the device, which is then torn down: the
// recorded commands own their copies and outlive the recorder. Zeroing the
// device's copy is what stops `list_device_destroy` freeing them.
dl^ = ld.list
ld.list = {}
list_device_destroy(ld)
return dl, err
}