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

478 lines
20 KiB
Odin

package pdf
import "core:mem/virtual"
// Annotations and their appearance streams (ISO 32000-2, 12.5).
//
// An annotation is a dictionary in a page's /Annots array: a link, a form
// field, a stamp, a signature, a highlight, a sticky note. None of them say
// what they look like. What is drawn is an APPEARANCE STREAM -- a form XObject
// under /AP -- mapped onto the annotation's /Rect. /Annots is in 53% of the
// files in the corpus, so a renderer that stops at the page's content stream
// draws the page perfectly and loses every form field and every stamp on it.
//
// THIS LIBRARY NEVER SYNTHESISES AN APPEARANCE
//
// An annotation with no /AP draws nothing at all: no border for a /Square, no
// leader line for a /Line, no laid-out text for a /FreeText or for a widget's
// /DA string. That is a deliberate scope decision, not an oversight.
// Generating appearances is the whole of 12.5.6's per-subtype geometry plus
// 12.7.4.3's variable-text layout, which is what a form-*filling* library
// owes; a reader owes what the file says the page looks like. Every producer
// that expects its annotations to be seen writes an /AP, and the ones that do
// not set /NeedAppearances, which announces that the consumer is expected to
// be an editor.
//
// PLACEMENT IS THE WHOLE JOB
//
// `annot_matrix` is the algorithm of 12.5.5, step by named step. Getting it
// wrong does not fail loudly -- it silently misplaces or wrongly scales every
// stamp and form field on the page -- so each step is named in the code and
// pinned by tests against hand-computed matrices.
//
// Once placed, the appearance is drawn by the ordinary content interpreter,
// through two synthesised tokens: `/Ap Do`. Invoking a form XObject is exactly
// what `Do` is for, and going through the interpreter rather than around it is
// what gets the /BBox clip, a transparency /Group, the form's own /Matrix, the
// /OC test and the self-reference guard right -- all of it already written and
// tested in content.odin.
// ---------------------------------------------------------------------------
// Limits
// ---------------------------------------------------------------------------
// Entries read from one page's /Annots. A table of contents can carry a link
// per line and a form can carry a field per cell, so this is generous; past it
// the array is not a page's annotations.
@(private)
MAX_ANNOTS :: 4096
// ---------------------------------------------------------------------------
// Flags
// ---------------------------------------------------------------------------
// Annot_Flag is one bit of /F (12.5.3, table 167).
//
// The format numbers its flag bits from 1; these values are the same bits
// numbered from 0, which is what a bit_set indexes by. `.Hidden` is therefore
// the format's "bit position 2".
Annot_Flag :: enum u32 {
Invisible = 0,
Hidden = 1,
Print = 2,
No_Zoom = 3,
No_Rotate = 4,
No_View = 5,
Read_Only = 6,
Locked = 7,
Toggle_No_View = 8,
Locked_Contents = 9,
}
Annot_Flags :: bit_set[Annot_Flag; u32]
// annot_flags_read turns /F into a set. Bits above the ten the format defines
// are dropped rather than kept: they are reserved, and a set that holds
// members no enumerator names prints as garbage.
@(private)
annot_flags_read :: proc(raw: i64) -> (f: Annot_Flags) {
for bit in 0 ..< len(Annot_Flag) {
if raw & (i64(1) << uint(bit)) != 0 {
f += {Annot_Flag(bit)}
}
}
return
}
// ---------------------------------------------------------------------------
// Annot
// ---------------------------------------------------------------------------
// Annot is one annotation of a page with its appearance already chosen.
//
// It borrows from the Document: `dict`, `ap`, `subtype` and `state` all point
// into the Document's arena or the file bytes and become invalid when `close`
// is called. The struct is a value and holds no state of its own.
Annot :: struct {
// The annotation dictionary itself, for the many per-subtype entries this
// library does not interpret (/Contents, /T, /Dest, /FT, /V ...).
dict: ^Dict,
// Object number, or {0,0} when /Annots held the dictionary directly.
ref: Ref,
// /Subtype verbatim: "Link", "Widget", "Stamp", "Popup", ... Empty when
// the file omitted it, which is malformed and does happen.
subtype: string,
// /Rect, with its corners sorted: the format allows either order and
// producers use both.
rect: Rect,
flags: Annot_Flags,
// The appearance stream to draw, after /AS has chosen among the states of
// an /AP /N sub-dictionary. nil when the annotation has no appearance, and
// then nothing is drawn -- see the note at the top of this file.
ap: ^Stream,
// The appearance state /AS asked for, or the sole state of a /N
// sub-dictionary that had only one. Empty when /N was a stream outright.
// It is set even when the state named nothing and `ap` is therefore nil,
// which is the ordinary way an unticked checkbox is written.
state: string,
// False when this annotation is not part of the page's visible content:
// /F hides it, or it is a popup. It says nothing about /OC, which is
// resolved when the appearance runs -- a text-extraction pass deliberately
// draws layers a renderer would leave switched off, and that choice is
// `Content_Options.ignore_optional_content`, made per run and not here.
visible: bool,
}
// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
// annot_load reads a page's /Annots.
//
// Every entry that resolves to a dictionary comes back, including the ones
// that will not be drawn: a viewer needs a /Link's rectangle to make it
// clickable and a /Widget's dictionary to fill it in, whether or not either
// has an appearance. `visible` and `ap` say what `annot_run` will do with it.
//
// The slice is the caller's, allocated with `allocator`, and is released with
// one `delete`; everything the Annots point at is Document memory and must not
// be freed. An error comes back only for a nil argument -- a page with no
// /Annots, or with an /Annots that is not an array, has no annotations, which
// is not a failure.
annot_load :: proc(doc: ^Document, page: ^Page, allocator := context.allocator) -> ([]Annot, Error) {
if doc == nil || page == nil || page.dict == nil {
return nil, .Invalid_Argument
}
arr := dict_array(doc, page.dict, "Annots")
n := min(array_len(arr), MAX_ANNOTS)
if n == 0 {
return nil, .None
}
list := make([dynamic]Annot, 0, min(n, 64), allocator)
defer delete(list)
for i in 0 ..< n {
raw := array_get_raw(arr, i)
d, ok := to_dict(resolve(doc, raw))
if !ok {
// A null entry, or a reference to an object that is gone, is how
// a producer deletes an annotation without rewriting the array.
continue
}
a := annot_read(doc, d)
a.ref, _ = to_ref(raw)
append(&list, a)
}
// Handed back at its exact length: a caller frees this with `delete`, and
// freeing a slice that was sized as a dynamic array's capacity is a
// mismatch for every allocator that checks.
out := make([]Annot, len(list), allocator)
copy(out, list[:])
return out, .None
}
@(private)
annot_read :: proc(doc: ^Document, d: ^Dict) -> (a: Annot) {
a.dict = d
a.subtype = dict_name(doc, d, "Subtype")
// object_to_rect sorts the corners and rejects a non-finite one, so
// everything downstream can subtract without checking.
a.rect = dict_rect(doc, d, "Rect")
a.flags = annot_flags_read(dict_int(doc, d, "F", 0))
a.ap, a.state = annot_appearance(doc, d)
// 12.5.3: bit 2 Hidden means do not render it anywhere. Bit 6 NoView means
// do not render it on a screen -- and this library renders for viewing, so
// it is honoured the same way; a consumer producing a print stream is the
// one that would want the opposite and would have to say so.
//
// Bit 1 Invisible does NOT suppress an appearance stream. It applies only
// to an annotation whose subtype the reader has no handler for, and it
// says "then draw nothing rather than a placeholder"; an annotation with
// an /AP is handled by definition (12.5.3, table 167).
a.visible = !(.Hidden in a.flags) && !(.No_View in a.flags)
// A popup is the window a markup annotation opens when its parent is
// clicked. It is never part of the page's own appearance, even though it
// sits in /Annots and carries a /Rect (12.5.6.14).
if a.subtype == "Popup" {
a.visible = false
}
// /NoZoom and /NoRotate ask for an appearance that ignores the viewer's
// zoom and the page's /Rotate. Both are properties of an interactive
// view, not of the page: honouring them needs the zoom factor teased back
// out of the CTM, and a static render has no zoom to be independent of.
return
}
// annot_appearance picks the stream to draw out of /AP.
//
// Only /N is page content. /R (rollover) and /D (down) are responses to a
// pointer that is not there and never appear in a rendering of the page
// (12.5.5).
@(private)
annot_appearance :: proc(doc: ^Document, d: ^Dict) -> (^Stream, string) {
n := dict_get(doc, dict_dict(doc, d, "AP"), "N")
if stm, ok := to_stream(n); ok {
return stm, ""
}
// A sub-dictionary instead of a stream is keyed by appearance state: it is
// how a checkbox carries both its /Off and its /Yes look in one place and
// /AS says which of them it is showing.
sub, is_dict := to_dict(n)
if !is_dict {
return nil, ""
}
if state := dict_name(doc, d, "AS"); state != "" {
// A state that names nothing draws nothing. A checkbox whose /N holds
// only /Yes and whose /AS is /Off is unchecked, and the absent entry
// IS its appearance; falling back to the only stream present would
// tick every empty box on the page.
stm, ok := to_stream(dict_get(doc, sub, state))
return ok ? stm : nil, state
}
// /AS is required as soon as /N is a sub-dictionary and is nonetheless
// left out. With exactly one state there is nothing to disambiguate and
// every viewer draws it; with more there is no way to choose, and drawing
// the wrong state is worse than drawing none.
if len(sub^) == 1 {
for k, v in sub^ {
if stm, ok := to_stream(resolve(doc, v)); ok {
return stm, k
}
}
}
if len(sub^) > 1 {
add_repair(doc, .Bad_Resource, 0)
}
return nil, ""
}
// ---------------------------------------------------------------------------
// Placement -- ISO 32000-2, 12.5.5, "Algorithm: appearance streams"
// ---------------------------------------------------------------------------
// annot_matrix maps an appearance stream onto its annotation's rectangle.
//
// The algorithm, exactly as the clause gives it:
//
// 1. Map the appearance's /BBox through its /Matrix -- transform its four
// corners.
// 2. Take the bounding box of those four transformed points. For a /Matrix
// that rotates or skews this is bigger than the shape inside it, and
// that is the whole reason the step exists.
// 3. Compute A: the matrix that maps that box onto the annotation's /Rect.
// 4. The appearance is drawn under /Matrix * A * the page's CTM.
//
// `form` is that final matrix -- the one the appearance's own coordinates are
// drawn under, and what a caller asking where an annotation landed wants.
// `invoke` is the same thing without the form's own /Matrix (A * page_ctm),
// because the interpreter applies /Matrix itself when it runs the stream and
// applying it here as well would apply it twice.
//
// `ok` is false when there is nothing to place: an annotation with no area, or
// a transformed appearance box with no width or no height. The scale factor of
// step 3 would be a division by zero, and a zero-area appearance has no
// picture in it to stretch.
@(private)
annot_matrix :: proc(doc: ^Document, a: ^Annot, page_ctm: Matrix) -> (form, invoke: Matrix, ok: bool) {
if doc == nil || a == nil || a.ap == nil {
return MATRIX_IDENTITY, MATRIX_IDENTITY, false
}
if rect_is_empty(a.rect) {
// A missing /Rect reads as the empty rectangle, and an annotation with
// no area shows nothing however its appearance is scaled.
return MATRIX_IDENTITY, MATRIX_IDENTITY, false
}
// A /Matrix that is short, non-numeric or infinite arrives as the identity:
// object_to_matrix is the finiteness boundary for a matrix out of a file,
// and the identity is both the format's default and the only safe guess.
mtx := dict_matrix(doc, a.ap.dict, "Matrix")
place := MATRIX_IDENTITY
if bbox, has_bbox := object_to_rect(doc, dict_get(doc, a.ap.dict, "BBox")); has_bbox {
// Steps 1 and 2. transform_rect maps the four corners and returns
// their bounding box, which is precisely what the two steps ask for.
tb := transform_rect(mtx, bbox)
tw := rect_width(tb)
th := rect_height(tb)
if !(tw > 0) || !(th > 0) {
return MATRIX_IDENTITY, MATRIX_IDENTITY, false
}
// Step 3. A maps tb onto /Rect: scale each axis by the ratio of the
// sides, then translate tb's low corner onto /Rect's.
sx := rect_width(a.rect) / tw
sy := rect_height(a.rect) / th
place = Matrix{sx, 0, 0, sy, a.rect.x0 - tb.x0 * sx, a.rect.y0 - tb.y0 * sy}
} else {
// /BBox is required of every form XObject and there is nothing to map
// onto the /Rect without it. Drawing the appearance where its own
// coordinates put it is what content.odin does with the same damage,
// and it shows something that is at least in the right units.
add_repair(doc, .Bad_Resource, 0)
}
// Step 4.
invoke = matrix_mul(place, page_ctm)
form = matrix_mul(mtx, invoke)
if !matrix_is_finite(form) || !matrix_is_finite(invoke) {
return MATRIX_IDENTITY, MATRIX_IDENTITY, false
}
return form, invoke, true
}
// ---------------------------------------------------------------------------
// Drawing
// ---------------------------------------------------------------------------
// The synthesised content stream that invokes the appearance, and the names it
// uses. The names are looked up only in the dictionary built below, so they
// cannot collide with anything in the file.
@(private = "file")
ANNOT_INVOKE :: "/Ap Do"
@(private = "file")
ANNOT_INVOKE_OC :: "/OC /Oc BDC /Ap Do EMC"
@(private = "file")
ANNOT_AP_NAME :: "Ap"
@(private = "file")
ANNOT_OC_NAME :: "Oc"
// annot_run draws one annotation's appearance stream.
//
// `page_ctm` is the same matrix the page's own content stream was run with:
// annotations live in default user space, so the page flip, /Rotate and zoom
// apply to them unchanged.
//
// An annotation with no appearance, or one /F or its subtype hides, draws
// nothing and is not an error -- most pages are mostly those. `opts` is passed
// through to the interpreter, which is how a caller shares one scratch arena
// across a page and how a text-extraction pass asks for switched-off layers.
annot_run :: proc(doc: ^Document, a: ^Annot, dev: ^Device, page_ctm: Matrix,
opts := Content_Options{}) -> Error {
if doc == nil || a == nil {
return .Invalid_Argument
}
if !matrix_is_finite(page_ctm) {
return .Invalid_Argument
}
if a.ap == nil || !a.visible {
return .None
}
// `form` is the complete appearance matrix; the interpreter arrives at it
// itself by multiplying the form's /Matrix onto the CTM it is invoked
// with, which is why it is `invoke` that is handed over.
_, invoke, ok := annot_matrix(doc, a, page_ctm)
if !ok {
return .None
}
o := opts
arena: virtual.Arena
if o.allocator.procedure == nil {
// Same contract as run_content: the run frees nothing individually, so
// its allocator has to be one that reclaims in bulk.
if arena_err := virtual.arena_init_growing(&arena); arena_err != nil {
return .Out_Of_Memory
}
o.allocator = virtual.arena_allocator(&arena)
}
defer if opts.allocator.procedure == nil {
virtual.arena_destroy(&arena)
}
// The appearance is a form XObject, so it is placed the way every form is
// placed: by invoking it with `Do` from a stream whose CTM is the
// placement matrix.
//
// The dictionary this is looked up in holds nothing but the appearance
// itself. An appearance stream that has no /Resources of its own therefore
// resolves no names at all, and that is right twice over: an annotation is
// not page content and inherits nothing from the page (12.5.5), and a
// dictionary that dies with this call must not be reachable from anything
// that outlives it -- a tiling pattern found through the invoking
// resources keeps them on the Document (pattern.odin), which would leave a
// dangling pointer behind every annotation.
res := new_dict(o.allocator, 2)
xobj := new_dict(o.allocator, 1)
dict_set(xobj, ANNOT_AP_NAME, a.ap)
dict_set(res, "XObject", xobj)
// An annotation may carry its own optional-content membership (12.5.2).
// Wrapping the invocation in a marked-content section hands it to the code
// that already evaluates a page's layers, OCMD policies and all, instead
// of growing a second copy of it here.
src := ANNOT_INVOKE
if oc := dict_get_raw(a.dict, "OC"); oc != nil {
props := new_dict(o.allocator, 1)
dict_set(props, ANNOT_OC_NAME, oc)
dict_set(res, "Properties", props)
src = ANNOT_INVOKE_OC
}
return run_content(doc, dev, transmute([]byte)src, res, invoke, o)
}
// annot_run_page draws every drawable annotation of a page, in /Annots order,
// which is the order they paint over one another in.
//
// This is the whole of what a renderer has to call: `render_page` runs the
// page's content stream and then this, with the same CTM.
annot_run_page :: proc(doc: ^Document, page: ^Page, dev: ^Device, page_ctm: Matrix,
opts := Content_Options{}) -> Error {
if doc == nil || page == nil {
return .Invalid_Argument
}
if !matrix_is_finite(page_ctm) {
return .Invalid_Argument
}
// The annotation list is scratch. When the caller supplied an allocator it
// is theirs -- a per-page arena reclaimed in one call -- and freeing out of
// it here would be at best a no-op and at worst a mismatch.
alloc := opts.allocator.procedure != nil ? opts.allocator : context.allocator
annots, err := annot_load(doc, page, alloc)
if err != .None {
return err
}
defer if opts.allocator.procedure == nil {
delete(annots, alloc)
}
n := 0
for &a in annots {
if a.visible && a.ap != nil {
n += 1
}
}
if n == 0 {
return .None
}
// The page's annotations share the token budget the page itself gets. A
// file with five hundred annotations all naming one enormous appearance
// stream is otherwise five hundred times the work of the page it is on,
// and it costs three lines to write.
o := opts
budget := opts.max_ops > 0 ? opts.max_ops : DEFAULT_CONTENT_FUEL
o.max_ops = max(budget / n, 1)
for &a in annots {
// One annotation cannot fail the page. The only error `annot_run` can
// return from here is a scratch arena it could not reserve, and the
// next annotation may well get one.
annot_run(doc, &a, dev, page_ctm, o)
}
return .None
}