Files
pdf-odin-llm/docs/render-layer-contract.md
T
2026-09-25 23:32:29 +02:00

8.0 KiB

Frozen types for the rendering layer

These are shared across many files, so they are fixed here rather than invented by whoever writes the first one. Add fields if you need them; do not rename or restructure without saying so.

Rasteriser (raster.odin)

Rasterising is separated from compositing by a span callback, so the same code fills a bitmap, builds a clip mask, and feeds a display list.

// Coverage for one horizontal run on scanline y, starting at pixel x0.
// Values are 0..255. The slice is only valid for the duration of the call.
Span_Proc :: #type proc(user: rawptr, y: int, x0: int, coverage: []u8)

Raster_Clip :: struct { x0, y0, x1, y1: int }   // device pixels, half-open

raster_fill :: proc(fp:       ^Flat_Path,
                    even_odd: bool,
                    clip:     Raster_Clip,
                    span:     Span_Proc,
                    user:     rawptr,
                    allocator := context.allocator) -> Error

Requirements: exact analytic coverage horizontally; at least 15 sub-scanlines vertically (or an equivalent cell/area accumulator). Both fill rules. Never allocate per scanline. Bounded by clip — a path 10^9 units tall must cost the same as one that fills the clip. Degenerate input (no points, one point, all points collinear, NaN already filtered by path.odin) must produce no spans rather than misbehaving.

Stroking (stroke.odin)

// Converts a stroke into a fill. Output is a Flat_Path in DEVICE space,
// already flattened, to be filled with the nonzero rule.
stroke_flatten :: proc(out:   ^Flat_Path,
                       path:  ^Path,
                       state: ^Stroke_State,
                       ctm:   Matrix,
                       flatness := FLATNESS) -> Error

A zero or sub-pixel line width must still draw a hairline exactly one pixel wide — that is what 0 w means (ISO 32000-2, 8.4.3.2) and thin table rules are everywhere. Dashes are applied along the flattened path in user space, then transformed. Caps apply to the ends of each dash. A degenerate subpath (a single point) draws a dot under round or square caps and nothing under butt caps.

Colour (colorspace.odin)

Colorspace_Kind :: enum {
    Device_Gray, Device_RGB, Device_CMYK,
    Cal_Gray, Cal_RGB, Lab, ICC_Based,
    Indexed, Separation, Device_N, Pattern,
}

Colorspace :: struct {
    kind: Colorspace_Kind,
    n:    int,              // components a colour value in this space has
    name: string,

    base:   ^Colorspace,    // Indexed
    lookup: []byte,
    hival:  int,

    alt:            ^Colorspace,   // Separation / DeviceN / ICCBased
    tint:           ^Function,
    colorant_names: []string,
    is_none:        bool,          // /None paints nothing at all
    is_all:         bool,

    white_point: [3]f64,           // CalGray / CalRGB / Lab
    black_point: [3]f64,
    gamma:       [3]f64,
    mtx:         [9]f64,
    range:       [4]f64,

    under: ^Colorspace,            // Pattern's underlying space
}

colorspace_load :: proc(doc: ^Document, obj: Object, res: ^Dict) -> (^Colorspace, Error)
colorspace_to_rgb :: proc(cs: ^Colorspace, c: Color) -> [3]f64
colorspace_default_color  :: proc(cs: ^Colorspace) -> Color
colorspace_default_decode :: proc(cs: ^Colorspace, bpc: int, allocator := context.allocator) -> []f64
colorspace_device_gray :: proc(doc: ^Document) -> ^Colorspace   // cached singletons
colorspace_device_rgb  :: proc(doc: ^Document) -> ^Colorspace
colorspace_device_cmyk :: proc(doc: ^Document) -> ^Colorspace

ICCBased is rendered through its /Alternate, or through the device space implied by /N when there is none. That is not colour management and is not pretending to be; it is what every non-colour-managed viewer does. Loaded spaces are cached on the Document keyed by the object they came from, because a page that names the same space in 400 XObjects must not build it 400 times.

Functions (function.odin)

// `kind` is an enum whose values ARE the format's type numbers, so a switch on
// it is exhaustive and `FunctionType 1` cannot be smuggled through as an int.
Function_Kind :: enum u8 { Sampled = 0, Exponential = 2, Stitching = 3, PostScript = 4 }

Function :: struct {
    kind:   Function_Kind,
    domain: []f64,
    range:  []f64,      // required for 0 and 4
    n_in, n_out: int,
    ...
}
function_load :: proc(doc: ^Document, obj: Object) -> (^Function, Error)
function_eval :: proc(fn: ^Function, in_: []f64, out: []f64)

Type 4 is a real PostScript calculator: implement the whole operator set with a bounded operand stack and a bounded execution step count. Inputs are clamped to /Domain and outputs to /Range before returning — the format requires it and a shading with an out-of-range tint otherwise paints garbage.

Images (image.odin)

Image :: struct {
    width, height: int,
    bpc:           int,
    cs:            ^Colorspace,
    is_mask:       bool,      // /ImageMask: a 1-bit stencil, painted in fill colour
    interpolate:   bool,
    decode:        []f64,
    smask:         ^Image,    // /SMask, alpha
    mask:          ^Image,    // /Mask as a stencil
    color_key:     []int,     // /Mask as a colour-key range
    // Decoded on demand, cached: straight-alpha RGBA8 unless is_mask.
    rgba:          Bitmap,
    decoded:       bool,
}

image_load    :: proc(doc: ^Document, stm: ^Stream, res: ^Dict) -> (^Image, Error)
image_decode  :: proc(doc: ^Document, img: ^Image) -> (^Bitmap, Error)  // cached on img
image_load_inline :: proc(doc: ^Document, d: ^Dict, data: []byte, res: ^Dict) -> (^Image, Error)

Decoded images are cached on the Document keyed by their stream, and the cache has a byte budget — a 300-page catalogue must not hold every photograph at once.

Fonts (font.odin and friends)

Each font-program reader is self-contained and knows nothing about PDF:

// font_cff.odin
cff_parse        :: proc(data: []byte, allocator := context.allocator) -> (^Cff_Font, Error)
cff_glyph_count  :: proc(f: ^Cff_Font) -> int
cff_glyph_path   :: proc(f: ^Cff_Font, gid: u16, out: ^Path) -> Error   // glyph space
cff_glyph_advance:: proc(f: ^Cff_Font, gid: u16) -> f64
cff_gid_for_name :: proc(f: ^Cff_Font, name: string) -> (u16, bool)
cff_gid_for_cid  :: proc(f: ^Cff_Font, cid: u16) -> (u16, bool)
cff_font_matrix  :: proc(f: ^Cff_Font) -> Matrix

// font_truetype.odin -- same shape, plus cmap lookup
tt_parse, tt_glyph_count, tt_glyph_path, tt_glyph_advance,
tt_gid_for_unicode, tt_gid_for_mac_code, tt_gid_for_name, tt_units_per_em

// font_type1.odin
t1_parse, t1_glyph_count, t1_glyph_path, t1_glyph_advance, t1_gid_for_name,
t1_builtin_encoding

font.odin ties them to the PDF font dictionary:

Font_Code :: struct {
    code:     u32,   // raw character code from the string
    cid:      u32,
    gid:      u32,
    width:    f64,   // glyph space (1/1000 text space for everything but Type3)
    unicode:  rune,  // -1 if unknown
    nbytes:   int,   // bytes of the string this code consumed
    is_space: bool,  // single-byte code 32 -- word spacing applies
}

font_load       :: proc(doc: ^Document, d: ^Dict) -> (^Font, Error)   // cached on doc
font_decode     :: proc(f: ^Font, s: []byte, out: ^[dynamic]Font_Code)
font_glyph_path :: proc(f: ^Font, gid: u32, out: ^Path) -> Error
font_matrix     :: proc(f: ^Font) -> Matrix

A font that fails to load must still produce a Font with widths, so text lays out and extracts correctly even when nothing can be drawn. Missing glyphs draw nothing; they never abort a page.

Public API (page.odin)

Render_Options :: struct {
    ctm:            Matrix,     // applied on top of the page flip and /Rotate
    background:     [4]u8,      // default opaque white
    alpha:          bool,       // default false: an opaque RGB(A) page
    no_annotations: bool,
    allocator:      mem.Allocator,
}

render_page  :: proc(page: ^Page, opts := Render_Options{}) -> (Bitmap, Error)
run_page     :: proc(page: ^Page, dev: ^Device, ctm: Matrix) -> Error
extract_text :: proc(page: ^Page, allocator := context.allocator) -> (string, Error)