PageWright Design Notes
The engine's subject-by-subject design record.
Version 1.0 · October 2026 · 117 pages
# PageWright™ - Design Notes
The engine's subject-by-subject design documents, bound into one volume and laid out by the engine itself. Each chapter is one of the design documents the engine is built to, telling what it does and why.
For authoring guidance see the Users Guide; for every key and its type, the JSON Spec Language Reference; for architecture and internals, the Developer Manual.
# Inline runs: styled text inside a paragraph
A block of text holds a runs array, and each run is one stretch of characters plus the inline properties that apply to it. That is how a paragraph carries a bold word, a link, a footnote marker, a superscript, a language span or an inline equation without being broken into separate blocks.
runs is accepted by paragraph, heading, callout, a list item, a table cell, a caption written in run form, and a running header/footer slot. One decoder reads them all (ParseOneRun), so a run behaves identically wherever it appears.
A run is either a bare string or an object:
{ "type": "paragraph", "runs": [
"Plain text, then ",
{ "text": "a bold word", "style": { "weight": "bold" } },
", then a ",
{ "text": "link", "link": "https://appligent.com" },
"." ] }runs may also be a single run object rather than an array, which is convenient when a block has exactly one.
The cascade
A run's style resolves over its block's, which resolves over the document's defaultStyle. Text properties inherit; box properties belong to the block that sets them. A run that names nothing keeps everything it was given.
The style on a run can be an inline object, or the name of an entry in the document's styles table - which is what a character format is here. basedOn derives one entry from another, and an object may carry basedOn plus its own overrides:
{ "styles": {
"Emphasis": { "italic": true, "color": "#a4133c" },
"Strong": { "basedOn": "Emphasis", "italic": false, "weight": "bold" } },
"sections": [ { "blocks": [
{ "type": "paragraph", "runs": [
{ "text": "named", "style": "Emphasis" },
{ "text": "named + override", "style": { "basedOn": "Emphasis", "fontSize": 14 } } ] } ] } ] }What a run's style changes
| property | effect |
| fontFamily, fontSize | any face or size mid-line, the run's word spaces included; each face is embedded and subset. A larger size opens the line it is on, and no other: that line steps the block's lineHeight times its largest size |
| weight / bold, italic | the family's bold and italic faces |
| color | the fill; "none" leaves hollow letters that show only the stroke |
| underline | true, "single", "double", "numeric" |
| strike / strikethrough, overline | rules in the text color |
| smallCaps | synthetic small capitals; the real text survives in /ActualText |
| letterSpacing / tracking | extra points between glyphs |
| stretch | horizontal glyph scale as a percent (the PDF Tz operator) |
| features | OpenType: liga, dlig, onum, lnum, tnum, pnum, smcp |
| strokeColor + strokeWidth | an outline on the letterforms |
| textEffect | shadow, lift, echo, glow, neon, background |
| textFill | paint the glyphs with a gradient or an image, still as text |
features needs a face that actually defines them: smcp on a face without true small capitals changes nothing, while smallCaps is synthesized and always available.
What a run's style does NOT change
A run accepts the whole style object, so box properties can be written on a run and are silently ignored: background, padding, border, radius, stripe, accentBar, shadow, minHeight, align, lineHeight, firstLineIndent, tabs, spaceBefore, spaceAfter, fitWidth. They describe a box, and a run is not a box.
Two text properties are also block-level only, however inline they read: wordSpacing and direction. Set them on the block.
To highlight words, use "textEffect": "background", not "background". The second is the box property above and does nothing on a run - the likeliest mistake on this page.
Inline properties on the run itself
| key | what it does |
| text | the characters this run contributes |
| style | the style above: an object, or a name from styles |
| superscript, subscript | raise or drop the run. Neither resizes it - set style.fontSize too |
| baselineShift | points to move the run off its baseline, + up and down, ADDED to where superscript or subscript puts it (or on its own). Moves the ink only: the line keeps its height |
| link | an external URL, or #anchor for an internal jump. linkStyle at document level makes links look clickable |
| ref + as | a live cross-reference to any block's id: page, number, label, title, full |
| refTemplate | custom wording over {number} {word} {label} {title} {page} |
| footnote | a raised marker here, the note at the page foot - on every form of heading, run-in and curved included; not in a caption, which has no page-foot room, nor in vertical (直排) text, which has none along its columns (the engine says so, and the footnote takes no number) |
| endnote | a marker here, the note gathered into the next endnotes block - on every form of heading, a vertical one too; in a caption's runs too, numbered where the caption stands, when the caption is drawn (a footnote or sidenote there is not drawn, and the engine says so) |
| sidenote | a margin note in the reserved side band - no marker. Needs pageSetup.sideHeadWidth. Not in a caption, nor beside vertical text (the engine says so) |
| cite + citePages | a citation into the document's bibliography, numbered by first use: the run's own text is never drawn, the marker is - on every form of heading, and in a caption's runs too, where the caption stands |
| lang | a language span, which also chooses the hyphenation rules for this run |
| condition | conditional text at run level: dropped, or tinted under conditionIndicators |
| mathml, mathSvg, mathAlt | an inline equation, drawn on the baseline |
| image or svg (+ alt, width, height) | an inline graphic on the baseline: an icon, a logo, a signature |
| badge + badgeStyle | an inline pill: badge is the label text, badgeStyle the box |
| field (+ name, value, options, tooltip, width, height) | a live AcroForm widget in running text |
A ref renders the target's title whether or not the document numbers anything, and full names it either way - "Figure 3-2 on page 47" where there is a number, "Centre of Pressure on page 47" where there is not. number and label do need a number and degrade to the target's page without one. Only a target with neither a number nor a title - a rule carrying an id - falls all the way back to its page, because there is nothing to name it by (FormatRefText).
What the tags become
Tagged output is not an afterthought of these features; each one produces structure.
| feature | structure |
| lang | <Span> carrying /Lang, one per contiguous stretch per line - on an inline graphic or equation, /Lang on its own <Figure> / <Formula> instead: the language of the /Alt the run gave it (none when the engine supplies "graphic" / "mathematical equation"). A run that declares none reads as its paragraph, and the paragraph as the lang box around it; a <Link> or small-caps <Span> inside a run's <Span> reads as that <Span> |
| link (URL) | <Link> + a /URI annotation, with /OBJR |
| link (#anchor), ref | <Link> + a GoTo destination |
| footnote, sidenote, endnote body | <Note> (the footnote's /ID satisfies PDF/UA 7.9), read in the language of the box around its reference, else the document's - never the paragraph's or run's own lang |
| endnote and cite markers | <Link> forward to the note or entry, and back again |
| mathml / mathSvg | <Formula> with /Alt, and the MathML as a /Supplement associated file |
| image / svg | <Figure> with /Alt - the placeholder "graphic" when the author gave none, and a warning |
| field | <Form> and the widget annotation |
| smallCaps | <Span> with /ActualText restoring the real lowercase text |
| badge | the pill's fill is an Artifact; the label is ordinary text |
Two deliberate absences: the footnote marker in body text carries no tag and no link (the note body does), and cross-references and citations get no <Reference> wrapper, even under PDF/UA-2.
Traps
- A block written with text: instead of runs: carries no run keys at all. link, footnote, endnote, sidenote, cite and citePages belong on a run; written on the block itself - in either spelling - they are ignored, and the engine says so on stderr whatever the schema mode: "footnote" belongs on a run, not on the block. To mark a whole paragraph, give it one run: "runs": [ { "text": "...", "footnote": "..." } ]. Until 2026-09-17 the text: form read those five off the block while runs: did not, so the same paragraph kept its footnote spelled one way and lost it spelled the other - and this page described the first as intended. The schema never allowed either.
- An inline graphic is sized from the TEXT, not from itself. Unsized, it is one em tall and takes its width from the source's own aspect, so a 512-pixel logo does not open a 512-point line. Give height to make it bigger - the line grows to hold it - and give both width and height only when you mean a box of another shape. An image is stretched into that box; an svg keeps its viewBox's shape inside it, centred (preserveAspectRatio, default xMidYMid meet), unless its root <svg> says preserveAspectRatio="none" - until 2026-10-10 an svg was stretched too.
- An inline graphic needs alt. It draws no glyphs, so /Alt is the only thing a screen reader can be given for it, and unlike an equation there is no MathML to fall back on. Without one the engine tags the placeholder "graphic", says so on stderr, and -wcag reports it under 1.1.1.
- svg beside mathml or mathSvg is the EQUATION's SVG, not a second graphic. The math keys claim the run; svg and content are their aliases there, and mean a picture only when no math key is present. One run is one atom.
- A space belongs to the run it is typed in. A space inside a 44 pt run is a 44 pt space; a space ending a run is that run's and one opening the next run is the next run's; of a stretch of spaces across two runs the first is kept, which is CSS's rule for collapsible white space. The run's face and letterSpacing size its spaces as they size its letters; wordSpacing stays the block's. Until 2026-10-21 every word space was the PARAGRAPH's: a title set as one 44 pt run in a paragraph that named no size was drawn with 11 pt spaces and read "AppligentLicensing", and a line was broken and justified with those spaces too. A no-break space (U+00A0) inside the run was the workaround; it still works, since it is a glyph of the word, drawn in the run's own face.
- A run larger than its paragraph opens its own line, and only that line - since 2026-10-21. Each line steps the block's lineHeight times the largest size on it (never less than the block's own leading), the rule CSS gives every inline box and InDesign's auto leading uses; an equation or an inline graphic opens its own line the same way. Before, a block had ONE pitch, its leading grown to its tallest line's glyphs: a 44 pt title written as runs in a paragraph with no size of its own was set solid (40.7 pt a line where the same words as text step 52.8), one large word or equation spaced every line of its paragraph, and beside a float, in a threaded frame, after a run-in heading or around a drop cap the lines overprinted. The factor is still the BLOCK's - a run's own lineHeight changes nothing (above) - so a run set only a little larger than its text, inline code say, opens the lines it falls on: set it no larger than the text to keep them even.
- Captions, contents rows, running heads and run-in headings follow the same rule - since 2026-10-22. They set lines of their own and kept stepping a fixed advance after 2026-10-21, so a larger run printed over its neighbours there. Now a caption written as runs steps 1.2 (its own factor, whatever its block's lineHeight) times the largest size on each line, and the band under the figure is as tall as its lines; a contents row (toc, lof, lot, lol, loc) whose heading or caption sets a run larger than the list steps the list's lineHeight times that size, its text set from the top of the room it opened, its leader and page number on the same baseline - a row no larger run opens keeps the list's baseline exactly; a running header or footer reserves one line as tall as its tallest slot's, pinned to the band's outer edge, and a slot's lines step by their own sizes; a run-in heading and the first line it shares count as ONE line of the paragraph, whose lineHeight times the heading's size opens it (a heading no larger than its body leaves it as it was). The factor is the PARAGRAPH's, not the heading's own lineHeight: a run-in heading sets exactly as the same words written as a larger run would, and a heading's lineHeight keeps spacing only its own lines when it is stacked. Before: a 30 pt word in a caption put the next line above its own baseline and the block after the figure printed over it; a heading with a 30 pt run became a 13.2 pt contents row whose glyphs covered the rows above and below; a 24 pt run in a 9 pt header reserved 10.8 pt and the body printed under its glyphs; a 40 pt run-in heading hung its descenders into the second line. A band reserves what its runs' sizes ask, not an inline graphic in it, which is measured only when drawn. Where floats beside a run-in heading leave the body's first line no room, that line is set below them, from the foot of the line the heading opened, and steps its own pitch - the heading's line is not its line (until 2026-10-24 it took the heading's step too); moved down only by a float that ends inside the heading's line, it is still beside the heading and steps to that line's foot, so the line after it starts below the heading at full width. A drop cap's initial is sized for N of the paragraph's own advances, so a larger run that opens line N or a line above it leaves the initial on its band's own baseline; and a drop cap whose Nth line is too deep for the room left - opened by a larger run - defers whole, as one whose N advances do not fit.
- An unknown key costs the words. runs is validated with unevaluatedProperties: false, so a misspelled key makes the whole run invalid rather than merely unstyled.
- subscript is ignored when superscript is also set.
- baselineShift moves ink, not the line (2026-11-03). A superscript stands a fixed 34% of the line's ascent up and a subscript 20% down (thePieceDy), which left a small ™ at 45% of a 64 pt title 8 pt under the cap height with no way to lift it. The shift is added in that one function, so the page and the dy an editor redraws from cannot disagree; a superscript or subscript piece now always writes its dy, because a shift can bring it to 0 and a reader that finds none raises it by its own rule. The line box is not grown for it, as in a page-layout program: a large shift can reach the line above.
- A run-in, curved or vertical heading carries its notes since 2026-10-14 - before that it carried none. Each drew its runs' text and nothing else: a footnote there took a number and was never drawn (the next note in the body printed "2." with no note 1), a citation drew the run's own text instead of [n], an endnote had no marker to link back to, a margin note vanished - silently. In vertical text a footnote or sidenote is still not drawn: a 直排 column has no page foot and no margin band. The engine says so, naming the block, and the footnote takes no number (a story poured into a positioned frame on a vertical section is vertical text too); put the note in horizontal text, or make it an endnote, which a column draws.
- A run-in heading is drawn as a stacked heading's line since 2026-10-14 - style, index and glossary included. Its smallCaps and letterSpacing are drawn (the per-run draw ignored them), blanks collapse and a trailing one is dropped, an index term in its words is indexed and a glossary term's first use there takes the auto-link. A run-in heading before a curved or a vertical paragraph is stacked instead - those draw no heading inline (until then it was drawn late, on the next plain paragraph's line, or lost).
- runIn on a heading set on a curve (textPath) or in vertical (直排) columns is ignored, and the engine says so - once per block, ending "(at /pointer)": aplayout: "runIn" is ignored on a heading set on a curve (textPath) -- ... / ... on a vertical (直排) heading -- .... The two cannot both apply: run-in sets the heading inline at the start of the next paragraph's first line, and a curve or a column is a shape of its own. The shape wins: the heading is set on its curve, or in its columns, on its own, before the paragraph - its notes and citations drawn as on any curved or vertical heading. Until 2026-10-14 a curved run-in heading was handed to the paragraph after it and drawn as one straight line, the curve gone, nothing said; so was a heading given its own vertical writingMode; one in a vertical section was already stacked in its columns, silently. (A heading's own writingMode is in the schema since 2026-10-14, with the paragraph's values: until then the schema listed writingMode on a paragraph, a section and pageSetup only, and the schema check said "writingMode" is not a key this accepts (at /pointer) of a heading's own though the engine honoured it. A heading that gives none takes its section's writingMode (or pageSetup.writingMode).) A heading set in columns in a horizontal page - that own writingMode - has its bookmark and its contents row like any heading. It takes the paragraph's boxHeight and columnGap too, with the same meaning and defaults: with a boxHeight it is bounded as a vertical paragraph is - in a table cell, a columns cell or a container its row or box is sized to it, and in a horizontal page its body follows below it; one whose columns are wider than the width left is split where it stands in a page's column or a positioned box, the heading before it keeping with its first part (2026-10-15: it moved whole to the next page, where its box comes back no wider, and stranded that heading alone on its page) - and moves whole, as before, where the next slot may be wider (a box narrowed by padding or a float, a story's frame, a vertical page); without one it fills the height it is offered, as such a paragraph does (to the page foot - and in a horizontal page its keepWithNext then moves it to a page of its own, its body to the next). Its box's edges are said, as a vertical paragraph's are: a boxHeight shorter than one character is raised to one, and columns past the width of a box that does not continue - a table or columns cell, a card, a positioned box - are not drawn, with the count of characters lost. Placed at a position with no height and no boxHeight, a vertical paragraph or heading fills the room from where its text begins down to the flow's foot - above a footnote band - or, beginning in the bottom margin or within a character of it, to the page's foot, and is never taller than the text area, as a flowing one's default box - its columns ran off the page until 2026-10-15. In a container with no height, what precedes it is drawn as before and what follows it is not drawn, said. In a cell of a positioned columns block, table or cardGrid with no height, every such text shares the block's room (a list item's in the block too): each is given the same box, the tallest with which the whole block ends at that foot - or, where its horizontal content runs past the foot however short that box is, with which every such text does - every row drawn, rows without such text as tall as before, a card grid's rows still uniform, columns past a cell's width cut and said, naming the box they are set in (2026-10-15: its rows stood a text area tall and ran off the page). Not bounded, as before: such text in a block with a position.height (a table drops its rows past it, said; a columns block's or card grid's stands a text area tall past it) or in a vertical section - give it a boxHeight there. The foot is read when the block is placed: a footnote its own cells, or a block placed after it, add on that page is not above it.
- Vertical text that continues on a later page says each part once (2026-10-14). A 直排 passage whose punctuation is drawn as vertical forms carries /ActualText, and each page's part spelled the WHOLE passage from where it began - the continuation was read twice by a screen reader, copy and search. Each part now says exactly what it draws; and the rest of a vertical heading too long for its page stays the heading (<Hn>, at its size), where it used to be set as a body <P>. What a continued vertical paragraph SAYS - its footnote or margin note not drawn, its box raised to a glyph - is said once, naming the book chapter, -merge record or include partial it came from (2026-10-15: its continuation said it again without that "(in ...)", or in a partial with no pointer at all).
# Vector graphics, images and SVG
The shape block draws vector primitives - rectangles, lines, ellipses, polygons, and arbitrary paths - filled (solid or gradient) and/or stroked, in flow or at an absolute position. The image block adds decorative effects (rounded corners, border, drop shadow). The svg block imports SVG drawings as native vector content. This covers Theme C phases C1 (shapes), C2 (paths), C3 (gradients), C4 (image effects), and C5 (SVG import).
The shape block
{ "type": "shape", "shape": "rect", "fill": "#1a4fa0", "width": 200, "height": 36 }
{ "type": "shape", "shape": "ellipse", "fill": "#f0a000", "stroke": "#7a5200", "strokeWidth": 1, "width": 150, "height": 80 }
{ "type": "shape", "shape": "polygon", "fill": "#2e8b57", "width": 130, "height": 100, "points": [[0.5, 0], [1, 1], [0, 1]] }| Field | Meaning |
| shape | rect (default), line, ellipse (or circle), polygon |
| fill | fill color (RGB / CMYK / gray / spot), or a gradient object (below); omit for no fill |
| stroke | stroke color; omit for no stroke |
| strokeWidth | stroke width in points (default 1) |
| width | flow box width (points or "50%"; default = the content width) |
| height | flow box height in points (an absolute shape uses position.h) |
| points | line / polygon vertices as [x, y] fractions 0..1 of the box |
- Box. A flow shape reserves a box width x height; an absolutely-positioned shape ("position": { "x", "y", "width", "height" }) draws in that rect. rect and ellipse use the whole box; a line runs points[0] -> points[1] (default: across the middle); a polygon connects its points and closes.
- Fractional points make a shape scale with its box: a triangle is [[0.5, 0], [1, 1], [0, 1]] (top-center, bottom-right, bottom-left) at any size.
- Color. fill / stroke accept the full color model - see "Color spaces"; a CMYK or spot shape lives happily in a print PDF/A.
Paths (shape: "path")
A path shape draws an arbitrary outline from a d (path data) plus an optional viewBox:
{ "type": "shape", "shape": "path", "fill": "#1a4fa0", "width": 220, "height": 130,
"viewBox": [0, 0, 100, 60], "d": "M5 25 L60 25 L60 10 L95 30 L60 50 L60 35 L5 35 Z" }d - the path, as either:
- an SVG path string with the full command set M/L/H/V/C/S/Q/T/A/Z (absolute or relative, with implicit repeats; arcs flatten to curves), OR
- a structured op array: [["M", x, y], ["L", x, y], ["C", x1,y1,x2,y2,x,y], ["Q", x1,y1,x,y], ["Z"]] (absolute coordinates).
- viewBox - [x, y, w, h] (or [w, h]) defines the path's coordinate space; it is stretched to fill the box. Default [0, 0, 100, 100], so round-number coordinates scale to any box size. Quadratic (Q) segments are elevated to cubics; the stroke width is a page-point value (not scaled by the viewBox).
- An open path (no Z) is stroked as a polyline; a filled path is implicitly closed.
Gradient fills (fill as an object)
Instead of a solid color, a shape's fill may be a gradient - a color ramp that the backend emits as a true PDF axial (Type 2) or radial (Type 3) shading, clipped to the shape's outline. Gradients work on every fillable shape (rect, ellipse, polygon, path); a line ignores them. A stroke, if any, is drawn over the gradient.
{ "type": "shape", "shape": "rect", "width": 360, "height": 40,
"fill": { "type": "linear", "angle": 0,
"stops": [ { "offset": 0, "color": "#1a4fa0" }, { "offset": 1, "color": "#7fb0ff" } ] } }
{ "type": "shape", "shape": "ellipse", "width": 150, "height": 90,
"fill": { "type": "radial", "center": [0.4, 0.4], "radius": 0.7,
"stops": [ { "offset": 0, "color": "#ffffff" }, { "offset": 1, "color": "#1a4fa0" } ] } }| Field | Meaning |
| type | linear (axial, default) or radial |
| angle | linear direction in degrees - 0 = left->right, 90 = top->bottom (shorthand for from/to) |
| from / to | linear axis endpoints [x, y] as box fractions 0..1 (default [0, 0.5] -> [1, 0.5]) |
| center | radial center [x, y] as box fractions (default [0.5, 0.5]) |
| radius | radial radius as a fraction of the box's longer side (default 0.5) |
| stops | two or more { "offset": 0..1, "color": <color>, "opacity": 0..1 }; each sits at its offset (below); sorted by offset |
Every stop sits at its offset along the axis (from -> to, or out from center to radius): before the first stop the ramp is the first stop's colour, after the last stop the last stop's colour, and between two neighbouring stops it runs linearly from one to the other. So stops at 0.3 and 0.7 are solid for the first 30% of the axis, ramp over the middle 40%, and are solid again from 70% on - at 25% along this bar is black, at 50% mid-gray, at 90% white:
{ "type": "shape", "shape": "rect", "width": 360, "height": 40,
"fill": { "type": "linear", "angle": 0,
"stops": [ { "offset": 0.3, "color": "#000000" }, { "offset": 0.7, "color": "#ffffff" } ] } }The same holds for a radial, for three or more stops, for spot and DeviceN ramps ("Color spaces"), for a textFill gradient and for an SVG <stop offset="30%">.
A stop may be translucent. An 8-digit "#rrggbbaa" gives the stop its own opacity, and "opacity" (0..1) on the stop multiplies it - the way to fade a cmyk or spot stop, which have no 8-digit form. "transparent" (or "none") is a stop with no ink of its own: each side fades out in its neighbouring stop's colour, the way CSS paints it, so red -> transparent -> blue is red fading out and blue fading in, never a grey band through the middle.
{ "type": "shape", "shape": "rect", "width": 360, "height": 40,
"fill": { "type": "linear", "angle": 0,
"stops": [ { "offset": 0, "color": "#e02020" }, { "offset": 1, "color": "transparent" } ] } }Between stops the colour and the opacity each ramp linearly (SVG's rule, which stop-opacity already followed); the opacity is painted as a luminosity soft mask the size of the shape's box. In PDF/A it is folded into the colour instead, keeping the file free of soft masks: a process stop is lightened toward white, a spot stop's opacity becomes its tint. A gradient with no translucent stop writes exactly what it did. Until 2026-10-01 an 8-digit stop drew opaque and "transparent" drew black.
- Stops & color space. Each stop's color is the full color model (RGB / CMYK / gray / spot - see "Color spaces"). Without a spot stop the shading is DeviceRGB, or DeviceCMYK in a CMYK-mode (colorMode: "cmyk") document, so a gradient is print- and PDF/A-safe; a ramp of one spot's tints is a /Separation on that spot's own plate, and spots mixed with each other or with process stops are /DeviceN (at most 27 spots; past that, or with a spot name PDF/A-1/2/3 cannot hold, the ramp is simulated in process colour with a notice) - see "Spot colours in gradients" in "Color spaces".
- How it is written. Stops at exactly 0 and 1 with two stops are one Type-2 exponential function; otherwise a Type-3 function stitches one Type-2 segment per pair of neighbouring stops, plus a flat segment of the first stop before it and of the last stop after it when the ends are inside the axis. The shading's axis (Coords) is always the whole from -> to axis, and Extend is on, so the end colours also fill past the axis ends. A full spectrum sweep is just a list of stops.
Edge cases, defined.
- Two stops at one offset make a hard edge: the colour jumps there. Stops at the same offset keep the order they are written in (red 0.5, blue 0.5 steps from red to blue).
- All stops at one offset is a single hard step at it, from the first stop's colour to the last's: all at 0 shows the first colour only before the axis start (where Extend paints past it) and the last colour from the start on; all at 1 shows the first colour along the whole axis and the last colour only past its end.
- Offsets are positions to 1/65536 - the precision of the function's /Bounds - and every decision is made on the written value, so an offset within 1/131072 of 1 is 1 (and of 0 is 0): red 0.999995, white 1 is a hard stop at the end, white past it.
- Out of order in JSON: sorted by offset. Out of range: clamped to 0..1 (the schema warns). An omitted offset follows the CSS rule - 0 on the first stop, 1 on the last, and a run of omitted ones spread evenly between the offsets either side - so two stops without offsets are a full 0 -> 1 ramp, as they always were.
- A zero-length axis (from equal to to) or a radius of 0 paints the last stop's colour over the whole shape (SVG's rule), rather than a shading with no defined rendering.
- A gradient needs at least two stops; with fewer, the shape falls back to no fill (the schema warns). An SVG gradient with one <stop> is a solid fill of that stop, as SVG specifies, and one with none (of its own or in a template) paints nothing - SVG's none.
A regression check holds all of this, from the written functions and from the rendered pixels, and another holds where an SVG gradient sits (see SVG import).
Image effects (image block)
An image block can carry three decorative effects, alone or combined:
{ "type": "image", "src": "photo.jpg", "alt": "...", "width": 300,
"cornerRadius": 18,
"border": { "width": 6, "color": "#f4f1ea" },
"shadow": { "dx": 4, "dy": 7, "blur": 12, "opacity": 0.45 } }| Field | Meaning |
| cornerRadius (or radius) | clip the image to a rounded rectangle of this radius (points) |
| border | a border stroked around the (rounded) edge - a width, a color, or { width, color } |
| shadow | a drop shadow behind the image - true for defaults, or { dx, dy, blur, opacity, color } |
| rotate | turn the image 0 / 90 / 180 / 270 degrees clockwise (other values snap to the nearest quarter turn) |
| flipH / flipV | mirror the image left-to-right / top-to-bottom |
| autoOrient | honor the file's own orientation (JPEG EXIF, TIFF tag 274, HEIF irot/imir) - default true |
| focus | [x, y] (or { "x": ..., "y": ... }, or one number for both axes) - where a cropping fit aims, as fractions of the upright photo; default [0.5, 0.5]. A "{{path}}" string binds it from data |
| zoom | fit: "cover" only - magnify past the plain cover crop (1 = plain cover, 2 = twice as close). A "{{path}}" string binds it from data |
| renderingIntent | this picture's own rendering intent - "perceptual", "relative", "saturation", "absolute" - in place of the document's: its conversion in cmyk mode, and its /Intent in the file ("Color spaces") |
- Rotation & flip. The image is rotated to fill its box (the box is not swapped), and the box is sized to the displayed orientation - so a landscape figure with "rotate": 90 and a plain width auto-sizes to a portrait box and fills it undistorted (the "turn a sideways scan upright" job). rotate 0 with no flip is a no-op - the exact prior placement matrix. The transform is a single content-stream cm, so nothing is re-rasterized and it composes with the clip / border / shadow above (the box carries the effects; the rotated image fills it). To turn, scale, skew or mirror the whole block rather than the picture inside it, use angle / scale / skew / mirrorH / mirrorV - see Transformed placement below, and note that flipH and mirrorH are deliberately different keys.
- The file's own orientation comes first. A phone stores a portrait photo sideways and tags it - JPEG EXIF Orientation (0x0112), TIFF tag 274, or a HEIF irot/imir - and every viewer turns it upright. So does the engine: the tag is folded into the same single cm, innermost, so the photo is stood upright first and flipH / flipV / rotate then act on it as a viewer sees it. The box is sized to the upright picture, so an orientation-6 photo with a plain width auto-sizes to a portrait box. Nothing is re-encoded: a JPEG still passes through byte-for-byte and the image XObject holds the pixels as stored. Orientation 1, a malformed tag and an untagged file draw exactly as they always did. "autoOrient": false draws the stored pixels in a stored-size box - for a spec that already stood the picture up with rotate, which would otherwise now turn it twice. (A TIFF tagged 2-4 used to be flipped by libtiff itself; under autoOrient: false it too shows its stored pixels.) Inline figures, running-band logos, the watermark, an SVG <image> and a textFill image have no such key and always honor the orientation.
- Focus and zoom - aiming a crop. fit: "cover" crops; focus says where. [x, y] are fractions of the photo as it stands upright (after its own orientation, before rotate / flipH / flipV, which carry the point along with the picture) - 0 = left / top, 1 = right / bottom. The point is brought to the centre of the box as far as the overflow allows, and clamped so the crop never pulls away from an edge: [0, 0] shows the top-left corner, [1, 1] the bottom-right, and the default [0.5, 0.5] is the centred crop, drawn with exactly the old matrix. It is a focal point, not CSS object-position: [0.3, 0.5] puts the point 30 % across the photo at the middle of the box, where object-position would line up the 30 % marks of box and picture. zoom (a number >= 1) magnifies a cover crop about the focus - the box does not change, more of the picture is cut away, and the same pixels are spread over more of the page, which images.minPpi counts (see "Print production"). focus also aims fit: "none" along an axis where the picture overflows the box. focus on contain or fill, or zoom on anything but cover, has nothing to act on: it is ignored with an aplayout: ... (at /blocks/N) warning. Only the placement matrix moves; the clip is still the box. A pdf block takes the same two keys for its placed page, with the same arithmetic ("Placing existing PDF pages").
- A crop per record. In a data-bound template, focus and zoom may be a "{{path}}" string instead of numbers, so each record aims its own photo: "focus": "{{cards.0.frontFocus}}". A value that is exactly one token takes the whole value at that path - a number, [x, y], {x, y} - and text of numbers works too ("0.3 0.4", "0.3,0.4", "[0.3, 0.4]", what a CSV field holds). A field the record lacks (or null, "", []) leaves the centred crop and plain cover, silently - the record that does not aim its crop is the common case. A value that is not numbers, or out of range, is said with the block's pointer and, under -merge, the record (... (in record 3) (at /sections/0/blocks/5)); a token in a document with no data is said too. See "Data-driven documents".
- Shadow object. dx/dy offset the shadow (points; right/down positive, default 4/4), blur is the soft-edge spread (points, default 6; 0 = a hard edge), opacity (alias alpha) is the peak opacity (default 0.35), and color is any color (default black). Since T1.6 this is the SAME shadow a shape and a style box cast - see Drop shadows below; it used to be sixteen grown rounded rects built inside the backend, which is why it could only ever be a rectangle.
- They compose and follow the radius. The clip, the border, and the shadow all use the same cornerRadius, so a rounded image gets a rounded border and a rounded shadow. The border is centered on the edge; the shadow sits behind and is covered by the (opaque) image, so only its penumbra shows - the lifted-print look.
- Transparency note. The shadow uses constant alpha (/ca), which is valid in PDF/A-2 and PDF/UA (the engine's targets) but not PDF/A-1.
Transformed placement (angle, scale, skew, mirrorH, mirrorV)
These five keys transform a whole block - text, image, container, chart, table, anything - about the centre of its position box.
| key | value | means |
| angle | degrees | turn counter-clockwise |
| scale | number, [x, y] or {x, y} | resize; a bare number scales both axes |
| skew | degrees, [x, y] or {x, y} | shear; a bare number skews x only |
| mirrorH / mirrorV | boolean | mirror left-right / top-bottom |
{ "type": "heading", "text": "Twenty-three degrees",
"position": { "x": 96, "y": 250, "width": 300, "height": 44 },
"angle": 23 }
{ "type": "container", "position": { "x": 96, "y": 330, "width": 250, "height": 84 },
"scale": [1.8, 0.7], "skew": [12, 0], "mirrorV": true,
"style": { "background": "#eef3fb", "padding": 8 },
"blocks": [ { "type": "paragraph", "text": "One matrix, both substrates." } ] }- They compose in one fixed order, and it is written down because it matters:
Note: mirror -> scale -> skew -> rotation, all about the centre of the position box.
R·S and S·R differ for a non-uniform scale, so a tool that round-trips a document through these keys must build the matrix this way - or read the composed matrix out of the layout map (below) instead of re-deriving it.
- Every positive direction is counter-clockwise as you see it. +angle turns the block anticlockwise, +skew.x leans its top to the right, +skew.y lifts its right-hand side. One convention for all three, so no key runs the other way.
- skew is clamped to ±89.9°. A 90° skew is an infinite tangent: a singular matrix, and a block that draws nothing. The clamp is silent because the authoring gesture is a slider and the last tenth of a degree is not visible.
- mirrorH is scale 1, and the two compose. mirrorH with scale.x: -1 is upright again, which is exactly what multiplying says. The boolean exists because a design tool's "flip horizontal" command must not depend on the current scale value.
- A positioned block, or a container. A block with position has a fixed box to pivot about, and reserves no flow height - the text around it does not move. A container in the flow needs no position: it becomes a group (below). Any other flow block - a bare paragraph, a heading, a table - ignores these keys with a warning; wrap it in a container to transform it.
- All five keys, and position, bind from data. "angle": "{{s.tilt}}" takes the record's number (or text of one), so each record turns its own sticker; "scale" / "skew" take its number, [x, y] or {x, y} (or text of numbers, "1.5 0.8"), and "mirrorH" / "mirrorV" its boolean (or "true" / "false", 1 / 0); position binds the same way, field by field or whole. A record without a key draws that key's identity, silently; a value that is not usable is reported and drawn at that key's identity, the rest of the transform still applied; a record without a usable x / y is reported and not drawn. A skew from data is clamped like a written one, and a flow block that is not a container refuses a bound transform as it refuses a written one. The map's transform, angle and pivot are the literal's. See "Data-driven documents", Numbers from data: a block's position, size and transform.
- [!] angle is not rotate, and mirrorH is not flipH. The image block owns rotate / flipH / flipV, which act on the picture inside its frame. The block keys act on the whole frame. They are different words so that both are expressible on one image, and they compose:
| block: angle / scale / skew / mirrorH | image: rotate / flipH / flipV | |
| value | any number of degrees / any factor | integer quarter turn; booleans |
| direction | counter-clockwise | clockwise |
| acts on | the whole block, frame and all | the picture inside its box |
| the box | unchanged | re-sized to the displayed orientation |
| pivot | centre of the position box | n/a |
So rotate stands a sideways scan upright and angle tilts the frame it sits in; flipH mirrors the photograph, mirrorH mirrors the panel it is mounted on. Writing one key for both jobs is not a shortcut - it applies BOTH, and for a mirror the two cancel, so the block renders untransformed while the map reports a flip. That is measured, not hypothetical: it happened when these keys were first spelt flipH.
Groups (container + a transform)
A container carrying any of those five keys is a group: it transforms itself and everything inside it, and it does not need a position to do it.
{ "type": "container", "angle": 12,
"style": { "background": "#eef3fb", "padding": 10 },
"blocks": [ { "type": "heading", "level": 3, "text": "Turned with its group" },
{ "type": "paragraph", "text": "So is this." } ] }- Children inherit, and transforms COMPOSE. A group at +20° holding a child at 20° draws the child upright: the child's matrix acts on its own content and the group's then carries the result. Nesting is arbitrarily deep, and a child that sets no transform of its own simply rides its ancestors'.
- A flow group reserves the bounding box of its turned self. It measures its own box, transforms about that box's centre, and charges the flow the AABB - so the text beneath clears the group's turned corners, not its upright edges. The box sits centred in the reserved band, because the pivot is the centre and the growth is symmetric.
- A group keeps itself together. It cannot split across pages - the two halves would be turned about two different centres, which is not a continuation of anything - so a group that does not fit the remaining column moves whole to the next page, whatever keepTogether says.
- The layout map reports the COMPOSED matrix for every positioned block inside a group, so a design tool sees where a child really is - an upright child included: its box is in the group's coordinates just as a turned sibling's is, so its transform is the group's matrix (two half turns that net to a move report that move). A block inside a group reports no angle/pivot summary at all: those describe a rotation about a point, and once an ancestor has moved that point they cannot reproduce the composition. See Transformed placement above.
- A group can also clip and fade its children - clip and groupOpacity, two more container keys. They need no transform and a transform needs neither of them; see Group clip and Group opacity below.
- A scale is DRAWN, not typeset. The block lays itself out upright at its natural size and the finished marks are transformed, so a scaled block does not re-wrap, re-hyphenate or re-measure at the scaled width, and its glyphs come out at the authored size times the matrix. That is what a design tool's scale handle does; changing fontSize is the different feature, and the difference shows the moment a line would have broken elsewhere.
- The pivot is the CENTRE, not the baseline origin. The engine's older text rotation (rotated table-cell labels) pivots about the baseline origin, because that is what a vertical column header needs. Authored transforms pivot about the centre, because that is what "angle": 30 means to anyone placing a block by hand. The two conventions differ by half a box - a drift that reads as an off-by-a-few-points bug rather than a convention mismatch, which is why only the centre is exposed.
- The result is NOT clipped to its box. A block lays out upright inside position and the finished marks are then transformed, so the result can extend past the box - and past the page edge, if you ask for that. This is the same freedom an untransformed positioned block already has. The transform happens after layout, so a position.height that truncates content truncates exactly the text it would have truncated upright.
- Links keep axis-aligned hit areas. A link inside a transformed block still gets a rectangular /Rect annotation covering its untransformed position, because the engine does not emit /QuadPoints. The text draws transformed; the clickable area does not follow it. Avoid links inside a block you intend to transform.
- Tagging is unaffected. The transform is written inside the marked-content run it belongs to, never around it, so a transformed block keeps its structure element and its reading order, and a regression check enforces it.
- The layout map reports the matrix. A transformed block's entry keeps its untransformed rectangle (that is the box you authored, and what an editor should show handles on) and adds transform, the composed affine in page coordinates and PDF operand order [a b c d e f]. A block that is a pure rotation also keeps the older angle + pivot pair, so a consumer written against those keeps working; a scaled, skewed or mirrored block reports only transform, because an angle alone cannot describe it and a consumer that applied one would draw the wrong picture. angle is the rigid summary of the whole matrix, not the key you wrote: turned by it about pivot, the box lands exactly where transform puts it. mirrorH + mirrorV together (and scale: [-1, -1]) are not a reflection but a half turn, so a block written at "angle": -8 with both mirrors reports 172 - the same angle and transform as a plain "angle": 172 block, which is what it draws. When the written angle already describes the matrix it is reported exactly as written (400 stays 400, -270 stays -270); a turn it does not describe is reported in (180, 180] - a half turn is equally far from the written number either way, so "nearest to what you wrote" would not pick one. Both mirrors and scale: [-1, -1] cancel and report the written angle; both mirrors at "angle": 180 are upright and report none. One mirror alone is a reflection: transform only. Per placement: each of the entry's fragments also carries its own placement's transform - the whole page matrix, never a part to be composed with an ancestor's - omitted only when that is the identity (an upright block outside any group). A pointer placed more than once - repeat clones, -merge records - is one entry, and since the transform binds from data its placements can turn differently; the entry-level keys are the first turned placement's.
- The page carries the turn exactly. The cm that turns a block writes its cos / sin to 6 decimals wherever 3 would not be exact, so the box's centre stays put to a thousandth of a point anywhere on a large sheet (3 decimals put it 0.6-0.8 pt off at the far corner of a 19 x 13 in sheet). Upright, quarter-turned, flipped and plainly scaled blocks keep their 3-decimal operands.
Container-relative coordinates (childOrigin)
A positioned block's position.x / y are page coordinates (the trim page, top-left origin) wherever it sits - a child at 10, 10 inside a container at 200, 200 draws at page 10, 10. Write "childOrigin": "container" on the container and its positioned descendants measure from its box instead, so a sticker stays on its card wherever the card goes:
{ "type": "container", "childOrigin": "container", "clip": true, "angle": 4,
"position": { "x": 200, "y": 200, "width": 288, "height": 180 },
"style": { "background": "#fbf6ea" },
"blocks": [ { "type": "image", "src": "stamp.png",
"position": { "x": 220, "y": 12, "width": 56, "height": 64 } },
{ "type": "paragraph", "text": "Greetings",
"position": { "x": 16, "y": 16, "width": 180 } } ] }The stamp draws at page 420, 212 - exactly where "x": 420, "y": 212 inside the same container would put it - and turns with the card.
- The origin is the BORDER box, the box the layout map shows: a positioned container's position rect (its fragment), a flow container's painted box where it landed - below its spaceBefore, across by boxAlign, on whatever page and column it reached (its visual rect). Not the content box inside the padding: that appears nowhere in the map, and editing the padding would move every sticker. A relative flow container that paints nothing still records its box as visual, so the corner is always readable off the map; on a page a split container continues onto, the corner is that page's part (its fragment there).
- The nearest container that WRITES the key decides. "page" resets its subtree to page coordinates; a container that writes nothing is transparent - its descendants measure from the ancestor that said "container", so a plain grouping box (for groupOpacity, say) inside a card does not change the card's coordinates. Two nested relative containers add.
- Only x and y move. Width, height, angle and the rest are unchanged; the child lays out in the container's upright box and its own transform, and the container's angle / scale / skew / mirror, clip, clipPath and groupOpacity, apply to it exactly as they apply to a child written in page coordinates. verticalAlign on the container does not move it.
- Everything reports page coordinates: the map's fragments, visual, per-clone transform and pivot; TextShop's line records; link rectangles; textWrap bands; the clip and overset notices. The offset is applied once, where the position is read, before any of them sees it.
- Data, clones, partials, masters. A bound position ("x": "{{s.x}}"), a repeat clone, an include partial and each -merge record measure from the box they are placed in; a block whose bound x / y is unknown is still not drawn, and says so. A master-page frame can be a relative container. Running headers and footers hold slots, not blocks, so no container can appear there; a frame's story is the document's, not the container's, and keeps page coordinates.
- Opt-in, and refused out loud elsewhere. A document that never writes the key renders byte for byte as before. On any block that is not a container (a callout included), or with any other value, the key is refused with a notice naming the pointer and ignored. A regression check holds every case above to its page-coordinate restatement.
Block opacity (opacity)
opacity fades a whole block - 0 is invisible, 1 is opaque and is the default. It is real alpha: a /CA /ca ExtGState in the PDF, so a translucent panel over a photograph shows the photograph through it.
{ "type": "container", "opacity": 0.5,
"style": { "background": "#000000", "padding": 12 },
"blocks": [ { "type": "paragraph", "text": "Legible over any picture." } ] }- It is not style.background {color, alpha}. That key mixes the tint toward white when the spec is parsed, so nothing translucent ever reaches the PDF and the result is only right over a white page. Over a backdrop b (0..1) the error is (1 alpha) x (1 b) per channel, independent of the fill color: a 50% black panel over a mid-grey photograph at (150, 170, 150) paints a flat (128, 128, 128) where the truth is (75, 85, 75). Over white the error is 1/255, which is why the key has survived.
- Per-element, not group. On a container it paints that container's own decoration - background, border, accent bar, stripe - and never its children: a child opens its own opacity scope at the default 1 and is fully opaque, which is what keeps text legible on a faded panel. Two translucent shapes that overlap each other composite twice, so their intersection is darker. Compositing a whole subtree once and then fading it is groupOpacity, a different key - see Group opacity below.
- Alphas multiply, they never override. A draw that already carries its own alpha (an SVG fill-opacity / stroke-opacity, a gradient's alpha) composes with the block's opacity: an 0.5 shape inside an 0.5 block draws at 0.25.
- Everything fades: text, images, container decoration, shapes, paths, polygons, rules, table rules, chart wedges, gradients.
- Tagging is unaffected, and PDF/UA-1 stays clean - see Implementation below for why that took a Form XObject.
Group opacity (groupOpacity)
groupOpacity on a container composites that container and everything inside it once, then fades the result. It is not a stronger opacity; it is a different operation.
{ "type": "container", "groupOpacity": 0.55,
"position": { "x": 300, "y": 356, "w": 210, "h": 118 },
"style": { "background": "#1d3557", "padding": 16 },
"blocks": [ { "type": "container", "style": { "background": "#1d3557" },
"blocks": [ { "type": "paragraph", "text": "one object" } ] } ] }- The difference is the overlap, and only the overlap. Two opaque panels, one over the other, each at opacity 0.55, fade the backdrop twice: a seam appears where they cross. Under one groupOpacity 0.55 they composite first, so the overlap matches the panel around it. Measured on the clipped-groups pair at 200 dpi: 87 grey against 143. Where children do not overlap, the two keys look identical.
- They are different words on purpose, and they compose. opacity fades this block's own ink - a translucent panel with crisp text on it, which is a real design and is what the opacity sample shows. groupOpacity fades the whole subtree. opacity 0.5 and groupOpacity 0.5 gives a panel at 0.25 under a caption at 0.5.
- Container-scoped. On a flow block that is not a container the key is refused with a warning rather than dropped in silence. On a leaf it costs a Form XObject and looks exactly like opacity.
- [!] A faded group is ONE object and takes ONE z slot. This engine draws all graphics and then all embedded text, so ordinarily page text floats above every shape. A faded group cannot keep that: its text is inside the transparency form and the form draws where the group's graphics draw. If a shape drawn after the group would have passed under its text, it now passes over it. A group that only clips keeps both substrates where they were.
Group clip (clip)
clip: true crops a container's children - or any positioned block's own ink - to its box, following the style's corner radius.
- It only bites where content is bigger than the box. A flow container grows to fit its children vertically, so nothing overflows to crop; give it position with a height, or a minHeight. Horizontally it bites at once - a wide image, a scaled child, a turned one reaching past a corner.
- The crop follows the group's transform. Inside a rotated group the clip is the box the author drew, turned with its content, not an axis-aligned rectangle over turned ink.
- It needs no transparency group. Clipping is idempotent: intersecting each child's clip with the box paints exactly what clipping the finished group would, so clip costs a path in the content stream and nothing else. That is the whole reason it and groupOpacity are independent keys rather than one switch.
- A positioned block with no height clips horizontally only - "no height" means size to content everywhere else here too, and cropping to a box nobody drew would delete the overflow the author asked for.
Clip path (clipPath) - frames
clip crops to the block's box. clipPath crops to a shape stated inside that box, which is what a design tool calls a frame: a photograph in a circle, a star, a heart. Sample: clip-paths.
{ "type": "image", "src": "portrait.jpg", "position": { "x": 54, "y": 120, "w": 160, "h": 120 },
"clipPath": { "shape": "ellipse" } }
{ "clipPath": { "shape": "polygon", "points": [[0.5,0],[1,0.38],[0.81,1],[0.19,1],[0,0.38]] } }
{ "clipPath": { "d": "M 50 90 C 22 70, 5 48, 5 31 C 5 15, 18 6, 30 6 C 40 6, 47 12, 50 19 ...",
"viewBox": [0, 0, 100, 100] } }- Same vocabulary as the shape block, and the same builder. shape, points (box fractions 0..1), d (SVG path data), viewBox, plus radius for rect. A shape block and a clipPath given identical parameters describe the identical outline, because one function builds both - which is what makes "crop the photo to a star and draw the same star behind it" a reliable idiom rather than a near miss.
- clip and clipPath intersect. They push onto one clip stack, and that is what two clips mean in PDF. In the ordinary case (an ellipse inscribed in the box) the intersection is the ellipse, so nothing surprising happens; where the shape reaches past the box, clip: true trims it back - a diamond bigger than the box gives the box with its corners cut off, which neither key states on its own.
- It scopes exactly as clip does: any positioned block, or a container in the flow. Elsewhere it is refused with a warning. It also needs position.height: the shape is stated inside the box, so a heightless box has nothing to state it in - clip survives that as a horizontal-only crop, a shape would not.
- The fill rule is evenOdd, default false (nonzero), as in SVG and PDF. It only matters for an outline that crosses itself or nests: a five-pointed star written as one self-crossing path is a solid star under nonzero and a hollow one under even-odd. Both rules reach both substrates - the raw stream says it with W n / W* n, the deferred text with a PDE paint op (kPDEFill / kPDEEoFill), and the clip-paths sample carries the pair that proves it.
- The shape turns with the block. Inside a rotated or scaled block the frame is the shape the author drew, transformed with its content - the same rule clip follows, for the same reason.
- The picture is not resampled. The crop is a clipping path in the content stream, so a framed image stays one image XObject at full resolution behind its frame. Note that an image block takes its drawn height from its width and the file's aspect, so position.height sizes the frame and not the picture: a box taller than the picture leaves the bottom of the shape empty.
Drop shadows (shadow)
shadow casts a soft shadow behind an element - an offset (dx, dy), a softness (blur), a peak opacity and a color, or just true for the defaults. Sample: drop-shadows.
{ "type": "shape", "shape": "circle", "fill": "#c0392b",
"shadow": { "dx": 6, "dy": 7, "blur": 9, "opacity": 0.4 } }
{ "type": "container", "style": { "background": "#f4f1ea", "radius": 10,
"shadow": { "dx": 3, "dy": 5, "blur": 10, "opacity": 0.28 } } }It reaches three places: an image (shadow beside cornerRadius and border), any block's style box (style.shadow), and a shape, where it is also a top-level key because every other paint property on a shape is.
- [!] A shape's shadow follows the SHAPE, not its box. A circle casts a circle and a pentagon casts a pentagon, because the shadow is built from the same outline builder the shape draws through. Until T1.6 the only shadow the engine had was sixteen concentric grown rounded rects in the backend, so a shadow could only ever be a rectangle - invisible as a limitation, because the only thing that could carry one was an image.
- A style box shadow is a box property, like border and background: it does not inherit, so a shadow on a container lifts the container rather than every paragraph inside it. It draws before the background, so the background lands on top of it.
- A shadow reserves no flow space, the way a CSS box-shadow does not affect layout. The block is exactly as tall as it was and the shadow spills past it; give a shadowed block spaceAfter if the next one should clear the penumbra.
- blur: 0 is one hard-edged copy - correct, and the cheap path. A soft shadow is 25 gaussian-weighted stamps of the outline, which is how a native-vector engine fakes a blur it cannot rasterize (PDF has no blur operator - that is T1.5).
- [!] shadow is the block's BOX, never its glyphs. A shadow on a paragraph shadows the panel the paragraph sits in, and does nothing visible if the paragraph has no background or border. Shadowed type is a different key - textEffect, below. Both can be set at once and they compose: a panel that lifts off the page, holding letterforms that lift off the panel.
Text effects (textEffect)
textEffect decorates the glyphs. Six of them - shadow, lift, echo, glow, neon and background - written either as a bare name or as an object that tunes it. Sample: text-effects.
{ "type": "heading", "text": "Launch", "style": { "textEffect": "shadow" } }
{ "type": "heading", "text": "Launch",
"style": { "color": "#ffe9fb",
"textEffect": { "type": "neon", "color": "#ff2fd0", "spread": 4, "opacity": 0.9 } } }| effect | what it draws | knobs |
| shadow | an offset, optionally softened copy behind the type | dx, dy, blur, opacity, color |
| lift | the same softening with no offset - the type floats off the page | blur, opacity, color |
| echo | two hard offset copies, the second further out and lighter | dx, dy, opacity, color |
| glow | one halo centered on the glyphs | spread, opacity, color |
| neon | two halos, a wide diffuse one plus a tight bright core | spread, opacity, color |
| background | a rounded box behind the line, not the block | radius, padding, opacity, color |
It inherits with the other text properties, so a bold run inside an effected heading keeps the effect instead of dropping out of it mid-line; clear an inherited one with "textEffect": "none". Left unset, color is derived per effect: shadow and lift default to black, the rest to the text's own color, because an echo in an unrelated hue reads as a bug.
- Hollow / outlined type is deliberately not on this list. It already ships as strokeColor + strokeWidth (see Hollow type), and a second spelling of one idea is how two implementations of a feature start drifting apart.
- [!] Every effect except background is the run DRAWN AGAIN. PDF has no glyph-blur operator, so a shadow is the same string re-stamped, offset, recolored, at a share of the peak opacity. Those copies are written as artifacts, so a screen reader reads the words once and PDF/UA-1 clause 7.1 is satisfied - but they are still glyphs on the page. Text extraction sees them, and so do this build's line-overlap and glyph-overprint checks, which is why text-effects is the one sample in the corpus where deliberate text-on-text is baselined as expected.
- The sample count is chosen from the radius, and that came from looking. Nine stamps soften a 1pt edge cheaply; spread over 4pt they stop overlapping and read as discrete ghost copies of the word rather than as light. Past about 2pt the engine switches to twenty-five. The ghosting is obvious on a rendered page and invisible in the arithmetic.
- background hugs the line, not the block. It is measured from the line's own ink, so it breaks where the line breaks and is as wide as the words - which a style.background cannot do, since that fills the column. Within a line the per-word boxes join seamlessly at opacity: 1; below that, the overlaps at each word gap read as a slightly darker seam.
- It is dropped on vertical (直排) text. The box is measured from a horizontal baseline - advance across, ascent above, descent below - while a vertical run's origin is the top-center of the first glyph and its advance runs down the column, so the box would be drawn lying on its side. The stamped effects carry over to vertical text unchanged; an offset means the same thing on either axis.
- No effect changes any metric. They paint outside the glyph box or behind it, so a line's advance, ascent and descent are identical with and without one - measure and place cannot disagree about an effect, which is the one thing in this engine they usually disagree about.
- [!] blur spreads symmetrically, and on an image that is a change. The value is the gaussian radius: the penumbra reaches roughly blur in every direction, as SVG's stdDeviation does. Until T1.6 an image's shadow grew outward by blur only, because it was built from concentric grown rectangles - so the same number now reads a little wider, softer and lighter on an image than it did. Unifying the two implementations meant keeping one of the two meanings; this is the conventional one. Nudge opacity up if an existing image shadow reads faint.
- The text a block contains casts no shadow of its own. A shadow on a paragraph shadows the panel the paragraph sits in, and does nothing visible if the paragraph has no background or border. Per-glyph text shadow is a different feature.
Filled text (textFill)
A gradient or a picture showing through the letterforms, in place of a solid ink. One key, textFill, on any style - a block's, a run's or a named one. Sample: text-fill.
{ "type": "heading", "level": 1, "text": "Gradient headline",
"style": { "fontSize": 40,
"textFill": { "gradient": { "type": "linear", "angle": 0,
"stops": [ { "offset": 0, "color": "#e02020" },
{ "offset": 1, "color": "#1040d0" } ] } } } }
{ "style": { "textFill": { "image": "photo.jpg", "fit": "cover" } } }- It is inherited, with the other text properties, so a bold word inside a filled headline stays in the ramp instead of dropping out of it mid-line. A child clears one with "textFill": "none".
- [!] THE TEXT IS STILL TEXT. The glyphs are drawn in render mode 7 - added to the clipping path, painting nothing - and the fill is painted inside them. ISO 32000-1 9.3.6's render mode governs painting, not presence: the show operator still carries a font, a string and a position, so the run extracts, searches and joins the structure tree exactly as ordinary type does. It needs no paired invisible copy and no /ActualText. This was measured (apbboxapp reads the filled headlines; veraPDF UA-1 passes with 0 failed rules on both substrates), against a confident prediction that it would not.
- extent decides which box the ramp maps through, and it is the difference between one ramp across a wrapped headline (block, the default) and the ramp restarting on every line (line). Under a horizontal ramp on full-width lines the two render identically; turn the ramp ninety degrees and they are unmistakable. Note line means the line's column box, not its inked width, so a short last line still stops partway through the ramp.
- [!] gradient is the SAME object a shape's fill takes, read by the same parser and built into the same shading by the same function. Angle, from/to, center/radius, N stops, the Type-3 stitching, the CMYK branch and the PDF/A alpha folding all apply unchanged - because a second gradient reader would have started as "just two stops" and silently lacked every one of them.
- fit: cover may overflow the box, and that is safe here. It scales until the box is filled and lets the longer side run past; the glyph clip is already in force, so nothing outside the letterforms paints. contain fits the whole picture inside instead, which on a wide headline leaves the outer letters empty - legible, but rarely wanted, which is why cover is the default. A picture path resolves against the spec file exactly like an image block's src.
- Underline, strike and overline keep the text COLOR, not the fill. They are drawn as lines, and a line cannot join a glyph clip.
- A textEffect under filled type is drawn in the effect color, not filled. Both keys compose - a gradient headline over a soft shadow is one block - but filling the shadow too would paint the ramp a second time at an offset, which reads as a printing fault rather than as a shadow.
- It does not reach vertical (直排) text, and it adds no glyphs: unlike textEffect, a fill re-uses the run that was already there, so the line-overlap and glyph-overprint checks see exactly what they saw before.
- It turns with its block. In a block with angle, scale, skew or a mirror, the gradient or picture is carried by the same matrix as the glyphs, so a headline's ramp runs along the turned line, red at its first letter. Until 2026-10-01 a JSON fill stayed level while the words turned: a red -> blue headline at 90° came out one flat purple, and a picture fill stayed upright in the unturned box, so the letters turned out of it painted nothing.
- A translucent stop fades the type ("#rrggbbaa", "opacity", "transparent" - see Gradient fills), on both text paths.
Text on a path (textPath)
A run set along a curve instead of a straight baseline. One key on a paragraph or a heading, saying the curve one of two ways. Sample: text-path.
{ "type": "heading", "level": 1, "text": "CURVED HEADLINE",
"style": { "fontSize": 30 }, "textPath": { "arc": 120 } }
{ "textPath": { "d": "M 0 0.78 C 0.25 0.12 0.4 0.95 0.6 0.45 S 0.85 0.06 1 0.55" } }- arc is the Curve slider, in degrees swept; the sign is the direction (positive bulges up). d is an arbitrary path in box fractions, read by the SAME parser a path shape and a clipPath use - one path syntax for the language, so a run and the curve drawn beside it cannot describe two different shapes. An open d stays open: the run does not double back.
- [!] THIS NEEDED NO BACKEND CHANGE. SetTextRotation has existed since I8 and both substrates honour it per call, so a curved run is many small DrawTextLine calls with the rotation changed between them. Worth checking before writing a renderer for it.
- The tangent is sampled at each glyph's MID-advance, not its start. Sampling at the start tilts every letter by the angle at its left edge, and on a tight curve the run visibly slides off the path. Mid-advance is what SVG's textPath specifies and what stops the wobble.
- [!] The curve is inset inside the block's band by the type standing on it. The band reserves the curve's extent PLUS ascent and descent; building the curve at the band's edge would let every glyph at the apex overflow the block by its whole ascent, and the next block would draw through the top of the arc. below flips which side the slack goes. Found by looking at a rendered 120-degree arc - the arithmetic was self-consistent and wrong.
- align places a run shorter than its path (start / center / end); below hangs the type under the curve rather than standing it on the line.
- It is one show operator per GLYPH, because PDF has no operator that bends a string - every implementation works this way. The whole run still goes inside ONE marked-content container carrying /ActualText, so it stays a single structure element and copies, searches and reads aloud as words. Verified: veraPDF UA-1 0 failed, ap_addstruct audit 0 findings.
- A curved run does not split across pages. It is one gesture; half an arc at a page break is worse than moving the whole thing, so it defers entire, like a heading.
- On a block type that cannot honour it the key is REFUSED, with a warning, rather than silently ignored. writingMode falls back silently because staying horizontal is legible; a curve has no such fallback.
- The containment guarantee is VERTICAL only - a curved run's ink can leave through the SIDES. The chord spans the block's full width, so a glyph standing at either END of the path is turned by the tangent there, and turning it swings the ascent sideways: the corner reaches up to ascent x sin(tangent) past the box edge. Measured at 22pt on a 70° arc - 6.0pt beyond the left edge with align: "start", 4.6pt beyond the right with "end"; it grows with size and sweep (30pt on a 120° arc is about 18pt, wider than a normal column gutter). On a single-column page that lands in the margin and looks deliberate; in a multi-column body it eats into the gutter, and a curved heading in one column can reach the next. "center" never reaches the ends and so never does this. Leave room, or centre the run, where a curve sits beside other content. Insetting the chord by that amount fixes the overflow - verified in pixels, and Measure and Place stay in exact agreement because both go through one band function - but it also moved a block that demonstrably fits onto the following page, and that reflow is not yet explained, so the inset is NOT in the shipped engine. Do not re-attempt it without first understanding that page break.
Dashes, caps and joins (dash, lineCap, lineJoin, miterLimit)
A stroke has four properties besides its width and its color: the dash pattern, the shape of its free ends, the shape of its corners, and how far a sharp corner may spike before it is cut off. The engine writes them as d, J, j and M (ISO 32000-1 8.4.3.3-8.4.3.6). Sample: stroke-styles.
{ "type": "shape", "shape": "polygon", "stroke": "#c0392b", "strokeWidth": 14,
"dash": [16, 8], "dashPhase": 0, "lineCap": "round", "lineJoin": "round", "miterLimit": 4,
"points": [[0.08, 0.9], [0.5, 0.08], [0.92, 0.9]] }The same four keys work in three places, because one value type serves all of them: on a shape, inside any block's style.border object, and on an imported SVG as stroke-dasharray, stroke-dashoffset, stroke-linecap, stroke-linejoin and stroke-miterlimit.
- A dotted line is a CAP, not a pattern. dash: [0, 12] with the default butt cap draws nothing - a zero-length dash with square ends has no area. Add lineCap: "round" and the same pattern gives dots. This is the single most surprising thing about PDF dashing.
- A dash array whose lengths are all zero is refused with a warning and the stroke draws solid. [0 0] 0 d is a division by zero in most renderers and an invisible line in the rest.
- A border dashes as one path, so the pattern runs continuously round the corners instead of restarting on each side - the difference between a cut line and four dashed rules that meet.
- [!] A join needs one path, and until T1.7 a stroked polygon was not one. Each side went out as its own line, so PDF saw N separate S operations, and j joins segments within a path. Measured on a 16pt triangle: a notch at the apex and a step at each foot, every vertex being two butt caps meeting rather than a corner. An ellipse was the same defect with 48 vertices. Both now come from the shared outline builder - the one a clipPath uses - so they are one closed path and can be joined, dashed and capped like any other.
- SVG's odd-count rule is applied on import. stroke-dasharray="5 3 2" repeats to 5 3 2 5 3 2 (SVG 1.1 11.4); PDF's d does not repeat, so without this the rhythm would be wrong. stroke-dasharray="none" clears a pattern inherited from a <g>, and the dash lengths scale with the CTM exactly as stroke-width does.
Arrowheads (arrow, arrowSize)
arrow: "start" | "end" | "both" puts a filled head on a line or a path - the two shapes that have ends. It is turned to the path's own direction where it lands, which for a curve is its tangent there rather than the chord across it, and it takes the stroke color. arrowSize sets the head length in points; 0 (the default) derives it from strokeWidth, so the head stays in proportion when the weight changes.
- A closed shape has no ends, so arrow on a rect, ellipse or polygon is refused with a warning rather than drawing nothing.
- The head is filled, never stroked - a stroked head would take the block's own dash pattern and come out as three dotted sides.
- Not SVG <marker>. Arbitrary marker content, markerUnits, orient and mid-path markers are a different and much larger feature; this is the arrowhead a diagram wants.
Blend modes (blend, groupBlend)
blend says how a block's ink combines with what is already on the page, rather than simply replacing it. All sixteen PDF modes (ISO 32000-1 11.3.5) are available under their PDF names, and the engine writes the mode as /BM in the same ExtGState that carries opacity. Sample: blend-modes.
{ "type": "shape", "shape": "rect", "fill": "#90f030", "blend": "multiply",
"position": { "x": 70, "y": 266, "w": 96, "h": 96 } }| separable (channel by channel) | non-separable | |
| multiply screen overlay | darken lighten | hue saturation |
| colorDodge colorBurn hardLight softLight | difference exclusion | color luminosity |
normal is the default and writes no /BM at all, so a document that names no mode emits exactly the bytes it emitted before this feature existed. Case and separators are folded - colorDodge, color-dodge and COLORDODGE are one mode - and an unrecognized name is refused with a warning rather than dropped in silence, so a typo does not ship as a page that quietly looks normal. compatible and none are accepted as spellings of normal.
- It reaches both substrates. Shapes, images, gradients and panels take the mode as a content-stream gs; embedded text takes it on the text element itself, exactly as its alpha does. Tagging is unaffected: the blend-modes sample is PDF/UA-1 clean.
- [!] blend is the element's own ink; groupBlend is the subtree. On a container blend reaches its background, border and stripe and stops there - children paint normally unless they say otherwise. groupBlend composites the whole subtree into a transparency group and blends the result.
- [!] And the two differ by more than they do for opacity, because blending is not idempotent. Two stacked children that each multiply the backdrop multiply it twice; the group multiplies it once. Measured on the blend-modes pair: (99, 105, 33) where one multiply happened, (56, 99, 6) where two did - and no per-child setting produces the first picture across the whole shape. With alpha the two constructions differ only where children overlap; with a blend mode they differ wherever the children touch the page at all.
- groupBlend is container-scoped, like groupOpacity: on any other flow block it is refused with a warning. It buys a Form XObject even at full opacity, because "these objects blend as one" is a statement about the composited result.
- A drop shadow keeps normal compositing. image.effects.shadow is drawn as sixteen stacked alpha rings whose softness is that alpha arithmetic; blending each ring would compose the mode with itself sixteen times and a multiplying shadow would come out solid black. To blend an element together with its shadow, wrap the pair in a container with groupBlend.
- PDF/A-1 forbids transparency, blend modes included. The engine does not currently degrade blend, groupBlend, opacity or groupOpacity under pdfaLevel: "1b" - declare PDF/A-2 or later for a document that uses any of them.
Canvas mode (pages) - a designed page instead of a flow
A top-level pages array turns the document into a stack of designed pages: each entry is one page and the elements on it, every element states its own position, and nothing flows. Sample: canvas-design.
{ "pageSetup": { "size": { "width": 612, "height": 612 } },
"pages": [
{ "blocks": [
{ "type": "paragraph", "position": { "x": 48, "y": 300, "w": 400 }, "z": 40, "text": "Headline" },
{ "type": "image", "src": "photo.jpg", "position": { "x": 0, "y": 0, "w": 612, "h": 460 }, "z": 10 },
{ "type": "shape", "shape": "rect", "fill": "#12203a",
"position": { "x": 0, "y": 250, "w": 612, "h": 362 }, "z": 20 } ] },
{ "blocks": [ ... ] } ] }- A canvas page is a section that makes exactly one page. That is the implementation and it is why page size, margins, master pages, running bands, page labels, links, named destinations, tagging and the -map layout map all work here unchanged - there is no second render path to keep in step. Use blocks or sections for a flowing document; the two do not mix in one file.
- [!] z is what makes stacking possible at all, not merely convenient. This engine writes every raw graphic into the page's content stream and defers every embedded text run to EndPage, so in a flow document text floats above every shape on its page. Invisible there, fatal in a design: a photograph dropped over a caption drew under it. In canvas mode each element is captured into its own Form XObject and invoked in z order, so it occupies one z slot and author order wins. Lower z draws first (further back); unset means "wherever I am in the list"; ties keep author order.
- [!] z moves the PAINT and leaves the READING alone. The structure tree keeps the order the elements were written, so a caption authored first because it is read first can still be painted last because it sits on top. That is the thing a flattened scene graph cannot express - it has one order and has to pick - and it is why a canvas page here can be PDF/UA-1 clean while looking like a poster. apsorderapp on canvas-design shows the structure tree listing a higher MCID before a lower one; that is the feature, not a defect.
- Every element needs a position. A block without one is refused with a warning: there is no flow on a canvas page to put it in, and stacking it at the top-left would read as "the engine ignored my coordinates".
- Page size is per document or per page. pageSetup.size takes a named size or { "width": w, "height": h } in points - a square social post and an 18x24 poster are both just numbers.
Raster image transparency (soft masks)
A raster image (PNG, WebP, or TIFF) that carries an alpha channel keeps its transparency. The decoder holds the alpha straight (not composited to white), and DrawImage embeds it as a separate DeviceGray image attached to the color image via /SMask, so transparent and semi-transparent pixels reveal whatever is behind the image instead of compositing onto an opaque white box.
- Nothing to set - place a transparent file and it composites correctly, in RGB or DeviceCMYK output.
- Opaque images are unaffected - no mask is written when every pixel is opaque, so their output is byte-for-byte unchanged.
- The mask rides on the color image whether it is FlateDecode, DCTDecode (the imageFormat:"jpeg" policy keeps a transparent image lossless to avoid DCT edge fringing), or DeviceCMYK, and stays pixel-aligned through maxPpi downsampling.
- libtiff's TIFFReadRGBA raster is premultiplied for RGB + alpha, so RGB TIFF color is un-premultiplied for both associated and unassociated alpha. Its gray + alpha routines are not (a 16-bit alpha is dropped, an unassociated 8-bit one is never premultiplied), so a chunky gray + alpha TIFF is read by DecodeTiffGrayAlpha instead and comes out straight ("Color spaces").
- veraPDF-clean at PDF/A-2b and PDF/UA-1. Samples image-alpha, image-alpha-pdfa.
Raster color profiles and HEIC
A photo carries more than pixels: a color profile that says what its numbers mean, an orientation (see Image effects above), and - from an iPhone - a HEIC container. The profile and the orientation used to be ignored, and a HEIC drew a grey placeholder. Nothing here needs a key; each is on by default.
- An embedded ICC profile is kept. A PNG iCCP, a JPEG APP2 ICC_PROFILE (split over any number of segments), TIFF tag 34675 or a WebP ICCP chunk is validated and the image is written as [/ICCBased n 0 R] instead of DeviceRGB / DeviceGray / DeviceCMYK - so an Adobe RGB or Display P3 photo prints as its numbers mean, not as sRGB. One profile stream per distinct profile per document, however many images share it. In colorMode: "cmyk" (and PDF/X) the profile is instead the source of the image's CMYK conversion, and the file stays ICCBased-free. A profile that cannot describe the samples - damaged, not version 2-4, a link, abstract or named-color profile, not Gray / RGB / CMYK, the wrong component count (a Gray profile on a gray PNG, which the decoder expands to RGB), or one whose tags the color engine cannot read (APDFL would draw nothing at all for it) - is dropped with one notice per document per file, and so is a PNG iCCP chunk that libpng itself rejects: aplayout: <file>: embedded ICC profile ignored (<why>) (at /blocks/N). "images": { "colorProfiles": "ignore" } restores the old untagged behavior. Full rules: "Color spaces" and "PDF/A archival output".
- HEIC / HEIF (and AVIF) decode on macOS, through ImageIO. The box is sized from the primary item's ispe (an iPhone's grid lists a 512 x 512 tile first), a clap crop is honored, and irot / imir become the photo's orientation - ImageIO hands back the stored pixels, and the placement matrix turns them exactly once, as for a JPEG. A file that breaks the spec's property order is read the way ImageIO reads it (measured): the last ispe and the last clap apply wherever they sit, and repeated irots (or imirs) mean the last one alone; both kinds with one of them repeated is a list ImageIO cannot draw, so it gets the placeholder and the notice below. The picture is kept in its own color space when that space is RGB (or gray without alpha) and has an ICC description - so a Display P3 photo embeds as ICCBased with Apple's Display P3 profile - otherwise it is drawn into sRGB. A 10-, 12- or 16-bit picture in a linear-light space (linear sRGB, say) is drawn into sRGB too: eight bits of linear light would collapse its shadows to a handful of levels. An HDR photo prints its SDR base image. Alpha becomes an /SMask, as for PNG.
- Under images.format: "auto" an opaque HEIF is re-encoded as JPEG at jpegQuality: it is a lossy photograph already, and lossless Flate of its decoded pixels is ruinous - measured on an 8-megapixel wallpaper, 12.3 MB as Flate against 0.8 MB as DCT. A transparent one stays Flate. "flate" and "jpeg" behave as for any raster. The re-encode applies outside colorMode: "cmyk" / PDF/X: there an RGB HEIF is converted to DeviceCMYK and stored as Flate, as every recolored raster is (the engine has no CMYK JPEG encoder), and is correspondingly larger.
- A HEIF the engine cannot decode says so, like every other file it cannot read (next section): aplayout: image "photo.heic" cannot be read (<why>) - a placeholder box is drawn (at /blocks/N). The reasons are a non-macOS build (HEIC/HEIF images need macOS, where ImageIO decodes them), a file ImageIO cannot open or decode - including a damaged or truncated one, whose decode ImageIO reports as complete and then draws nothing (never embedded as a black picture) - and a picture over 64 megapixels (checked from the header, before decoding). The placeholder still has the photo's shape. An MP4 or QuickTime movie named .heic is not a HEIF and never reaches ImageIO: it is unsupported image type: ISO media 'isom', not HEIC/HEIF.
Files that cannot be read
A file the engine cannot read is drawn as a light grey placeholder box (0.95 grey, a 0.5 pt 0.7 grey border) in the place and at the size the picture would have had, and the render says so on stderr - once per (block, file), on the final layout pass, with the block's pointer and, under -merge, its record:
aplayout: image "logo.png" cannot be read (not found) - a placeholder box is drawn (at /blocks/3) aplayout: image "trunc.png" cannot be read (truncated or corrupt PNG file) - a placeholder box is drawn (in record 3) (at /blocks/1)
The exit status stays 0, as for every notice. Until 2026-09-26 every one of these drew its placeholder in silence (only a HEIF spoke, once per file, with its full path).
| reason | when |
| no file is named | src is "" |
| not found / permission denied / it is a folder, not a file / empty file / the path is too long to open | the file cannot be opened, or holds 0 bytes |
| it is a data: URI, which this engine cannot decode; ... / it is a URL (https://), and this engine opens files by path, it never fetches a URL; ... | src is a data: URI or a web address: it is left as written (never joined to the spec's folder) and named by its head ("data:image/png;base64,iVBORw0KGg...") or in full - so is an SVG <image href> and an svg / math block's src (an https:// href in a web or Figma export was "not found" under its last segment) |
| not a PNG file (JPEG, GIF, TIFF, WebP, HEIC/HEIF) | its bytes are no picture at all; the word is the file's extension |
| unsupported image type: BMP (PDF, SVG, Photoshop PSD, an MP4) | a recognisable file of a kind this engine does not decode - a PDF is placed with a pdf block, an SVG with an svg block |
| truncated or corrupt PNG file (JPEG, GIF, TIFF, WebP) | the signature is right and the decoder gave up - a file cut short keeps its header, so its box still has the picture's real shape |
| this TIFF image is 8200 x 8200 pixels, over the 64-megapixel limit (GIF; HEIC/HEIF in ImageIO's sentence) | an intact file larger than the decoder takes - never called "truncated" |
| unsupported image type: TIFF with ISO JBIG compression, which this engine cannot decode; save it uncompressed or with ZIP compression | an intact TIFF in a compression this build's libtiff was not built with, named by libtiff's name for it - never "truncated" |
| unsupported image type: animated WebP; export one frame of it as a still image | an intact animated WebP: the WebP decoder draws a still image only (an animated GIF or APNG draws its first frame) |
| ImageIO's own words | a HEIC/HEIF (above) |
The file's bytes decide, never its name: a JPEG named photo.png is a JPEG and draws. The reason is worked out only after a load has failed, so a document whose files all load does exactly the file I/O it always did, and renders byte-identically.
- Nothing else reports a picture that was not drawn. A truncated file's header still reads, and it used to be reported by images.minPpi ("placed at 23 ppi") and listed in the -map's images[] - for a grey box. An unreadable image gets neither. A JPEG cut short in its scan data used to be embedded byte for byte (its header is all a passthrough reads) and rasterised as nothing at all; it is now the placeholder too (JpegIsComplete: the scan must reach its EOI). A JPEG whose scan is complete but has no EOI after it - some encoders and scanners write one, and so does a copy that lost its last two bytes - is decoded to tell it from a cut one (JpegScanDecodesWhole) and draws exactly as it always did, its bytes passed through. A GIF cut at a sub-block boundary no longer draws with its missing rows filled with palette colour 0; it is truncated or corrupt GIF file.
- -maponly and -page N say what the full render says. Neither draws the file - a map-only pass draws nothing, and a single-page render draws only page N - so the backend used to have "nothing to say" about it, and PageWright Studio's live preview and page editor got placed at 46 ppi and an images[] entry for a PNG cut in half, and no line at all for a missing one. An image that is not drawn is now tried through the same decoders a draw would use (nothing is embedded; the answer is kept per file identity for the life of a resident server) and gets the full render's line, and no minPpi or map report. A TextShop layout (the editor's text-only question) does not try it.
- Library text is gone. libpng error: Read Error, libtiff's TIFFFillStrip: Read error ... and the like were the only trace a broken file left; a failed decode's library lines are now dropped in favour of the notice. A TIFF that does decode prints its libtiff warnings exactly as before.
- Tagging is unchanged, on purpose. The placeholder stays inside the image's <Figure> and carries the author's /Alt: the figure is still there to be described, and the notice says what the page shows in its place. The structure tree is exactly the one a drawn picture would give.
The same sentence, with its own kind and consequence, covers every other place a file is named:
| where | kind | what is drawn instead | pointer |
| image block, and an image in a table cell or any container | image | a placeholder box | the block |
| inline graphic run with image | inline image | a placeholder box | the paragraph / heading |
| <image> inside an SVG | SVG <image> | a placeholder box | the svg / math block, or the paragraph / heading of an inline svg run (once per block) |
| textFill: { image } | text fill image | the text in its own color | the block whose style or run names it; /defaultStyle; the band (/pageSetup/header ...); the table, for a cell's or a column's style |
| pageSetup.watermark.image | watermark image | a placeholder box, stamped in its place | /pageSetup/watermark (or the section's / page's / master's) |
| header / footer image.src | header logo / footer logo | a placeholder box in the band | /pageSetup/header ... |
| svg block src | SVG | a placeholder box - see the next section | the block |
| math block src | math SVG | its alt text, in italics (by design) | the block |
| pdf block, template, contact sheet | placed PDF, page template, contact sheet PDF | see "Placing existing PDF pages" | the block, or /pageSetup/template |
Page furniture (watermark, template, band logo) is said once however many pages it is drawn on. A relative header / footer logo and an inline graphic's image are resolved against the spec's directory like every other path (they were not, and loaded only when the process happened to run there - sample header-logo drew a grey box); so is an <image href> in inline SVG content and in an inline svg run, and a text fill named by the defaultStyle, a header / footer band's style, or a table cell's or column's style. An inline image, text fill or inline SVG <image> in a block that was never drawn - a story no frame pours, or text that overflowed the last frame - is on no page and is not reported at that block's pointer. An SVG <image> with a data: URI draws nothing and says so, per block and per render, in its own sentence (... uses a data: URI ... - this engine cannot decode one, so the image is NOT drawn ...).
SVG import (svg block)
The svg block imports a vector drawing from an .svg file (src) or inline markup (content) and draws it as native PDF vector paths - so it scales crisply and stays print- and PDF/A-safe.
{ "type": "svg", "src": "logo.svg", "width": 200, "align": "center", "alt": "Acme logo" }
{ "type": "svg", "width": 80, "content": "<svg viewBox='0 0 10 10'><circle cx='5' cy='5' r='4' fill='#1a4fa0'/></svg>" }| Field | Meaning |
| src | path to an .svg file (relative to the spec) |
| content | inline SVG markup (wins over src) |
| width | box width (points or "NN%"); height follows the SVG aspect unless given |
| height | explicit box height in points |
| alt | when set, the drawing is tagged as a Figure with this alt text (else a decorative artifact) |
| lang | the language of alt (or of caption, when that is the alt), written as /Lang on the Figure - an English alt in a French page reads as English. The Caption itself inherits. Every graphic block takes it the same way. A graphic that declares none reads as the box around it - a container, callout, table, list or frame with its own lang - like the text beside it |
Supported subset. <svg> (viewBox / width-height), <g>, and the shapes <path>, <rect> (incl. rx/ry), <circle>, <ellipse>, <line>, <polyline>, <polygon>. A <polyline> is filled like a polygon - the fill closes it implicitly and only its stroke stays open (SVG 1.1 9.6); a <line> is never filled. (Until 2026-10-10 a polyline was never filled, so a filled one with no stroke vanished: Noto's crocodile lost its mouth.) Full path data M/L/H/V/C/S/Q/T/A/Z (arcs flattened to curves). Transforms translate / scale / rotate / matrix / skewX / skewY, composed down the tree. Presentation fill, stroke, stroke-width, fill-rule (nonzero / even-odd), opacity / fill-opacity / stroke-opacity, via attributes or a style="...", inherited from ancestors; colors as #rgb / #rgba / #rrggbb / #rrggbbaa, rgb() / rgba() / hsl() / hsla() (comma form, or CSS Color 4's rgb(255 0 0 / 50%)), common names, transparent, or none. A colour's own alpha is painted and MULTIPLIES fill-opacity, stroke-opacity, stop-opacity, flood-opacity and opacity (CSS Color 4): stop-color= "rgba(255,226,99,.72)" stop-opacity=".72" is a 52 % stop. (Until 2026-10-10 rgba()'s alpha was dropped - the stop drew at stop-opacity alone - and hsl() / hsla() / #rgba were not parsed.) A stop-color="transparent" has no ink of its own and fades each side out in its inked neighbour's colour, exactly as a JSON gradient's "transparent" stop does (no grey band through the fade; stop-color="black" stop-opacity="0" still means black); flood-color="transparent" casts no shadow. display:none - attribute, style, or a <style> rule - draws neither the element nor its subtree, and a child's own display:inline does not bring it back: exactly how Illustrator keeps a hidden layer. It does not stop the element being referenced (a <use> of something inside a hidden group draws, the SVG-sprite pattern), and a <symbol> that a <use> instantiates draws whatever its own display says - display does not apply to a symbol, and sprite sheets write <symbol style="display:none">. Inside a <clipPath>, <mask> or <pattern>, a hidden shape adds nothing to the clip or the tile, whether it is hidden itself or sits in a hidden <g>. A display:none <tspan> takes itself and everything nested in it out of the text. visibility:hidden hides an element's own marks and is inherited, but a child that says visibility="visible" is drawn (a nested <tspan> too, as a run of its own at its own x / y). (Until 2026-10-10 neither was read, so 16 of Noto's 2D emoji drew their hidden Illustrator layers - guide grids, a spare coin - over the art.) Gradients - <linearGradient> / <radialGradient> (+ <stop>) referenced as fill="url(#id)", stroke="url(#id)", or a <text> / <tspan> fill="url(#id)" - become true PDF shadings (C5b), placed where SVG puts them. In objectBoundingBox units (the default) the unit square maps onto the element's own geometric bbox, before its transform: a radial on a 2:1 box is an ellipse, and a diagonal ramp's isolines are perpendicular in that square, not on the page. In userSpaceOnUse units the coordinates are the element's own user space, its transform and its ancestors' applied. A gradientTransform is any affine (rotate, skew, a non-uniform scale). Geometry attributes are numbers, percentages - of the box, or in userSpaceOnUse of the viewport: its width, its height, ((w² + h²) / 2) for a radius - or CSS lengths. A radial's focal point fx / fy (and SVG 2's fr) is honoured; one outside the end circle is moved onto it (SVG 1.1). spreadMethod pad, reflect and repeat are all drawn (past 256 repeats across one shape it is painted as pad, with a notice). Stop offsets follow SVG 1.1: a number or a percentage, clamped to 0..1, and an offset smaller than an earlier one is raised to it (never re-sorted); stop-opacity is a number or a percentage, clamped to 0..1, and renders as a real soft mask. xlink:href / href gradient templates are followed: what the element does not write - units, transform, spread, the geometry of a template of its own kind, and the stops when it has none - comes from the template chain, nearest first; a cycle is cut where it closes, with a notice. A gradient with no <stop> anywhere is SVG's none, and a paint fallback is honoured: fill="url(#g) #0f0" paints green where #g cannot be used, url(#g) none paints nothing. A horizontal or vertical <line> has no box for objectBoundingBox units, so SVG paints nothing with such a gradient there (or the fallback) - and the engine says so. Still not drawn, each said once on stderr: color-interpolation="linearRGB" (interpolated in sRGB, SVG's default), a <pattern> as a stroke or as a text fill, and reflect / repeat on a radial whose focal circle reaches outside its end circle (painted as pad); an id that names nothing keeps the plain fill, as before, with its notice. <image> (its href resolved next to the .svg) and <text> are placed natively; text lays out over multiple <tspan> runs, each with its own x/y/dx/dy (em-aware) position and its own font-size / weight / style / fill, so multi-line labels stack correctly. The SVG's viewBox maps to the block's box (a width plus the aspect, or height).
Aspect ratio. When the box is not the viewBox's shape - a width and a height that disagree with it, on a block or an inline svg run - the root <svg>'s preserveAspectRatio decides (SVG 1.1 7.8). The default is xMidYMid meet: one uniform scale, the largest that fits, and the drawing centred in the box, so a square emoji in a 216 x 72 box stays square. meet / slice (cover the box; the viewport clip crops the overflow) and the nine xMin|xMid|xMax x YMin|YMid|YMax alignments are honoured, and none stretches each axis to the box - what every SVG did before 2026-10-10. It applies only when the root has a viewBox; without one, width x height (or the 300 x 150 default) still maps onto the box as before. A box of the viewBox's own shape - the usual case, a width with the height left to the aspect - draws exactly as it did. An invalid value - an unknown or wrong-case alignment, a second word that is not meet / slice, anything after it - counts as not written, and the default applies. A slice is always clipped to the box, even when the root says overflow="visible": what slice crops is most of the drawing, not a stroke overhanging the viewBox (meet and none still honour overflow="visible"). A nested <svg>'s own viewBox and an <image>'s preserveAspectRatio are not read.
Stylesheets. A <style> element (CSS, incl. <![CDATA[...]]>) is honored: rules keyed by type (rect), .class, #id and * color any element that matches, with the CSS cascade inline style="" > #id > .class > type > *, and presentation attributes lowest - so a drawing that carries its colors in a stylesheet (the Illustrator / Inkscape / Figma default) imports with the right paint instead of falling back to black. A selector with a combinator (#layer rect, .x.axis path, g > circle) is applied by its trailing simple selector alone, so it matches more than it was written for: such a rule still paints, but its display / visibility are not applied - a rule meant to hide one group would otherwise delete every rect or path in the drawing (a D3 chart's data line). A trailing !important is accepted and ignored: it does not change the cascade order.
A file that cannot be read. An svg block whose src is missing, empty, locked, a folder, or not SVG markup the engine can parse (a PNG named .svg, markup cut short) draws the grey placeholder box in its reserved box - tagged as the drawing would have been (a <Figure> with its /Alt when it has alt or a caption, else a Layout artifact) - and says SVG "diagram.svg" cannot be read (<why>) - a placeholder box is drawn. It used to reserve the box and draw nothing in it, untagged, so its /Alt left the structure tree too. Inline content that does not parse, and a file that parses to nothing drawable, are unchanged. An <image href> in inline content resolves against the spec's directory (it used the process's working directory). An SVG with no viewBox and no width / height is not unreadable: it is drawn in the 300 x 150 box a browser gives it (the replaced-element default, in user units), mapped onto the block's box. It used to be the placeholder, "not SVG markup the engine can read".
Reuse. <defs>, <symbol>, <clipPath>, <mask> and <pattern> are treated as a definitions library - their contents never draw at their definition site: a <symbol> draws only where a <use href="#id"> instantiates it (x/y become a translate, and the use's own paint is inherited), and a <clipPath> / <mask> / <pattern> draws only through the element that references it. <use> resolves through a few passes so a reused group may itself contain a <use>.
Compositing. A clip-path="url(#id)" crops an element (and its subtree) to the referenced <clipPath>'s outline; a mask="url(#id)" clips to the mask's shapes with an even-odd rule, so a dark shape nested inside a light one punches a hole (a donut / knockout); and a fill="url(#id)" that resolves to a <pattern> tiles the pattern's content across the filled region (each tile cropped to the shape). All three become native PDF clipping (W / W*) and vector paint - tagged-clean and print-safe. Clips use userSpaceOnUse geometry.
Drop shadows. A filter="url(#id)" that reduces to a drop shadow - a <feDropShadow>, or the classic <feGaussianBlur> + <feOffset> (+ <feFlood>) pattern - is drawn behind the shape. Because a native-vector importer can't rasterize a real Gaussian, the soft edge is faked with overlapping translucent copies of the shape (a hard stdDeviation="0" shadow is a single offset copy); dx / dy, stdDeviation, flood-color and flood-opacity are honored.
Viewport. By default the drawing is clipped to its box - the SVG UA stylesheet clips the outermost viewport, so content authored past the viewBox is cut off at the box edge just as a browser's overflow:hidden would. Set overflow="visible" on the root <svg> to let it spill.
Not yet: filter effects beyond a drop shadow (a Gaussian blur of content, color matrices, lighting) are parsed-but-not-applied - a vector importer can't rasterize them, and a drop shadow honors its geometry, not a real blur. A mask's luminance is likewise approximated by the geometric even-odd clip, so a soft / gradient mask edge becomes a hard edge; and objectBoundingBox clip / mask / pattern units are skipped (use userSpaceOnUse).
Tagging
Shapes are drawn as Layout artifacts (decoration), so they never enter the structure tree and a tagged / PDF-UA document stays clean. A shape that carries meaning should be paired with a caption or an alt-bearing block.
Sample
- shapes - filled / stroked / both rectangles, a diagonal line, a filled + outlined ellipse, a triangle polygon, and an absolutely-positioned badge. Tagged, passes veraPDF --flavour ua1.
- shapes-path - path shapes: a filled arrow (SVG string), a cubic-bezier wave, a stroked-open checkmark, and a fill+stroke diamond (op array). Tagged, ua1-clean.
- gradients - gradient fills: horizontal / vertical / diagonal linears, a five-stop spectrum (Type-3), a radial badge, an off-center radial ellipse, a stroked pentagon, and a gradient-filled wave path. Tagged, ua1-clean.
- image-effects - rounded corners, a colored border, a soft drop shadow, and all three combined into a framed print. Tagged, ua1-clean.
- image-rotate - the same figure at rotate 0 / 90 / 180 / 270 and flipH / flipV; the 90/270 variants auto-size to a portrait box and fill it undistorted. Tagged, ua1-clean.
- rotated-blocks - angle on four block types: a heading at 23°, an image at 12°, a tinted container at 15° whose panel is drawn on the raw substrate while its text rides the PDE tree, and a container at 8° whose content overflows its fixed height. Unrotated body text above shows that a rotated positioned block consumes no flow height. Tagged, ua1-clean; the transforms all sit inside their marked content.
- grouped-blocks - GROUPS (T0.2): a container turned 12 degrees in the flow with its heading and paragraph inheriting the transform, a scaled group, and - the fixture that matters - a group at +9 degrees holding a child at 9, which draws upright because the two compose. Before groups inherited, the child's matrix replaced its parent's and drew at 9 absolute. The body text after each group is the witness for the reserved bounding box: it clears the turned corners. Tagged, ua1-clean.
- clipped-groups - the other two things a group does, each against its control: a pair of panels where the left one fades per element (and shows a seam where its two opaque boxes overlap) and the right one fades as a group (no seam), and a pair where the same 24-degree child overflows an uncropped box on the left and is cut to a rounded box on the right. Tagged, ua1-clean - which is the point of tagging it: a faded group's text lives in a transparency-group Form XObject, so the sample re-proves the /Stm + /StructParents pairing on every gate run.
- clip-paths - FRAMES (T0.4): one picture uncropped and then through an ellipse, a star polygon and a heart d; the same self-crossing star under both fill rules, solid beside hollow, which is the fixture that catches the two substrates disagreeing about the rule (a rectangle cannot, and that is why clip alone never showed it); a shape block stroked over the crop built from the same points, so the stroke must trace the crop edge exactly; a frame inside a block turned 20 degrees; tagged text cropped to an ellipse (the deferred-text substrate); and clip plus clipPath intersecting to cut a box's corners off. Tagged, ua1-clean.
- canvas-design - CANVAS MODE (T0.6): two square designed pages from a pages array, no flow anywhere. Page 1 is a poster whose tinted band is declared after the words it covers and must hide them - a layering that was impossible before, because text always floated above every shape. Page 2 is the assertion stated plainly: three cards authored A, B, C and painted C, B, A, each card's label occluded by the card above it (so the z slot is per ELEMENT, not per substrate). Authored in reading order throughout, so the structure tree and the paint order disagree on purpose. Tagged, ua1-clean, ap_addstruct audit 0 findings.
- drop-shadows - SHADOWS ON ANY ELEMENT (T1.6). The pentagons are the fixture: an outline that is neither its own bounding box nor a rounded rectangle, so a box-only shadow would put a grey rectangle behind them. The circles show the same silhouette reached two ways - a shape's outline and a container box at a 75pt radius - and the second row is blur: 0 against blur: 12 on the same offset. Page 2 puts the box shadow on ordinary furniture: a lifted panel whose paragraphs cast none of their own, and an image, whose shadow is the same key it always took and is now the same code. Tagged, ua1-clean.
- text-effects - GLYPH DECORATIONS (T1.2). Every swatch casts in a color the type is NOT, and that is the fixture: the first render used a black shadow under black type on white paper and showed nothing but slightly fatter letters, with shadow, lift and neon mutually indistinguishable - the effects were fine, the fixture proved nothing. Neon and glow sit on a dark panel for the same reason: a halo is light coming off the letterforms, and on white paper there is nothing for it to be lighter than. Page 2's wrapped magenta paragraph is the one thing only background can do - a backing that breaks where the LINE breaks rather than squaring off the column. Tagged, ua1-clean; the decorative stamps are artifacts, and this is the corpus's only baselined text-on-text.
- text-fill - GRADIENT- AND IMAGE-FILLED TEXT (T1.3). The fixture is the three-stop ramp across a WRAPPED headline: a two-stop ramp on one line is not discriminating, because it looks the same whether the fill maps through the block or the line and nearly the same as a solid mid-tone. The extent pair is turned ninety degrees for the same reason - under a horizontal ramp the two mappings are indistinguishable on full-width lines, so a horizontal pair would have "verified" a key that could have been ignored entirely. The picture-filled headline is three lines rather than one because cover centres the image, and a wide short box shows only a thin band through the middle of it, which for this picture is its plain sky; the first draft looked like a flat tint. Tagged, ua1-clean. It carries a glyph-overprint baseline, and not for the reason it looks like: filling adds no glyph - the filled runs are the runs that were already there - but one headline demonstrates textFill composed with a textEffect shadow, and the shadow's stamps collide. Measured both ways: strip every textEffect and the count is 0; strip every textFill and the collisions remain.
- text-path - TEXT ON A CURVE (T1.4). The arcs are wide on purpose: a shallow bend reads as a crooked straight line and cannot tell "the curve works" from "the baseline is slightly off". The below pair is the SAME arc twice, because a hanging baseline is not identifiable without the ordinary one beside it - and neither is a future change that breaks it. Page 2 sets a run on an arbitrary cubic S-curve and shows the three align positions on identical arcs. Tagged, ua1-clean; one show operator per glyph, one structure element, /ActualText carrying the words.
- stroke-styles - DASHES, CAPS, JOINS AND ARROWHEADS (T1.7). The three triangles are the regression fixture: lineJoin can only be told apart on ONE path, and a stroked polygon used to go out as N separate lines, so its apex was a notch rather than a corner. The dash rows include the case that surprises everyone - [0.1, 14] with a ROUND cap gives dots, and the same pattern with butt ends draws nothing at all - plus the same array at two phases. Page 2 carries arrowheads on a line, on a diagonal (each head takes its own end's direction) and on a CURVE (tangent, not chord), and a dashed rounded border whose pattern runs continuously round the corners because a box is one path. Tagged, ua1-clean.
- blend-modes - BLEND MODES (T1.1): all sixteen, one ink on one backdrop, so each swatch has a single right answer that can be read off a raster and checked against ISO 32000-1 11.3.5 - and measured, all sixteen match. The two colours are chosen, not picked: the obvious pair (#ff8000 on #00a0ff) is degenerate, returning the backdrop exactly for overlay, softLight and saturation, and an invisible swatch cannot tell "the mode works" from "the mode was dropped". Page 2 is the discriminating fixture for the two scopes: the same two overlapping squares multiply per element on the left (the overlap darkens twice, 56/99/6) and as a group on the right (once, 99/105/33, no seam), plus a blended and an unblended line of the same text - the second substrate, where a mode reaches the element rather than the content stream. Tagged, ua1-clean.
- transformed-blocks - the rest of the affine: a heading scaled 1.6, a container scaled 1.8 x 0.7 (the non-uniform case, the only one that can see composition order), two containers skewed on the separate axes, mirror-written text, a mirrored image, and one panel carrying all four at once. Tagged, ua1-clean. The mirrored image is the regression fixture for the flipH / mirrorH collision - the block keys and the image keys are deliberately different words, and an image is the only block where writing one for the other silently cancels.
- svg-import - an imported .svg (rounded panel, circle/ellipse, a rotated square, a smooth-curve wave, an arc ring, a polygon, a transformed letter-T group), an inline check badge, a gradient/opacity panel (linear + radial gradients, translucent blending disks, an even-odd frame), a per-stop opacity panel (two teal bands fading to transparent and a soft radial glow over a striped backdrop, via a luminosity soft mask), and an embedded image & text panel (a raster href beside native SVG <text>). Tagged Figures (alt), ua1-clean.
- svg-coverage - the design-tool idioms: a <style> stylesheet coloring status chips by .class / #id / type selector, a five-star rating drawn from one <defs> symbol reused with <use>, and a three-line <tspan> caption. Tagged Figure (alt), ua1-clean.
- svg-clip-pattern - the compositing idioms: a gradient shown only through a circular clip-path, a woven <pattern> tiling a rounded badge, and a <mask> punching a circular hole (even-odd knockout) through that pattern. Tagged Figure (alt), ua1-clean.
- svg-filter - filter drop shadows (a <feDropShadow> and the classic <feGaussianBlur>+<feOffset> pattern) softening three floating cards, and the SVG viewport clip cropping a tile's decorative circles at the box edge. Tagged Figures (alt), ua1-clean.
# Charts
The layout engine draws charts as a "chart" block. All chart types render through one function - Chart::Place - and share the same decoration (title, legend, colors, axes). Each chart is emitted as a single tagged PDF/UA Figure with /Alt, so charts pass veraPDF --flavour ua1.
Chart types
| chartType | shape | data meaning of a point |
| line | poly-line (optionally smooth) + markers | (x, y) |
| scatter | markers only | (x, y) |
| bar | vertical bars - grouped, stacked, stack100, or horizontal | (category-x, value-y) + categories |
| polar | true polar curve, (angle, radius) | (angle, radius) |
| pie / doughnut | filled slices (doughnut = pie + holeRatio) | one series = one slice |
| radar (alias spider) | one spoke per category, a closed polygon per series | i-th point's y = value on axis i |
| area | a line chart with the region under each curve filled (stacked/stack100 stack the bands) | (x, y) |
| bubble | scatter with a per-point dot size | [x, y, size] (radius size) |
| heatmap | a matrix of value-colored cells with a two-color ramp | matrix: [[v...]...] + xLabels/yLabels |
| gauge | a single value on a semicircular dial with zones + needle | value (+ zones, yAxis.min/max) |
| waterfall | up/down bars floating from the running cumulative, with connectors (up=highColor, down=lowColor) | one series of deltas + categories |
| funnel | centered tapering stages sized by value, with white in-stage labels | one series of stage values + categories |
| candlestick (alias ohlc) | per-period wick (high-low) + body (open-close), up/down colored | matrix rows of [open, high, low, close] + xLabels |
| boxplot (alias box) | whiskers with caps, a quartile box (Q1-Q3) and a median line, per-category colored | matrix rows of [min, Q1, median, Q3, max] + xLabels |
| combo | bars and lines together; each series renders by its own type (bar/line/area), and a series can plot against a secondary right axis ("axis": "right" + a yAxis2) - bars group only among bar series | per-series (x, y) + a type / axis |
| treemap | value-proportional rectangles laid out by the squarified algorithm, palette-colored with name/value labels; nests when given multiple series (each becomes a group rectangle, its children a shade of the group color, with a group header) | one series of values + categories, or multiple named series for a hierarchy |
| sankey | a flow diagram: nodes laid out in longest-path layers, each sized by its larger of in/out flow, joined by translucent bezier bands colored by source | categories (node labels) + matrix rows of [source, target, value] |
Common options (every type)
{
"type": "chart", "chartType": "line",
"title": "Headline", // bold, centered above the plot
"subtitle": "smaller line", // units / source / range
"legend": true, // default true; lists the series
"legendPos": "tr", // tl | tr | bl | br: a corner INSIDE the plot (over it);
// top | bottom | left | right: OUTSIDE it, the plot making room
"legendAt": { "x": "70%", "y": 8 }, // or at your own point: the box's top-left, from the chart's
// top-left, in points or % of its width / height; wins over legendPos
"markers": false, // a marker at each data point (line/scatter/radar)
"valueLabels": false, // print each point's numeric value
"smooth": false, // centripetal Catmull-Rom spline (line + polar)
"size": { "width": "100%", "height": 300 },
"alt": "...", // tagged-PDF alternate text (defaults to the title)
"series": [ { "name": "A", "color": "#1a4fa0", "points": [ [0,10], [1,14] ] } ]
}- Colors - each series takes a color in any color space: #rrggbb / a named color (RGB), {"gray":v}, {"cmyk":[c,m,y,k]}, or a spot ink {"spot":"PANTONE 185 C","cmyk":[...]} (see "Color spaces"); unset series cycle a 6-color palette.
- Chart furniture - everything a chart draws that is NOT data takes its colour from the document palette: axis (the plot frame, and a polar chart's outer ring), grid (major gridlines; a polar or radar chart's rings and spokes), gridMinor (the fainter intermediate lines) and tickLabel (tick values, category names, polar degree labels, value labels and the axis names). Each is optional and each falls back to the engine's own gray - 40% / 85% / 92% / 30% - so a document naming none renders exactly as before. They take any colour space, so a one-colour or spot-ink manual can tint its grids in the same ink as its rules. Set once at the document level, every chart in the file follows. A polar chart is the only type that shows all four at once; sample: chart-polar.
- valueLabels placement is contrast-aware: above a grouped bar (dark), centered in a stacked segment (white), beside a horizontal bar, at a line/polar/radar vertex. Pie uses its own built-in % labels instead.
- Non-Latin labels - every piece of text a chart draws (title, subtitle, legend, axis names, tick and category labels, value labels, node and slice names) goes through the document's fallbackFont for the runs a Latin face cannot render, the same way body text does, so Korean or Chinese categories render rather than coming out as empty boxes. A chart is drawn art, not flowed text, so its labels reach the fallback by their own path - worth remembering if you add a new label to the engine. Only a run the block's own font cannot render is re-routed, so an all-Latin chart measures and draws exactly as before. A label in a right-to-left script or in Devanagari is set as a paragraph's line would be -- reordered, joined or shaped, its logical text kept as /ActualText -- and centred by the width it is drawn at (since 2026-10-24; samples bidi-hebrew, bidi-arabic, devanagari). The chart itself stays physical: its axes and categories run left to right in any script. A label with no Hebrew or Arabic letter of its own -- "-10%", "$1,200", "3.5 kg" -- reads LEFT TO RIGHT even under a STATED right-to-left direction, its sign at the left as most charting tools set it (since 2026-10-25). The right-to-left a table's Hebrew cells give a chart in another of its cells is not stated, and moves no label without right-to-left text of its own (since 2026-10-25: drawn as before, byte for byte). Sample: fallback-cjk (this file stays Latin-only on purpose: it renders into guide-design-notes, which configures no CJK face, and a literal CJK glyph here becomes a .notdef that fails PDF/UA 7.21.8).
Axes - xAxis / yAxis (line, scatter, bar)
"xAxis": { "label": "Month", "min": 0, "max": 12, "majorTick": 2, "minorTicks": 1,
"grid": true, "unit": "" },
"yAxis": { "label": "Units", "unit": "K", "grid": true }- majorTick / minorTicks - explicit spacing + minor subdivisions; omit majorTick for automatic "nice" 1/2/5x10 spacing.
- grid - hairline gridlines at the major ticks.
- unit - suffix appended to every tick label ("K", "%", "$", "ms", ...).
- min / max - clamp the range (otherwise taken from the data, extended to nice ticks).
For polar and radar the axes are reused: yAxis is the radial axis (min/max/majorTick/minorTicks/unit), and for polar xAxis is the angular axis (majorTick = spoke spacing in degrees, min/max = angle range, unit = label suffix, default °).
The category axis - categories (bar, line, area, combo)
Everything above describes a numeric axis. Give the chart a categories array and its x axis becomes a category axis instead:
{ "type": "chart", "chartType": "bar",
"categories": ["Q1", "Q2", "Q3", "Q4"],
"series": [ { "name": "2026", "points": [[0,120],[1,150],[2,170],[3,210]] } ] }- A point takes its label from the rank of its x among the distinct x values across every series - not from the x value itself. So [[1,62],[2,78],[3,71],[4,84]] with four categories still reads A, B, C, D, and a series that skips a category leaves that slot empty rather than shifting every label after it.
- The axis is built from the categories rather than a numeric scale: a tick per category (thinned when crowded, below), and the range pinned to the categories with half a slot of air at each end - so the outermost bar sits inside the frame, and twelve categories fill the plot instead of being padded out to a "nice" 0-15.
- Labels are thinned when they would collide: the widest name is measured once against the tightest gap, and every Nth category is labelled. Its tick and gridline travel with the name, so the axis never carries a mark it cannot explain. Set xAxis.majorTick to an integer to choose that stride yourself. When the names are long, "horizontal": true is usually the better answer - a horizontal bar widens its left gutter to fit them, where a vertical one has only its slot width.
- categories must describe the whole axis for it to engage: one name for every slot, and slots evenly spaced. Fall short of either and the x axis simply stays numeric - which is what keeps a name off the wrong bar. A combo chart whose trend line sits off the bar grid, for instance, has more slots than the bars do, so it keeps its numbers. Spare names past the end of the data are ignored.
- On a category axis minorTicks and unit do not apply, logScale is ignored (a category index is ordinal, not a magnitude), and an explicit min/max pins that edge and drops any category that then falls outside the plot.
- Ignored on scatter and bubble, whose x is a real measurement. radar, waterfall, funnel, treemap and sankey read categories in their own way - see the type table.
- heatmap, candlestick and boxplot label their columns with xLabels, not categories.
Sample: chart-categories.
Reference lines & shaded bands (line, scatter, area, bar, combo)
Annotate a cartesian chart with limits and context, given in real data units so they track the axes.
"bands": [
{ "yFrom": 0, "yTo": 100, "color": "#2e8b57", "label": "Healthy" },
{ "xFrom": 6, "xTo": 8, "color": "#b0862a", "opacity": 0.22, "label": "Outage" }
],
"referenceLines": [
{ "y": 200, "label": "SLA limit", "color": "#c0202a" },
{ "y": 120, "label": "Target", "dashed": true },
{ "x": 10, "label": "v2 launch", "color": "#5a3fb0" }
]- referenceLines - a rule across the plot at a fixed value. Give y for a horizontal rule or x for a vertical one (one or the other). label is drawn at the line's end (right for horizontal, top for vertical), color defaults to a muted dark red, width is points (default 1), and dashed (default true) distinguishes it from a data series. Drawn over the data so the limit reads against it.
- bands - a shaded value range behind the data. Give yFrom/yTo for a horizontal band (spans the full plot width) or xFrom/xTo for a vertical one (full height). color (default a cool blue) is drawn tinted toward white by opacity (0-1, default 0.18); because the plot background is white, that equals true opacity. label sits in the band's top-left. Drawn behind the gridlines and data.
- Combo / dual-axis - a y reference line or yFrom/yTo band can read against the secondary (right) axis with "axis": "right" (or "secondaryAxis": true).
- Bar charts - reference lines and bands work on vertical bars, grouped or stacked, exactly as above (a y value is the height axis). On a horizontal bar the value axis is the X axis, so a value threshold or range is given with x / xFrom/xTo - a vertical rule or stripe (mirroring how a vertical chart uses y); the category axis carries no reference lines there.
- Ignored on non-cartesian charts (pie, radar, gauge, heatmap, treemap, ...).
Because bands and lines are painted with the chart's existing opaque primitives (no /ca soft-mask), a chart carrying them stays a clean tagged PDF/UA Figure. Sample: chart-reflines.
Error bars (line, scatter, area, grouped bar)
A series can carry error bars - a whisker with end caps at each point, spanning value below ... value + above in the value axis' units:
{ "name": "RT", "error": 35, "points": [[1,420],[2,395],[3,360]] }
{ "name": "yield", "errors": [ [6,8], 7, [10,12] ], "points": [[1,62],[2,78],[3,95]] }- error - a single symmetric ± delta drawn on every point.
- errors - an array parallel to the points; each entry is a number (symmetric) or a [below, above] pair (asymmetric). Overrides error for the points it covers.
- Drawn in a darker shade of the series color so the whisker reads over a filled bar as well as over a line; clamped to the plot, caps drawn only when in range.
- Applies to line / scatter / area and grouped bar series. Skipped on stacked charts (ambiguous), on secondary-axis series (their points are remapped to primary space), and on horizontal bars. Negative deltas are clamped to zero.
Sample: chart-error-bars.
Per-type options
Bar
stacked (stack series per category), stack100 (stack proportionally to 100%), horizontal (categories down the left, with the value axis at the bottom - see the category axis below). Default is grouped (side-by-side). The value axis auto-scales to the stack totals. Sample: chart-bar.
Polar (true polar)
angleStart (degrees where 0 sits; 0 = right / 3 o'clock), angleClockwise (default false = counter-clockwise / math), angleUnit ("deg" | "rad" for the point angles), closed (connect the last point back to the first). Rings + spokes + degree labels are drawn automatically.
A partial sweep - xAxis.min / xAxis.max set the angular range (default 0-360), and everything follows it: rings, the outer boundary and the two straight edges are drawn only across the sweep, and the plot is fitted to the sector's bounding box rather than to a whole circle. A half circle is therefore 2:1 and fills the width - roughly twice the linear size a full-circle fit would give in the same box - and a quadrant puts the centre in a corner. The radius labels move from the vertical to the sweep's first edge. Leave closed off for a partial sweep unless you want the chord drawn. The usual antenna convention is xAxis:{min:-90,max:90} with angleStart:90, angleClockwise:true - forward hemisphere, boresight up, horizon at ±90°, and a dB radial axis via yAxis:{min:-40,max:0,unit:" dB"}. Sample: chart-polar.
Pie / doughnut
holeRatio (0 = pie; e.g. 0.55 = doughnut). Each series is one slice: its name labels it, its color fills it, its value is the sum of its points' y. Slices show a % label and white separators. Sample: chart-pie.
Radar / spider
categories (string array - one labeled spoke each), fillOpacity (0 = outline only; >0 = translucent-tint fill). Each series' i-th point's y is its value on spoke i. A concentric web grid + radial labels are drawn automatically. Sample: chart-radar.
Area
A line chart whose region down to the zero line (or the running stack base) fills with a tint of the series color (fillOpacity sets the tint strength, default 0.45). stacked / stack100 stack the series as bands (points aligned by x); smooth and markers work as on a line chart. Sample: chart-extra.
Bubble
A scatter whose points carry a third value: [x, y, size] (or {x, y, size}). The dot area scales with size (radius size, largest 14 pt), filled with a tint and ringed in the series color. Sample: chart-extra.
Heatmap
matrix (rows of numbers, row 0 at the top), xLabels / yLabels (column / row labels), and a color ramp from lowColor (default white) to highColor (default the first palette color). valueLabels: true prints each cell's value with contrast-aware text. yAxis.min / yAxis.max clamp the ramp range. Sample: chart-extra.
Gauge
value (the needle), yAxis.min/max for the range (default 0-100), yAxis.unit for the tick labels, and zones - colored bands in range order: [{ "to": 60, "color": ... }, ...] (the remainder of the range renders light gray). Major ticks via yAxis.majorTick or the automatic nice step. Sample: chart-extra.
Samples
chart-line (line + ticks + grid + markers + value labels + legend), chart-polar (smoothed true polar), chart-pie (pie + doughnut), chart-radar (spider with fill), chart-bar (stacked / 100% / horizontal), chart-extra (stacked area, bubble, heatmap, gauge), chart-reflines (reference/threshold lines + shaded bands, horizontal & vertical), chart-error-bars (symmetric + asymmetric error bars on line, bar and scatter series). Also dashboard, annual-report, newsletter embed charts in larger tagged documents.
# Rich content: code, callouts, diagrams and math
Three block types that bring the "technical-document" look of on-screen Markdown/HTML into the PDF: syntax-highlighted code, semantic call-outs, and diagram-as-code. Samples: sample/syntax-highlight.json, sample/callouts-semantic.json, sample/mermaid-diagram.json.
Syntax-highlighted code (code / pre)
pre and code are the same block. Plain text renders verbatim monospace as before. Add a tokens stream (one inner array per source line, each a colored span) and the block becomes a highlighted code panel - the engine paints a panel behind every line, optionally numbers the lines, and can emphasize individual lines.
{ "type": "code", "language": "python", "lineNumbers": true, "highlightLines": [3],
"tokens": [
[ {"text":"def ","color":"#CF222E"}, {"text":"greet","color":"#8250DF"}, {"text":"(x):","color":"#1F2328"} ],
[ {"text":" ","color":"#1F2328"}, {"text":"# hello","color":"#6E7781"} ],
[ {"text":" ","color":"#1F2328"}, {"text":"return ","color":"#CF222E"}, {"text":"x","color":"#1F2328"} ]
] }| field | meaning |
| tokens | array of lines; each line an array of {text, color?} spans (or bare strings). Whitespace lives inside the tokens so the source reconstructs exactly. Supersedes text. |
| language | informational label (does not affect layout) |
| lineNumbers | draw an aligned monospace line-number gutter (default false) |
| highlightLines | 1-based lines to emphasize with a background band |
| panelColor | panel fill (default light gray #F6F6F8) |
| highlightColor | emphasis-band color (default soft blue) |
Do the highlighting upstream. The engine does not tokenize source - the host (PageWright Studio, a build step, Prism/highlight.js) supplies the colors. This keeps the engine simple and lets the highlighter be as good as a real one. The panel/gutter/bands paginate with the code and are tagged as artifacts, so tagged output stays PDF/UA-clean.
Semantic call-outs (callout)
A GitHub/Obsidian admonition as a set-off box: a variant preset supplies the accent color, tint and label; the body is ordinary content. It desugars onto the standard container box (left accent bar + tinted panel + padding), so it paginates like any container - but it does not tag like one. A plain container emits no structure element at all; a call-out emits a real <Aside>, the PDF 2.0 type for content outside the main narrative, role-mapped to Div for readers that predate PDF 2.0 and namespaced under PDF/UA-2. A split call-out re-opens its <Aside> on every page it continues onto.
Any container can do the same by declaring "role": "aside" (or section / div / blockquote); the call-out simply sets it for you.
{ "type": "callout", "variant": "warning", "text": "This step cannot be undone." }{ "type": "callout", "variant": "tip", "title": "Worked example",
"body": [ { "type": "paragraph", "text": "Any blocks go here." },
{ "type": "list", "items": ["a", "b"] } ] }| variant | color | default label |
| note | blue | NOTE |
| info | blue | INFO |
| tip | green | TIP |
| success | green | SUCCESS |
| important | purple | IMPORTANT |
| warning | amber | WARNING |
| caution | red | CAUTION |
| danger | red | DANGER |
Other fields: title/label (override the label), icon (glyph prefixed to the label), showLabel:false (suppress the label line), body/blocks (child blocks) or text (single-paragraph shorthand), plus any style keys (which override the preset). type:"admonition" and type:"note" are accepted aliases.
Diagram-as-code (mermaid / diagram)
Mermaid renders to SVG through a browser engine, so the PageWright engine does not run it. Instead the mermaid block prefers a pre-rendered SVG (which the host produces via the mermaid CLI mmdc, or any renderer) and falls back to a captioned source panel when no SVG is supplied - so the diagram is never lost.
{ "type": "mermaid", "alt": "flow", "width": "45%",
"content": "<svg ...>...</svg>" }{ "type": "mermaid",
"source": "flowchart LR\n A[Start] --> B[Done]" }| field | meaning |
| content / svg | pre-rendered inline SVG markup -> rendered as vector art |
| src | path to a pre-rendered SVG file |
| source | the diagram source; shown as a captioned code panel when no SVG is present |
| alt, width, height | as for the svg block |
Host bridge: to render diagrams, convert the fenced source to SVG before handing the spec to the engine - e.g. mmdc -i diagram.mmd -o diagram.svg (from @mermaid-js/mermaid-cli) and set content/src. Without a renderer the source panel is the graceful degradation.
Mathematical equations (math block + inline math run)
Accessible math, done the way PDF 2.0 intends. The visible equation is a pre-rendered SVG - the host produces it from Presentation MathML with MathJax - and the MathML itself travels into the PDF as a /Supplement associated file on a Formula structure element. So the drawn glyphs are vector-crisp and assistive technology, reflow and export read the real formula, not a picture. This is the mechanism ISO 32000-2 and the PDF Association's Well-Tagged PDF / Math in PDF guides prescribe for math.
Set-off (display) equations are a block:
{ "type": "math",
"mathml": "<math xmlns='http://www.w3.org/1998/Math/MathML'>...</math>",
"content": "<svg ...>...</svg>",
"alt": "x equals minus b plus or minus the square root of b squared minus four a c, all over two a",
"align": "center" }Inline equations ride on a run inside a paragraph, baseline-aligned in the text flow:
{ "type": "paragraph", "runs": [
{ "text": "Einstein's " },
{ "mathml": "<math ...>...</math>", "mathSvg": "<svg ...>...</svg>", "mathAlt": "E equals m c squared" },
{ "text": " relates mass and energy." }
] }| field (block) | run key | meaning |
| mathml / mathML | mathml / mathML | Presentation MathML -> the /Supplement associated file on the <Formula> |
| content / svg / src | mathSvg | the pre-rendered SVG (drawn as vector art) |
| alt | mathAlt | a spoken reading (the <Formula> /Alt) |
| width / height / align | - | as for the svg block; a display equation defaults to a little space above and below |
Sizing. A display equation renders at its natural size (scaled to the body font, capped to the column); an inline equation is sized from the SVG's ex metrics and split across the text baseline using MathJax's vertical-align, so subscripts dip and superscripts rise without disturbing the line. When an inline equation is genuinely tall, the line's leading opens up locally so nothing collides.
Do the rendering upstream - and it's lighter than the diagram bridge. MathJax is pure JavaScript (no headless browser), so it runs in a build step under Node or inside a WKWebView with no external process. Configure the SVG output with fontCache: 'none' so every glyph is inlined as a <path> (no <use>/<defs>), which is exactly the SVG subset the engine draws. The bridge takes LaTeX or MathML and emits both the SVG and the MathML - author in $...$ LaTeX (what Markdown uses) while the PDF still carries MathML for accessibility. With no rendered SVG the block degrades to its alt text, so the equation is never lost. Sample: sample/math-equations.json.
# Article threading
PageWright can flow one story through a thread of placed boxes on non-contiguous pages - the magazine idiom where a feature opens on page 4 and is continued on page 14. You supply a pool of free-standing stories and a set of frame blocks that pull from them; the engine fills each frame's column(s), carries the overflow into the next frame in the thread, and stamps hyperlinked "(Continued on / from page N)" jump lines - all PDF/UA-tagged, so the result still passes veraPDF --flavour ua1.
| you supply | the engine does |
| stories - named block lists, not placed in normal flow | holds them in a per-document pool, ready to pour |
| frame blocks referencing a story by id | fills each frame's columns from where the previous frame stopped; threads the overflow forward |
| continued / continuation flags | draws "(Continued on / from page N)" with N a live GoTo link to the neighboring frame's page |
Stories
A top-level stories value holds content that is not laid out in the normal section flow - it only appears where a frame pulls it in. Either an array of { id, blocks }, or an object keyed by id:
"stories": [
{ "id": "feature", "blocks": [
{ "type": "heading", "level": 3, "text": "A 44,000-Mile Commute" },
{ "type": "paragraph", "text": "Every year the Arctic tern..." }
] },
{ "id": "sidebar", "blocks": [ { "type": "paragraph", "text": "By the numbers..." } ] }
]A story is an ordinary block list - paragraphs, headings, images, lists, tables, even charts all thread. Its blocks keep their JSON Pointer (/stories/0/blocks/1) for the layout map, and they are scanned for the index/glossary like body content.
Frames
A frame is an absolutely-positioned box that pours a story into its column(s):
{ "type": "frame", "story": "feature",
"columns": 2, "columnGap": 24,
"continuation": true, "continued": true,
"position": { "x": 48, "y": 110, "width": 516, "height": 320 } }- story - the id of the story this frame draws from.
- position - required. The frame is placed at fixed page coordinates (points, top-left origin) and consumes no flow height, so it sits predictably on its page.
- columns / columnGap - text columns inside the frame (default 1 / 18). The frame fills column 0 top-to-bottom, then column 1, ...; whatever does not fit stays in the story for the next frame.
- continued - draw "(Continued on page N)" at the bottom when the story overflows this frame and another frame follows in the thread.
- continuation - draw "(Continued from page N)" at the top (skipped on the first frame of a thread).
- fromText / toText - override the default phrases; {{page}} is replaced by the linked page number (e.g. "continued on p.{{page}}").
- style - a border / background / padding turns the frame into a boxed sidebar or pull-quote; the story flows inside the padding.
- id - an optional cross-reference anchor for the frame itself.
Thread order is document order. Every frame that names the same story forms one thread, ordered as the frames appear (section by section, then block by block). The first frame opens the story; each subsequent frame resumes exactly where the previous one stopped - including mid-paragraph.
Jump lines
When a feature jumps to a non-contiguous page, the reader needs a pointer. With continued / continuation set, the engine writes "(Continued on page 14)" / "(Continued from page 4)", and the page number is a live internal link that jumps straight to the neighboring frame. The number resolves through the engine's cross-reference machinery, so it is a properly tagged <Link> (PDF/UA), exactly like a {{ref}}. Pages that are facing (a feature flowing from a left page to its right page) usually omit the flags - the story simply continues, with no jump note.
How it works (threading + forward references)
Frames ride the existing absolute-placement path; there are no changes to the paginator. The mechanism is a small amount of shared state:
- A per-document story pool holds, for each story, a resumable cursor (the same GroupCont continuation block the engine uses for nested content: a resume index plus a partial leading block). When a frame places, it runs a column loop that calls the cursor, then stores the returned remainder as the new cursor.
- The pool is reset at the start of every layout sweep, so the counting passes and the final render pour identical text into identical frames - the master story blocks are never mutated.
- The jump-line page numbers are forward references (frame k points at frame k+1, which is placed later). They resolve in the same fixed-point counting loop as the Table of Contents: each frame records an internal anchor for its page; the jump number is a cross-reference run to the neighbor's anchor; the loop repeats until the page numbers stop moving, then the final pass draws the text and emits the link annotations.
Because the jump line always reserves one line whenever its flag is set, the column fill height is identical across passes - only the printed number refines, so the layout converges quickly.
Variable column width
Frames in one thread may use different column widths: a story can open in a single wide column on page 1 and continue in three narrow columns on page 4, and the straddling paragraph re-wraps to each frame's measure. The engine flows story paragraphs as re-breakable word tokens (a WrappedFlow remainder) rather than fixed pre-broken lines, so the leftover carried from one frame re-breaks at the next frame's column width. (Body content outside frames is unaffected - it keeps the widow/orphan line path.) Page height may vary freely too. Sample: thread-variable (wide single column -> three narrow columns).
Every script threads
A story paragraph is set exactly as a paragraph in the body is - only the breaking differs (line by line, at each frame's width). The same passes run over it before any line is broken, in the same order (PrepareParagraphTokens, shared with the body's line builder): fallbackFont, the Unicode bidirectional algorithm with Arabic shaping, and Devanagari shaping. Each line then gets the paragraph's direction and its visual order, so a Hebrew or Arabic story starts at the RIGHT edge of every frame it reaches; a Hindi story is shaped; a Korean, Chinese or Japanese story under a Latin face draws in the fallbackFont. What carries on into the next frame - a frame's next column, a frame on another page, a frame of another width - keeps its direction and is never reordered a second time. A paragraph inside a story's list item or table cell is set the same way. The frame's own style.direction and lang reach its story as they would a container's children.
A word wider than its frame is cut one line at a time (the emergency wrap: at a hyphenation point where hyphenate allows one, blind otherwise), at the width of the line it lands on and from what is left of it - never at the first frame's width for good. So a word cut in a 60 pt frame and continued in a 348 pt one is drawn WHOLE there, a word that fit a wide frame is never cut again in the narrower one after it, and in a frame the width of a body column the chunks are the ones the body would draw. This is the one thing that changed for Latin story text on 2026-10-18: until then the story route never cut a word, so a long URL or part number ran straight past the frame's edge; it now wraps. A word carrying the author's soft hyphens is cut through them, so each piece quotes exactly its own letters wherever the frames cut it (since 2026-10-19; before, every piece after the first mark quoted the wrong ones). A paragraph with a colour emoji is lowered by the room its ink needs (ReserveColorInk), and that room is counted against the frame: a line goes into a frame only if it fits there lowered - in its slot, whose leading takes the lowering first, or lower only where the lowered line outgrows the slot.
Until 2026-10-18 none of that ran in a frame: a Hebrew story drew each word mirrored and flush left, Arabic unjoined, Hindi unshaped, and CJK under fallbackFont as .notdef. A regression check sets every script in one frame, in a frame's columns, onto the next page and through three frames of different widths, and compares each piece with the same text set as a plain paragraph. Each story line steps its own pitch, as a body line does ("Inline runs: styled text inside a paragraph": a run larger than its paragraph opens the line it is on), and a line a colour emoji lowers steps its lowered depth where that is deeper - the body's own steps. Still not applied to story text: blockAlign, and a firstLineIndent or keepIndent (the frame route ignores both, in every script).
Two caveats. Paragraphs re-flow; a table in a story keeps the column widths declared in its own spec, so it does not auto-fit a narrower frame - give threaded tables a width that fits the narrowest frame. And a story paragraph that carries a footnote draws its marker, but footnote vertical reservation inside an absolute frame is best-effort (frames are fixed-height); keep footnoted text in the body.
If a story has more content than its frames can hold, the leftover is simply dropped (copyfitting): shrink the opener frame to push more text down the thread, or add a frame.
Tagging
- Story content is tagged exactly like body flow - <P>, <H3>, <Figure>, <Table> - wherever it lands, including across a frame break.
- Each jump line is a <P>; its page number is a <Link> with an OBJR to the GoTo annotation (PDF/UA Link + /Contents). The boilerplate words are excluded from the index/glossary scan.
- Validated with veraPDF --flavour ua1 (0 failures).
Sample
article-threading - a 3-page feature ("The Pole-to-Pole Tern") that flows single-column -> two-column -> single-column with "(Continued on / from page N)" jump links, plus a boxed "By the Numbers" sidebar pulling a second story. The magazine-16pg and newsletter-4pg publications thread features across non-contiguous pages alongside charts, tables, an index, footnotes and barcodes. Validated veraPDF --flavour ua1 0 failures; the jump numbers are live GoTo links, CLI == server.
# Indexes and glossaries
PageWright can auto-generate a hyperlinked index and an auto-linked glossary as end-matter. Both are ordinary blocks you place where you want them (normally last); the engine scans the body, resolves page numbers / definition locations across a fixed-point counting pass, and emits internal-link annotations - all PDF/UA-tagged, so the result still passes veraPDF --flavour ua1.
| block | what you supply | what the engine generates |
| index | a list of words/phrases | an alphabetized, two-column, letter-grouped list with a hyperlinked page number per occurrence (jumping to the exact spot) |
| glossary | term + definition pairs | the rendered term/definition list and a link from the first body use of each term to its definition |
Index
{
"type": "index",
"title": "Index",
"columns": 2,
"letterHeadings": true,
"collapseRanges": true,
"terms": [
"ATP",
"Cytoplasm",
{ "term": "Endoplasmic reticulum" },
{ "term": "Membrane", "match": "membrane", "subentries": [
{ "term": "nuclear", "match": "nuclear membrane" },
{ "term": "plasma", "match": "plasma membrane" }
] }
]
}terms (or words) - each entry is either a bare string (label = search text) or an object:
- term (or label) - the text shown in the index.
- match - the text searched for in the body (defaults to term). Use it when the display label differs from the words on the page (e.g. label nuclear, match nuclear membrane).
- an explicit "match": "" makes the entry label-only - never scanned (no page list of its own), rendered as a group header when its subentries have hits. Use it for grouping parents whose own name would match all over the document.
- subentries - one level of nested entries (indented under the parent, each with its own match + page list).
- lang - the language of the label, a BCP 47 tag ({"term": "Weltanschauung", "lang": "de-DE"}), for a screen reader. Without it the entry reads in the language of the term's first occurrence (see Language under Tagging); with it, in this one. Tagged documents only.
- columns - 1 or 2 (default 2). The block manages its own columns and balances them (roughly equal height), so place the index in a single-column section/page.
- letterHeadings - insert A / B / C ... dividers between alphabetical groups (non-alphabetic terms group under #). Default true.
- collapseRanges - show consecutive same-format pages as a range, 5-7 instead of 5, 6, 7. Default true.
- collation - the sort order: "unicode" (default, code-point order), "pinyin" (Chinese terms by romanized reading - the Simplified-Chinese norm), or "strokes" (by stroke count, then radical - the Traditional-Chinese norm). The pinyin readings and stroke counts come from an embedded Unihan-derived table, so no external data is needed. With pinyin or strokes the group dividers become syllable or stroke-count headings; strokeLabel sets the character appended to a stroke-count heading (畫 gives 13畫). One collation per document. Samples: index-pinyin, index-strokes, and the two cnbook editions.
- sortAs (per entry) - an explicit sort key that overrides the table in every collation. Required for 多音字 whose dictionary reading differs from the character's first reading (长江 sorts under C for Cháng only with "sortAs": "changjiang") - the same escape hatch as InDesign's Yomi field or makeindex's key@display.
- title - an optional bold heading above the entries.
Matching
- Whole-word, case-insensitive. Surrounding punctuation is stripped (Reticulum, matches reticulum); the display label keeps the spelling you gave.
- Multi-word phrases match across token and line breaks (Endoplasmic reticulum is found even when it wraps). CJK terms match per character.
- No stemming. cell does not match cells; add a separate entry (or a match variant) if you need the plural.
- A term that never occurs in the body is dropped from the index.
- The page number links to the first occurrence on that page (the exact y), not just the page top. Headings are indexed; the TOC, the index itself, the glossary, and footnotes are not scanned.
Glossary
{
"type": "glossary",
"title": "Glossary",
"autoLink": true,
"entries": [
{ "term": "ATP", "definition": "Adenosine triphosphate - the cell's unit of energy." },
{ "term": "Cytoplasm", "definition": "The fluid that fills a cell outside the nucleus." }
]
}- entries - term + definition pairs. They are sorted alphabetically (case-insensitive) and rendered as a tagged list: the term in bold, then the definition (wrapping as needed). Each entry is one <LI> (kept whole on its page). An entry may add lang, the language of its term ({"term": "raison d'être", "definition": "...", "lang": "fr-FR"}); the definition stays in the glossary's language.
- autoLink (default true) - the engine scans the body and turns the first occurrence per document of each term into an internal link to its definition. Set false to render the list without touching the body.
- title - an optional bold heading.
First-use-per-document keeps the body readable: a term that appears fifty times is linked once, at its first mention. An occurrence that already carries an explicit link is left alone, and phrase terms link only within a single line.
How it works (forward references)
The index needs page numbers, and the glossary is referenced by the body - both are forward references the same way a Table of Contents is. The engine reuses the TOC's fixed-point counting loop:
- A CountingTarget lays the whole document out without drawing, while a rolling word-buffer in the text path records every index-term occurrence (AddIndexHit) and every glossary definition's landing spot (AddGlossaryAnchor).
- The hits are aggregated - sorted, range-collapsed, page-labeled via the numbering plan - and the index's real height is fed back into the next counting pass (so the pages after the index, e.g. the glossary, land correctly).
- The loop repeats until the TOC pages and the index render are both stable (usually 2-3 passes), then the final pass draws everything and emits the link annotations.
Because the index/glossary sit at the end, they never perturb the body pagination that determines the page numbers they show.
Tagging
- Index entries and letter headings are tagged <P>; each page number is a <Link> with an OBJR to the annotation (PDF/UA Link + /Contents).
- The glossary is an <L> of <LI>/<LBody>; body auto-links are <Link> spans.
- Language (2026-10-13). An index entry whose label is the body text it matches reads in the language of the term's first occurrence (its run's lang, else its paragraph's, else its box's, else the document's) - wherever the index sits, a lang box included. A label with a match of other words is the author's own wording and reads as the index, as do group headers and letter dividers. A glossary term reads in the language of its first use (the one the body links), as a <Span> written only where that differs from the glossary; its definition reads as the glossary. The block's own "lang" governs its title, dividers, definitions and every element it opens, on every page. The index's own text inside an entry keeps the index's language where the entry reads in another one. This covers a Hanzi page number (a <Link> in the index's language) and a split entry's English " (continued)" (a <Span>, en inside a non-English box or index "lang").
- A term's own "lang" (2026-10-13) wins over all of that: an index term's names its label - the entry reads in it even where the label is your own wording or a group header, and a sub-entry takes its own, not its header's - and a glossary entry's names its term, never the definition (a glossary defines words from other languages in the reader's; a picture's lang likewise names its alt and not its caption). "de_DE" is written de-DE; a value that is no language tag ("x!!bad") is ignored, and the entry reads as it would without one. A repaired or ignored spelling is reported on stderr once per spelling, naming the first term that carries it; an untagged document writes no /Lang and reports nothing. A lang equal to the block's own writes nothing.
- Everything is validated with veraPDF --flavour ua1 (0 failures).
Sample
index-glossary - a front TOC, a multi-page body, then a two-column index (phrases, ranges, sub-entries, letter headings) and an auto-linked glossary. Validated veraPDF --flavour ua1 0 failures; 21 internal links (TOC + index page numbers + glossary first-uses), CLI == server.
# Placing existing PDF pages
PageWright can drop a page from an existing external PDF onto the layout. The page is imported as a Form XObject - vector, never rasterized - so it scales crisply. Two blocks use it:
| block | what it places |
| one PDF page, sized / floated / captioned exactly like an image | |
| pdf-contact-sheet | many PDF pages tiled into a paginated thumbnail grid |
Both reuse the proven page->Form-XObject path in the ap repo (combine the source page's content streams, deep-copy its /Resources into the output, set /BBox to the CropBox, and normalize the CropBox origin + /Rotate into the form's /Matrix), then emit the placement transform into the page content like a placed image. Each placed page is a PDF/UA <Figure> with /Alt. A placed page shows what a print of it shows, so its printable annotations come with it: see Printable annotations.
pdf - a single placed page
{ "type": "pdf", "src": "chart.pdf", "page": 1,
"width": "60%", "fit": "contain", "maxHeight": 300,
"align": "center", "float": "right",
"caption": "Figure 1. Quarterly results.", "alt": "A bar chart." }- src - path to the source PDF. A relative path is resolved against the spec file's directory and made absolute (APDFL's PDDocOpen cannot open a path relative to the working directory), so a placement works from any current directory - exactly like an image src. An absolute path is used as-is.
- page - 1-based page index (default 1).
- width / height / maxHeight / fit / align / float / caption / alt - identical to the image block. The intrinsic size is the source page's CropBox (with /Rotate applied), so the aspect is preserved automatically. float is left / right / center, with text wrapping around it.
- focus / zoom - aim a cropping fit exactly as an image's do ("Vector graphics, images and SVG", "Focus and zoom"): focus [x, y] is the point of the page, 0..1 across and down, that a cover or none crop keeps in view; zoom (>= 1) magnifies a cover. Either may be a "{{path}}" data token. On a fit that crops nothing they are refused out loud. "fit": "none", "focus": [1, 0] places a page at 100% and keeps only its top-right corner in a box of any size - how the PhotoCard mail template puts a whole-envelope postage page on a card, with no clipping container and a box that is exactly the postage corner:
{ "type": "pdf", "src": "postage.pdf", "fit": "none", "focus": [1, 0],
"position": { "x": 360, "y": 0, "width": 234, "height": 108 } }pdf-contact-sheet - a thumbnail grid
{ "type": "pdf-contact-sheet", "title": "Contents of report.pdf",
"src": "report.pdf", "pages": "all",
"columns": 4, "gap": 12,
"labels": true, "frame": true, "uniformCells": true }Or mix pages from several PDFs with an explicit items list:
{ "type": "pdf-contact-sheet", "columns": 3,
"items": [
{ "src": "cover.pdf", "page": 1, "label": "Cover" },
{ "src": "report.pdf", "page": 4 },
{ "src": "appendix.pdf", "page": 2, "label": "Appendix A" }
] }- src + pages - single-source shorthand. pages is "all", a range "1-5", a list "1,3,5" or [1,3,5], or a single page. (The page count for "all" is read from the source at layout time.)
- items - explicit cells, each { src, page, label? } (a bare string is just a src with page 1). Use this to combine pages from different files.
- columns (default 4), gap (points between cells).
- labels (default true) - a small caption under each thumbnail ("p. <page>", or the item's label).
- frame (default true) - a thin border around each cell.
- uniformCells (default true) - fit every page into one identical cell box (so portrait + landscape pages line up). With false, each row's height follows its own pages' aspect.
- The grid paginates row by row across the document's pages; an optional bold title sits above it. Each cell is a <Figure> with a per-page /Alt ("Page N of file.pdf").
Behavior & limits
- Rotation & CropBox. The source page's /Rotate and CropBox (not just MediaBox) are honored - a rotated or cropped source places upright and tight.
- Caching. Each source PDF is opened once and reused for every placement, then closed when the document is saved - important for a contact sheet of a long PDF.
- Missing / unreadable source. Draws a light placeholder box instead of failing, and says why - once per block, on the final pass, with the block's pointer (and (in record N) under -merge), in the sentence every unreadable file uses (see "Vector graphics, images and SVG", Files that cannot be read): aplayout: placed PDF "report.pdf" cannot be read (<why>) - a placeholder box is drawn (at /blocks/2). The reason is not found, permission denied, it is a folder, not a file, empty file, not a PDF file (and what it is instead, e.g. a PNG), the PDF is encrypted and needs a password to open (a PDF whose user password is empty opens and places as usual), or the PDF could not be opened: <APDFL's reason> for a damaged one. This replaces the old warn: DrawPdfPage could not open source PDF '<full path>' (page N) line, which gave no pointer to click, no reason, and one line per placement - a template, one per page.
- A page the source does not have is the placeholder too: PDF page 7 does not exist, it has 1. It used to be clamped to the last page and drawn in silence - page 1 under an /Alt (a contact-sheet cell's) that still said page 7. The box is still sized from the last page.
- Template and contact sheet. A pageSetup.template that cannot be read fills every page with the placeholder (a Page artifact, under the content) and says so once, at /pageSetup/template (a section's, page's or master's own pointer when it set one). A contact sheet whose src cannot be read draws nothing - it used to vanish without a word - and now says contact sheet PDF "deck.pdf" cannot be read (...) - the sheet draws nothing; a page its pages asks for that the source lacks is left out of the grid, as before, and said (that page is left out of the sheet); an explicit item whose file or page cannot be placed is a placeholder cell under its <Figure>, and said.
- Fonts & PDF/UA / PDF/A. Placement imports the page as-is, including its fonts. For an ordinary (untagged) output there are no constraints - place any PDF. For a tagged PDF/UA or PDF/A output, the placed page must itself be compliant: its fonts must be embedded (an unembedded base-14 source breaks PDF/UA 7.21.4.1, which the engine cannot fix), and the source should be untagged (or already-conformant) so it brings no foreign structure. PageWright does fix one common snag - it strips the inconsistent /CIDSet from the imported CID fonts too, not just its own - so an embedded-font source places into a tagged doc and still passes veraPDF --flavour ua1. The bundled sample sources are generated with embedded fonts for exactly this reason.
Printable annotations
A placed page shows what a print of that page shows: its content and its printable annotations - a form field's border and background, the value a filled form carries, the check in a checked box, a stamp, a comment's appearance. Until 2026-11-02 only the content came across (annotations are not content), so a filled form placed as a pdf block, a contact-sheet cell or a pageSetup.template printed blank where its answers were, and nothing said so. Found imposing the two brochures as 11x17 booklets: the pages that hold fields differed from the viewer's render in 10,053 and 38,563 pixels. They now place pixel-identical to it.
| what | the rule (ISO 32000-1) |
| which annotations | Print set and Hidden clear (12.5.3). NoView is a screen flag: "hidden but printable" is drawn, "visible but doesn't print" is not. |
| which face | the annotation's own normal appearance, /AP /N; when /N is a dictionary of states, the face its /AS names (a checkbox's /Yes or /Off). Never /D or /R, and never a face rebuilt from the field's value. |
| where | 12.5.5: the face's /BBox through its /Matrix, the upright box around that, and a matrix A that maps the box onto /Rect. A is the CTM; the face's own /Matrix applies inside it. |
| NoRotate | on a page with /Rotate, the face stays upright and pivots on its /Rect's upper-left corner in default user space - the corner APDFL's own renderer pins. |
| NoZoom | ignored: it is a viewer's rule, and a placed page scales like any print. |
| resources | the face's own /Resources and nothing else. A face with none gets none: there is no fallback to the form's /DR, which nothing in the spec gives an appearance stream. |
| clipping | the page's crop box, as on paper. |
Left out, and said. A printable annotation with no face to draw is left out: one with no /AP (except a Link or a Popup, which have nothing to print without one), a /AS that names no face, or a /BBox, /Matrix or /Rect that is not numbers. The render says so once per page:
warn: placing '/path/form.pdf' page 3: 2 printable annotation(s) could not be drawn and are left out (no appearance stream: 1; no face for its /AS state: 1)
An annotation whose /Rect has no area - an invisible signature field's [0 0 0 0], which PDF/A still makes carry Print - shows nothing on paper either, and is not mentioned.
Faces that may be stale. A form whose /AcroForm sets /NeedAppearances true asks a viewer to rebuild its field appearances, so its stored faces may lag its values; FDFMerge's -norebuild merge leaves exactly that. The faces placed are the stored ones, and the render says so once per source file:
warn: placing '/path/merged.pdf': its form sets NeedAppearances, asking a viewer to rebuild the field appearances - the faces placed are the ones stored in the file, and may not show the fields' current values
Layers. An annotation in an optional-content group (an /OC entry) is drawn, and counted in the same flattened N optional-content section(s) warning as the page's own layers.
The faces are ink, not fields. The output carries no widget, no field and no annotation for them: a placed form is a picture of the filled form. To keep the fields live, move the page whole (apDocLib InsertPages) instead of placing it.
Tagged output: the faces belong to the placed page
Decision (2026-11-02): the faces are neither tagged on their own nor artifacts of their own. They are part of the placed page's ink and take its role. In a pdf block or a contact-sheet cell they sit inside the page's <Figure>, so its alt describes them - write one that states the answers if a reader needs them. In a pageSetup.template they sit inside the template's Page artifact, with the rest of the stationery.
- Not tagged on their own. The source's form structure - its <Form> elements and the OBJRs that tie them to their widgets - cannot come along, because the widgets do not: there is no annotation in the output for an OBJR to name, and the source page's own structure tree is already left behind (its marked content is stripped; see StripMarkedContent). A <Form> with nothing behind it would be a claim the file cannot keep.
- Not artifacts of their own. A filled value is content of the placed page, exactly what its <Figure> shows. Marking it an artifact would tell assistive technology it is decoration -- in a figure whose alt text may be the only place the answer is told.
- Their own marked content goes with the page's. A text field's face opens /Tx BMC; it is stripped like every other marked-content operator inside a placed page (ISO 14289-1 7.1). veraPDF ua1 passes on a form PageWright wrote, placed this way.
How it works
DrawPrintableAnnotsIntoSource runs at the top of DrawPdfPage, once per source page per render, on the source document in memory (the file is never written):
- It reads the page's /Annots and works out each printable face's matrix (12.5.5, NoRotate).
- The page's own content becomes a form of its own, /APLPage, so whatever state it leaves behind - an unbalanced q, a clip, a CTM - cannot reach the faces: Do saves and restores the whole graphics state. Its /BBox clips nothing (the media and crop boxes with an inch to spare): a second clip on the placement's own edge would antialias that edge twice.
- The page's content becomes /APLPage Do, then q A cm /APLAnnotN Do Q for each face, and its /Resources the /XObject dictionary that names them.
Everything DrawPdfPage then does to a page it does to the faces: the cmyk conversion (a face's RGB border reaches a PDF/X file as CMYK), the marked-content strip, the content-addressed font dedup, the dead-font prune, the imported /CIDSet fix and the embedding pass. A page with no printable face is not touched and places byte for byte as before - as every corpus sample does.
Fonts. A face brings the fonts it names. A viewer-built face often uses an unembedded /Helv (Helvetica) or /ZaDb (ZapfDingbats). In an embedding render (the default) they are embedded from the machine, like a placed page's own base-14 fonts, and a host that has no such face refuses the render as it does any unembeddable font; -fontdir supplies one. PageWright's own fields draw their checks as paths and, in a tagged form, their values in an embedded face.
Tested. A regression check writes its sources byte by byte - every case above, the same page holding only what a print shows, and the faces drawn as page content - and compares rasters at 144 dpi against apDocLib's pageRaster, which draws annotation faces as a viewer does. It places at 100% on a cropped page and on pages turned 90 and 270, as a template, at 50%, in a contact sheet, in cmyk and tagged; checks both notices; and places a form PageWright itself wrote under veraPDF ua1.
Sample
place-pdf - a centered placed chart, a floated chart with text wrap, and a contact sheet of a 6-page source. Uses the bundled sample/sample-graphic.pdf (one page) and sample/sample-pages.pdf (six pages), both with embedded fonts. Passes veraPDF --flavour ua1 with 0 failures.
# Barcodes
A barcode block renders any zint symbology as crisp vector primitives - no rasterizing, tiny output, sharp at any zoom or print resolution. The same zint build already ships inside StampPDF.
{ "type": "barcode", "code": "qr", "data": "https://example.com/doc/123",
"width": 100, "alt": "QR link to document 123" }Keys
| key | meaning |
| data (alias text) | the content to encode (required) |
| code (alias symbology) | symbology name - case- and punctuation-insensitive |
| width | display width in points (default 120 for square/2D symbols, 216 for wide/linear). Not for intelligentmail, which has a mandated size - see below |
| security (alias ecc) | zint option_1: QR ECC level 1-4, PDF417 security 0-8 |
| showText | the human-readable line, drawn with the document's own fonts (default true) |
| align | left (default) / center / right in the column |
| alt | tagged-PDF /Alt on the <Figure> - defaults to the encoded data |
| style/color | the block style's color inks the symbol (black default; spot/CMYK work) |
Symbologies
Every symbology in zint 2.14 is available by normalized name, including the aliases StampPDF users know:
- 2D/matrix - qr (qrcode), microqr, rmqr, datamatrix, pdf417, micropdf417, aztec, azrune, maxicode, dotcode, hanxin, gridmatrix, codeone, ultra (color), upnqr, mailmark2d.
- Linear - code128 (128), code39 (39), code93, excode39, gs1-128, codabar, telepen, msi, plessey, code11, code32, interleaved2of5, itf14, channel, bc412, vin, pzn, pharma.
- Retail - ean (EAN-8/13 by data length), isbn, upca, upce, ean14, GS1 DataBar databar / databarlimited / databarexpanded (+ stacked variants).
- Postal - postnet, planet, intelligentmail (onecode), royalmail (rm4scc), kix, auspost, japanpost, koreapost, mailmark, cepnet, daft, dpd, upus10.
- Stacked / composite / HIBC - code16k, code49, codablockf, the *cc composite forms, and the hibc* health-industry variants.
Rendering
zint's vector buffer maps straight onto the engine's primitives: bars and modules become rectangles, MaxiCode's field becomes hexagons and its bullseye rings, DotCode dots become discs, and Ultracode's colored modules keep their palette. The human-readable line is not zint's font: the engine draws it with the document face at zint's position and size - so it embeds and subsets like all other text (PDF/A + PDF/UA clean), including EAN/UPC's split digit layout. Unknown symbology or bad data renders a light placeholder box and says so on stderr - aplayout: barcode "..." cannot encode its data (<zint's reason>) ..., with the block's pointer (the JSON-Schema flags symbology typos at validation time).
The USPS Intelligent Mail barcode is drawn at its mandated size
A sorter reads the IMb's bar heights, and USPS-B-3200 fixes them in inches, so intelligentmail (onecode) is not scaled to a width like every other symbology. It is drawn at the specification's nominal dimensions (Rev H, 2.3.1, Figure 6): 65 bars 0.020 in wide on a 1/22 in pitch; full 0.145 in, ascender and descender 0.0965 in, tracker 0.048 in, on one centreline; 2.929 in long. The block is that plus the 2.3.2 clear zone - 0.125 in each end, 0.028 in above and below - 3.18 x 0.20 in in all. A width is ignored with a notice, and a box narrower than the symbol is said too. (Scaled, it was out of spec almost everywhere: the default 216 pt gave 0.173 in full bars against a 0.165 in maximum.) zint draws no human-readable line for it; 2.4 puts one 0.028-0.5 in above or below the bars, which is the author's to place.
data is the 20-digit tracking code followed by the 0, 5, 9 or 11-digit routing ZIP, either run together (25 / 29 / 31 digits, as USPS prints it) or with a dash between (...4321-01234). A regression check decodes the four worked examples of USPS-B-3200 from the page, bar for bar.
Each barcode is an atomic block (defers whole to the next column/page) and a tagged <Figure> whose /Alt defaults to the data, so the encoded content is available to assistive technology.
Sample
barcode - a two-column gallery (QR, Code 128, Data Matrix, PDF417, EAN-13, UPC-A, Aztec, MaxiCode, DotCode, POSTNET, Code 39, Han Xin, QR @ ECC 4), tagged PDF/A-2a, veraPDF ua1 + 2a clean.
# Data-driven documents
A spec can carry a data record and bind its fields into the document, so one template renders many personalized PDFs - statements, letters, certificates, invoices. This covers all of Theme A: A1 (binding), A2 (repeat), A3 (conditionals), A4 (include), and A5 (batch).
The data context
Give the document a record, inline or external:
{ "data": { "customer": { "name": "Dr. Alice Reyes" }, "balance": "1,284.50" },
"blocks": [ ... ] }{ "dataFile": "statement-0481.json",
"blocks": [ ... ] }- data - an inline JSON object (a record). May also be an array (used by the forthcoming batch mode).
- dataFile - a path, relative to the spec, to an external record: JSON or CSV, chosen by the file's extension. Used when no inline data is given, so the template and the data live in separate files (the natural shape for generating many documents from one template). A .csv file is read with its first line as the header row and every field kept as a string. One data row binds as the record itself, so {{firstName}} resolves exactly as it would from a JSON object; two or more rows bind as an array, so the rows are addressed by index ({{0.firstName}}) or iterated with repeat, and the whole file is what -data ... -merge fans out over. Anything that is not .csv is read as JSON, unchanged: a JSON array stays an array however many elements it has, including one, so a repeat over a one-element [{...}] keeps looping.
A document with no data/dataFile is completely unaffected - binding is skipped entirely, so existing specs render byte-for-byte as before.
Binding tokens - {{ path }}
Any text in the document may contain {{ path }} tokens. Each is replaced with the addressed value from the record:
| Token | Resolves to |
| {{company}} | a top-level field |
| {{customer.name}} | a nested field (dot path) |
| {{invoices.0.no}} | an array element (numeric segment) - {{invoices[0].no}} works too |
| {{openInvoices}} | a number (integers print without a decimal point) |
Binding applies to inline-run text, link URLs, lang and note text (a run's footnote, endnote and sidenote - bound since 2026-10-13; a note in a repeat printed its raw template until then), image / PDF src and alt, an image's or a placed PDF page's focus and zoom (a "{{path}}" string in place of the numbers - see below), the caption of every captioned block - image, pdf, svg, table, chart and pre - in both its plain-string and its run-array form, barcode data and alt, form-field labels and values, the running header / footer band text, and the title / author / subject / keywords metadata (so a bound title flows on to {{title}} and the /Info dictionary).
Note: A run-array caption binds run by run, and its flat text is re-derived from the result. The flat string is what reaches the list of figures, the /Alt and the structure tree, so the two must be rewritten together - otherwise one figure ships two spellings, the page showing {{who}} and its own list-of-figures row showing the bound value.
The same rule governs the OTHER pre-pass - the document's conditions tags, which drop hidden runs before layout: strip a caption run and the flat text is re-derived, or the hidden words survive in the /Alt, the structure tree and the generated row. Sample: caption-runs.
An unresolved data token (a path not present in the record) becomes an empty string.
Numbers from data: an image's crop
focus and zoom are numbers, so a template aims one crop for every record - unless the key is written as a token: "focus": "{{cards.0.frontFocus}}", "zoom": "{{cards.0.frontZoom}}". A value that is exactly one token resolves to the whole value at that path (the include with rule), so a record's [0.3, 0.4], {"x": 0.3, "y": 0.4} or 0.3 arrives as numbers; text of plain decimals works too - "0.3 0.4", "0.3,0.4", "[0.3, 0.4]" - which is what a CSV field holds. A field the record lacks, null, "" or [] leaves the default (the centred crop, plain cover) silently. Anything else - words, three numbers, true, a focus outside 0..1, a zoom below 1 - is said on stderr with the block's pointer and, under -merge, the record, and the default (or the clamped value) is drawn. Sample of the shape: the PhotoCard templates. A pdf block's focus and zoom bind the same way, under the same rules.
Numbers from data: a block's position, size and transform
The same rules place a sticker per record. On every block type that takes position, any of x, y, width, height (or the w / h aliases) may be a token, and so may the whole position - "{{s.box}}", resolving to the record's {x, y, width, height} object (members may be text of numbers). So may the whole transform: angle, scale, skew, mirrorH and mirrorV.
{ "type": "repeat", "for": "stickers", "blocks": [
{ "type": "image", "src": "{{item.src}}", "fit": "contain",
"position": { "x": "{{item.x}}", "y": "{{item.y}}", "width": "{{item.size}}" },
"angle": "{{item.tilt}}", "scale": "{{item.scale}}", "mirrorH": "{{item.flip}}" } ] }- Transform spellings. scale and skew take what the written keys take - a number, an [x, y] array or an {x, y} object (members may be text of numbers) - or text of one or two plain numbers: "1.5", "1.5 0.8", "1.5,0.8", "[1.5, 0.8]". A bare number scales both axes and skews x only, exactly as written; [x] and {x} leave the other axis alone. Only an empty {} is absent: an object with members but neither x nor y ({"sx": 2}, {"X": 2}) is a misnamed record, reported like any other unusable value. A skew past ±89.9° is clamped, silently, as the written one is; no scale is refused (a written scale of 0 or below is drawn as written, so a bound one is too). mirrorH / mirrorV take a boolean, text "true" / "false" in any case (a spreadsheet exports TRUE), or 1 / 0 as a number or text - and nothing else: "yes" is a word, reported like any other.
- Spellings. A number, or text holding exactly one plain decimal ("72", " -12.5 ", a CSV field), or a token inside text ("1{{n}}0"). A literal string with no token is read at parse time, so it works in a document with no data. There are no units: a written position is points, so a bound one is too, and "1in" is reported like any other word. A token inside text needs its value: if the record has no n (or it is null or ""), "1{{n}}0" is not "10" - the whole value is absent, so an x or y is reported and not drawn and a width, height or any key of the transform falls back, as below ("1.{{n}}" is not a scale of 1).
- Per record. Tokens are resolved by the data pre-pass, so each repeat clone, include partial and -merge record is placed from its own values.
- Absent width, height or transform - a missing field, null, "" (and [] / {} for scale / skew) - means what the unwritten key means: the box sizes to its content, the block is upright, at its natural size, unskewed, unmirrored. Silently.
- Absent or unusable x or y is not 0. The block is not drawn (it still leaves the flow, as any positioned block does), and stderr says why, with the block's pointer and, under -merge, the record - e.g. position.x "{{s.x}}" is bound to "left", which is not a number - not drawn: a positioned block needs its x and y (in record 3) (at /blocks/0). A sticker in the corner of the card is a wrong page that looks deliberate; a missing one is noticed. It takes no number either: a footnote, heading number, figure (or table, listing, equation, chart) number, counter value or citation inside it is not counted, so the rest of the document numbers on without a gap - exactly as if a when had dropped the block.
- A written "" is a value, and it is nothing. "position": "", "x": "" and "y": "" are reported and the block is not drawn, exactly like " " or a record with no value - not drawn at the page corner, and not x 0. A written "" width, height or angle is the unwritten one, silently.
- Bad, said, fallen back: a width or height that is words or negative is reported and the box sizes as if none were given; an angle that is not a number is reported and the block is drawn upright; a scale or skew that is not a number or a pair (words, "NaN", "150%", three numbers, true) is reported and drawn at natural size / unskewed; a mirror that is not true or false is reported and drawn unmirrored - e.g. scale "{{s.k}}" is bound to "big", which is not a number or an [x, y] pair - ignored, the block is drawn at its natural size. Only that key falls back: the block is still drawn, with the rest of its transform. A token in a document with no data is reported (and a bound x / y then draws nothing; a bound height with no data then refuses clipPath, verticalAlign and shrinkToFit as a height of none does, below - ... whose bound height has no data to bind it - ignored, ... - so a clip shape is never built on a box with no height).
- A flow block that is not a container refuses a bound transform at parse time, exactly as it refuses the written one - whatever the record holds, it has nothing to turn about - and draws untransformed. A container in the flow takes it as a group.
- Everything downstream sees the numbers - layout, the map's fragments and transform / angle / pivot (an angle / pivot summary only when the matrix is a pure rotation, as for the written keys), TextShop records, the clip, a childOrigin container's composition, a textWrap band, link rects, the clip notice, verticalAlign in the box. The parser's height rules (verticalAlign, shrinkToFit, clipPath need a position.height) accept a bound height and wait for the record - and when the record's height is none (absent, null, "", or a whole position object without one), each key it cannot act on is reported then, as the literal one is at parse time: verticalAlign on a positioned block whose bound height is none - ignored, ..., and likewise shrinkToFit and clipPath.
- One entry, many placements. repeat clones and -merge records share their block's pointer, so the layout map gives them one entry - and each of its fragments carries that placement's own transform. The entry's transform / angle / pivot are the first turned placement's. Inside a turned container a fragment's transform is the whole page matrix, the container's included, for an upright placement as much as a turned one (see "Vector graphics, images and SVG", Groups); outside one an upright placement has none.
- Inside a positioned container a positioned child's coordinates are page coordinates, not the container's; a container with "clip": true does crop its positioned children; and a repeat in its body gives each clone its own position.
Data vs. the page variables
The existing render-time page variables - {{page}} {{pages}} {{date}} {{time}} {{datetime}} {{title}} - are resolved later, per page, by the running-band substitution pass. The data binding runs first, with this rule:
Note: Data wins. If the record contains a field of that name, the token binds to the data. Otherwise, a page-variable name is passed through untouched for the render-time pass; any other name becomes empty.
So {{page}} keeps meaning the page number (no record has a page field), while a record with its own date field makes {{date}} mean that date everywhere - including in the body, where the page-variable pass never reached.
Repeating content - repeat
A repeat block clones its blocks once per element of a data array, so a list of records becomes a list of rendered blocks (line items, cards, table-like rows):
{ "type": "repeat", "for": "order.items", "as": "item",
"blocks": [
{ "type": "container", "keepTogether": true, "style": { "border": { "color": "#ccc" } },
"blocks": [
{ "type": "paragraph", "text": "{{loop.number}}. {{item.name}}", "style": { "bold": true } },
{ "type": "paragraph", "text": "Qty {{item.qty}} x ${{item.price}}" }
] }
] }- for - a data path to the array to iterate.
- as - the loop-variable name bound to each element (default item), so inside the body {{item.name}} is the current element. Fields from the enclosing scope still resolve ({{order.id}}, {{company}}).
- loop - a helper available inside the body: {{loop.index}} (0-based), {{loop.number}} (1-based), {{loop.count}}, {{loop.first}}, {{loop.last}}.
The whole template subtree is cloned (containers, lists, tables, nested repeats all work). A missing or non-array for path expands to nothing. Because each clone is bound at the model level - never by re-serializing JSON - a value containing a quote or backslash (e.g. a product name 21" riser) is safe and cannot corrupt the structure.
Conditionals - when / unless / if
Show or hide content based on the data. Any block takes a when and/or unless attribute - the block is kept only when when is true and unless is false:
{ "type": "paragraph", "text": "Your balance is past due.", "when": "balance > 0" }
{ "type": "paragraph", "text": "As a Gold member...", "when": "tier == 'gold'" }
{ "type": "container", "unless": "autopay", "blocks": [ ... ] }For an either/or, an if block emits its blocks when the test is true, else its else:
{ "type": "if", "test": "balance > 0",
"blocks": [ { "type": "paragraph", "text": "${{balance}} is PAST DUE." } ],
"else": [ { "type": "paragraph", "text": "Paid in full - thank you!" } ] }Predicate (when / unless / test) - deliberately small and safe (field lookups and comparisons only, no code):
- a bare path -> its truthiness ("autopay", "customer.vip"); false for missing / null / false / 0 / "" / an empty array or object;
- path OP value with OP one of == != < > <= >=; the right side is a number, a 'quoted' string, true / false / null, or another path;
- a leading ! negates ("!autopay").
Gating happens during the same pre-pass, so a hidden block (and its whole subtree) is removed before layout - it costs nothing and never enters the structure tree.
Reusable partials - include
Factor shared content (a letterhead, an address block, a signature) into a partial file and pull it in with an include block:
{ "type": "include", "src": "partials/letterhead.json" }
{ "type": "include", "src": "partials/address-block.json", "with": { "addr": "{{billTo}}" } }- src - a path, relative to the spec, to a partial: a single block, an array of blocks, or { "blocks": [ ... ] }.
- with - optional fields added to the data scope for the included blocks. The enclosing context still resolves ({{company}}), and a with value that is a single "{{path}}" token resolves to the whole value at that path - so you can pass an object: "with": { "addr": "{{billTo}}" } lets the partial use {{addr.name}}, {{addr.city}}, .... The same partial can be included repeatedly with different with.
Includes nest (a partial may include another, up to a depth guard that catches cycles), and a missing/unreadable partial warns to stderr and contributes nothing rather than failing the render.
Batch - one template, many PDFs (CLI)
The CLI renders a template once per record from a data set:
pagewrightapp template.json out.pdf -data records.json # -> out-1.pdf, out-2.pdf, ... pagewrightapp template.json out.pdf -data records.csv # CSV: header row = field names pagewrightapp template.json out.pdf -data records.json -merge # one PDF, one section per record
- -data <file> - the records: a JSON array (one element per record), a single JSON object (one record), or a CSV file (the header row names the fields; each data row is one record of string fields). Each record replaces the template's data context, so {{name}}, {{customer.city}}, ... bind per record.
- Without -merge, each record writes its own out-<n>.pdf. With -merge, every record's content is concatenated - one section (a fresh page) per record - into a single out.pdf. The merged document is the first record's document: every top-level setting (metadata, images, colorMode / pdfx, security, numbering, canvas, ...) comes from it, exactly as a single render of that record would use it, and attachments and declarations come once, from it. Each record brings its own master frames and threaded stories, renamed per record (the book's rule), so a frame bound to {{name}} shows each record's name on that record's pages. -recordrange N-M selects the records to merge.
- A notice about a block in a merged record names the record before its pointer - aplayout: ... (in record 3) (at /blocks/1) - and each record reports on its own.
- -map writes the merged document's layout map (one document, one map; a path placed once per record has one fragment - and, for an image, one images record - per page). Without -merge there is no single document, so -map is not written and the run says so.
Note: -merge combines page content; cross-references and the TOC/index are per-record constructs and are not reconciled across the merge - use separate PDFs when a record relies on those.
How it works (architecture)
Binding is a pre-pass over the parsed model (ApplyDocumentData): it walks the block tree once - dropping when/unless-gated blocks, resolving each if to one branch, expanding every repeat (deep-cloning the template per array element), splicing in each include (loaded via a callback injected by LoadDocumentFromFile, so the binding pass itself touches neither the filesystem nor the JSON parser), and substituting the record into every bound string - producing a fully static document. The layout engine - line breaking, pagination, tagging, PDF/A - is untouched; it never sees data, tokens, repeats, conditionals, or includes. The record itself is a small DataValue tree of its own, independent of the JSON parser, built during parse and carried on the model.
Samples
- data-letter - a personalized statement of account: the company, date, customer block, account number, open-invoice count, balance, first invoice (an array element), and account-rep contact details are all bound from one data record; the header/footer bands bind too, while {{page}}/{{pages}} remain live. Tagged, passes veraPDF --flavour ua1.
- data-repeat - an order confirmation whose line items are generated by a repeat over order.items: each element becomes a bordered card binding {{item.*}} and {{loop.number}}, with the order id/customer/total bound outside the loop. Tagged, ua1-clean.
- data-conditional - an account statement that adapts to the record: an if/else on balance > 0, plus when/unless paragraphs (Gold-member perk, autopay state, an overdue notice box) that appear or vanish per the data. Tagged, ua1-clean.
- data-include - an invoice that pulls in a shared partials/letterhead.json and reuses one partials/address-block.json twice (Bill To / Ship To) via different with scopes. Tagged, ua1-clean. (Partials live in sample/partials/.)
- data-batch - a certificate template; run it standalone for a preview, or pagewrightapp sample/data-batch.json out.pdf -data sample/partials/batch-records.json to fan it out to one certificate per record (add -merge for a single PDF).
# Fonts and embedding
Setting pageSetup.embedFont to a system TrueType font (e.g. "Arial") renders full-Unicode text with that font subsetted + embedded (Identity-H) and a ToUnicode CMap, so the text is selectable/searchable. Bold / italic / bold-italic variants and text color are honored. Empty embedFont => the Base-14 Helvetica path (unchanged).
How it works now (data flow)
- Ctor (APDFLRenderTarget): mEmbed = !embedFontName.empty().
- Per variant (bit0=bold, bit1=italic): EnsureVariant() lazily APE_Font::Manufacture(<name>, "TrueType", "Identity-H", embed=true, ..., eUTF16, ..., PDDocGetCosDoc(doc)) where <name> is "Arial", "Arial Bold", "Arial Italic", "Arial Bold Italic". Caches both the PDEFont and the APE_Font*. If a variant face isn't installed it falls back to the regular face (so text still renders, just not slanted/bold).
- Measure (MeasureText -> EmbeddedAdvance): build an ASText from UTF-16BE, PDETextItemCreateASText into a temp PDEText, read PDETextGetAdvanceWidth (user space -> points). Falls back to AFM if anything raises.
- Draw (DrawTextLine in embed mode): defer each run into mPageText (a TextOp with utf8/x/baseline/size/color/bold/italic).
- EndPage: write the graphics content stream first (so text lands on top), then FlushDeferredText(): PDPageAcquirePDEContent -> per op: ASTextFromSizedUnicode(..., kUTF16BigEndian), APE_TextState::SetFontSize, APE_GraphicsState + APE_Color/SetFillColor, identity matrix translated to (x, pageH-baseline), PDETextItemCreateASText, PDETextAddItem, PDEContentAddElem -> PDPageSetPDEContent. Accumulate a per-variant char count and call APE_Font::AddChars() on each used face.
- FinishDocument: BuildOutline(), then APE_Font::CommitFontSubset(doc,NULL) (embeds + subsets every used face and builds ToUnicode), then PDDocSave.
The same EndPage PDE round-trip is what lets graphics + embedded text coexist on one page (verified): PDPageSetPDEContent rewrites the page content from the PDE model, preserving the graphics stream that was parsed back in by PDPageAcquirePDEContent.
# Color spaces
Any color in a spec - text color, background, a rule color, a table border, a chart series color - may be given in one of four color spaces. The PDF backend emits the true color operator for each (rg / g / k / scn), so CMYK and spot output is print-ready, while the engine keeps an RGB approximation for on-screen preview.
| space | JSON | PDF operator |
| RGB | "#1a4fa0" or "navy" | r g b rg / RG |
| Gray | { "gray": 0.45 } | v g / G (in colorMode: "cmyk": 0 0 0 1-v k / K, black ink only) |
| CMYK | { "cmyk": [0.85, 0.5, 0, 0.2] } | c m y k k / K |
| Spot | { "spot": "PANTONE 185 C", "tint": 1, "cmyk": [0, 0.91, 0.76, 0] } | /CSn cs t scn / SCN (a /Separation colorspace) |
Syntax
- RGB - a CSS hex string ("#rgb", "#rrggbb", "#rrggbbaa") or a named color, as before. The 8-digit form's fourth pair is an alpha - see Transparency. "none" (or "transparent") means no paint at all: a shape written that way is not filled, and a run keeps whatever color it inherits. That is not the same as an alpha of 00.
- Gray - { "gray": v }, a single value 0..1.
- CMYK - { "cmyk": [c, m, y, k] }, components 0..1.
Spot (separation) - { "spot": "<colorant name>", "tint": <0..1>, "cmyk": [c,m,y,k] }.
- spot is the colorant name written into the PDF (spaces are escaped, e.g. /PANTONE#20185#20C), so a RIP separates it onto the right plate.
- tint (default 1) is the ink strength 0..1.
- cmyk is the alternate - what the ink approximates at full tint (used to render the tint transform and the on-screen preview). If you give a color (RGB) instead of cmyk, the RGB is used for preview only.
CMYK / gray / tint values may also be written as percentages (0..100); any component greater than 1 is treated as a percent and divided by 100.
Transparency
Three different keys make something translucent, they do not mean the same thing, and one of them is not transparency at all. Getting them confused is the likeliest mistake on this page.
| you write | what it emits | real transparency? |
| "color": "#3366cc80" | /ca + /CA in an ExtGState | yes |
| "opacity": 0.5 on a block | /ca + /CA in an ExtGState | yes |
| "background": { "color": "#3366cc", "alpha": 0.5 } | a solid rg - no ExtGState at all | no |
A color alpha and a block opacity MULTIPLY; neither overrides the other. They are two controls over one number, and "the block fades its contents" can only mean the product. Measured on the three-rect probe in sample/colour-alpha.json:
| spec | /ca |
| "fill": "#3366cc" | 1 |
| "fill": "#3366cc80" | 0.5 |
| "fill": "#3366cc80" and "opacity": 0.5 | 0.25 |
Fill and stroke move together - the engine sets /ca and /CA to the same value.
[!] { "color": ..., "alpha": ... } IS NOT ALPHA. It is a tint: the color is composited over white at parse time and the result is emitted as one opaque rg (ParseTintColor). The output carries no ExtGState, and the rule is out = 1 alpha x (1 in) per channel, so #3366cc at alpha 0.5 becomes a solid #99b2e6. That is correct where it is used and nowhere else: it reaches only style.background, a badge background, and a bar's track / fill - boxes that sit on a white page. Over anything other than white it is simply the wrong color, and because it flattens at parse time nothing downstream can tell it was ever meant to be translucent. If you want a box that really lets the page through, put the alpha in the color: "background": "#3366cc80".
[!] An 8-digit alpha is an RGB-form feature only. { "gray": ... }, { "cmyk": ... } and { "spot": ... } carry no alpha field; a translucent CMYK or spot ink has to come from the block's opacity instead. (Color::a is set only on the hex path.)
Until 2026-09-10 the 8-digit form was parsed, reported to PageWright Studio, and then dropped by every painter, so an 8-digit color drew byte-identical to its 6-digit form. Nothing in the corpus used the form, which is why nothing caught it; sample/colour-alpha.json exists so that stays true.
How spot color works
A spot color becomes a real PDF /Separation colorspace: [ /Separation /<name> /DeviceCMYK <tintTransform> ], where the tint transform is a Type-2 (exponential) function mapping tint 0 -> white and 1 -> the CMYK alternate (N = 1, so tints are linear). The content then selects it and sets the tint: /CSn cs <tint> scn. The same colorant at several tints (e.g. 100 % / 60 % / 30 %) shares one separation and just varies the scn operand.
A spot name is one ink, with one alternate, everywhere in the document. The first alternate the document gives a name is the one every later use of it is written with - solid fills, strokes, text and gradients alike, in every mode - and a later use with a different cmyk says so once: spot colour "PANTONE 185 C" is given two different cmyk alternates in this document - every use keeps the first, [0 0.91 0.76 0]. A RIP makes one plate per name either way; two alternates only gave one ink two previews, and failed PDF/A (ISO 19005-2 6.2.4.4: every /Separation of a name must agree). Before 2026-10-01 that failure was silent.
When the plate cannot be kept, the spot is simulated and says so (once per name), exactly as a spot gradient is:
- in an RGB-mode PDF/A file - its alternate is DeviceCMYK, which the sRGB OutputIntent cannot describe (6.2.4.3) - the spot is drawn as its RGB preview (alternate x tint); a cmyk colour there is drawn as its RGB equivalent, with one notice of its own. Use colorMode: "cmyk" to keep plates and authored ink. (Untagged CMYK images there are still DeviceCMYK - see Known limitations.)
- in PDF/A-1, -2 or -3, a spot whose name is over 127 bytes (UTF-8; 6.1.13) is drawn as its alternate x tint in DeviceCMYK. PDF/A-4 and files with no PDF/A claim keep the plate.
Spot colours in gradients
A gradient stop takes any colour, spot included, and a spot keeps its plate and its tint inside a gradient exactly as it does in a solid fill. What the shading is written as depends on which inks the stops use together:
| the stops are | the shading's colour space | notice |
| no spot at all | DeviceRGB, or DeviceCMYK in cmyk mode - unchanged | - |
| one spot, at any tints, and/or paper | /Separation on that spot - the same array a solid fill of it writes | - |
| two or more spots, or a spot and process stops | /DeviceN [the spots, then only the process inks a stop uses] | - |
| a spot and an RGB or gray stop, in an RGB document | DeviceRGB, each spot at its alternate x tint | one line |
| any spot in an RGB-mode PDF/A document | DeviceRGB, each spot at its alternate x tint | one line |
| more than 32 inks, or more than 27 spots | process colour, each spot at its alternate x tint | one line |
| a spot name over 127 bytes (UTF-8), in a PDF/A-1, -2 or -3 document | process colour, each spot at its alternate x tint | one line |
{ "type": "shape", "shape": "rect", "width": 360, "height": 40,
"fill": { "type": "linear", "angle": 0, "stops": [
{ "offset": 0, "color": { "spot": "PANTONE 185 C", "tint": 0.2, "cmyk": [0, 0.91, 0.76, 0] } },
{ "offset": 1, "color": { "spot": "PANTONE 185 C", "tint": 1, "cmyk": [0, 0.91, 0.76, 0] } } ] } }writes [/Separation /PANTONE#20185#20C /DeviceCMYK <<... /C1 [0 0.91 0.76 0] ...>>] with a function from tint 0.2 to 1: one plate on press, and on screen the 20% end matches a solid 20% swatch. Everything the process ramp does still holds - every stop at its offset (the tint before the first stop is the first stop's, after the last the last stop's, linear between: 0.2 at 0.3 -> 1 at 0.7 is a flat 20% for the first 30% of the axis - see "Gradient fills" in "Vector graphics, images and SVG"), Type-3 stitching for three or more stops, Extend, linear or radial, shape fills and textFill.
- Paper is a stop with no ink: #ffffff, { "gray": 1 }, { "cmyk": [0,0,0,0] }, or the spot at tint: 0. It joins any ramp as tint 0, so "spot -> white" is the spot fading to 0 on its own plate, not a process simulation of it. (A stop's own opacity is a different thing - see below.)
- DeviceN names each spot once, then /Cyan /Magenta /Yellow /Black only for the process channels a stop actually uses; an RGB stop in cmyk mode converts through the outputProfile first, exactly as in a process gradient. Its tint transform (a Type-4 function) lays the inks over each other, 1 (1 tint x alternate) per channel, so at every stop - where one ink is on - it is exactly that stop's alternate x tint, the colour of its solid swatch. Every spot is also in /Colorants with the same /Separation array a solid fill writes, as PDF/A-2 requires.
- Simulated means the plate is not kept and the file says so, once per gradient: a gradient with spot colour "PANTONE 185 C" is simulated in RGB at each stop's tint, and the spot plate is not kept - ... and the reason (write the RGB stop as {"cmyk": [...]}, or use colorMode: "cmyk"). A simulated spot stop is its alternate x its tint - never the full-strength alternate.
- One name, two alternates keep the first alternate the document gave the name - in this gradient, in another, or in a solid fill (see How spot color works) - and say so once.
- 27 spots in one gradient is the most that keeps the plates. DeviceN allows 32 colorants, and a file of 28-32 spots is valid PDF (veraPDF passes it), but APDFL cannot draw a DeviceN with more than 27 spot colorants - the whole page fails to render, not only the ramp. So past 27 spots the ramp is simulated, with a notice. The four process inks do not count: 27 spots plus cyan, magenta, yellow and black stay a DeviceN.
- A long spot name in PDF/A. In a gradient the spot's name is written as a PDF name (the colorant), and PDF/A-1, -2 and -3 cap a name at 127 bytes (ISO 19005-2 6.1.13). A longer name - counted in UTF-8 bytes, so 43 CJK characters is already too long - makes the gradient simulated, with a notice saying to shorten the name. PDF/A-4 has no such limit and keeps the plate. A solid fill of such a name is simulated the same way (2026-10-01; it used to write the name and fail 6.1.13).
- Gradient text fills keep each run's ink. Two runs of one paragraph that each have a textFill share the block's box (extent block), and runs with the same fill are painted as one ramp. "The same" means the same inks, not the same on-screen colour: a spot run beside a process run whose stops preview identically, or two spots with one alternate, are separate shadings, each on its own plate.
- Overprint: with "overprint": true a spot-only ramp (Separation, or DeviceN of spots only) overprints exactly as a solid spot fill does; one that also names a process ink knocks out, like a process fill. See "Print production".
- A stop's own opacity ("opacity" on the stop, 0..1) fades a spot ramp to transparent on its own plate: the colour stays the Separation and a soft mask carries the opacity - or, in PDF/A, the opacity is folded into the stop's tint. (Until 2026-10-01 a JSON stop had no opacity at all; see "Gradient stop opacity" in "Vector graphics, images and SVG".)
Until 2026-09-27 every stop was written as its alternate at full tint in a DeviceCMYK ramp: a 20% -> 100% ramp printed as a flat 100% bar, a fade to tint 0 printed solid, and no spot plate existed in any colour mode. A regression check holds it.
CMYK color mode ("colorMode": "cmyk")
By default a document mixes whatever color spaces you write - RGB text on a CMYK rule is fine for ordinary or tagged output. But a PDF/A file is stricter: DeviceCMYK requires a CMYK /OutputIntent and DeviceRGB an RGB one, and a PDF/A file may have only one OutputIntent. So to make an archival CMYK file you render the whole document in CMYK:
{ "title": "...", "pdfa": "2a", "colorMode": "cmyk", "blocks": [ ... ] }"colorMode": "cmyk" (default "rgb") changes two things:
- Every emitted color becomes DeviceCMYK. RGB inputs (including the engine's own defaults - black text, the chart palette, borders) are converted to CMYK at emit time through the ICC color engine (AcroColor, relative colorimetric, into the press profile - the bundled SWOP, or outputProfile); the formula Color::AsCmyk is only the fallback when the color engine cannot be built at all. An exact neutral - RGB black, a gray #808080, and a vector { "gray": v } alike - maps to pure black ink (0,0,0,1-v), the print-correct result, so vector gray is written as 0 0 0 1-v k, not DeviceGray. Explicit cmyk and spot colors are unchanged. Gray images - a gray PNG, TIFF, GIF, JPEG or HEIF, at any bit depth, with or without alpha - are embedded as DeviceGray, which prints on the black plate alone as K = 1 v: the same ink as vector gray (see Gray images in CMYK output). The file therefore carries no DeviceRGB.
- The PDF/A OutputIntent is CMYK. When pdfa is also set, the OutputIntent is characterized against the bundled U.S. Web Coated (SWOP) v2 profile (/N 4) instead of sRGB - or against the document's own outputProfile. See "PDF/A archival output".
Without pdfa, colorMode: cmyk still applies - you get a DeviceRGB-free, print-CMYK PDF with no /OutputIntents dict.
Note: Note. In colorMode: cmyk the engine recolors RGB raster images to DeviceCMYK at embed time - per pixel, via the same ICC transform used for text, rules and charts - so an ordinary RGB PNG/JPEG/GIF/TIFF/WebP lives happily in a CMYK PDF/A with no DeviceRGB and no pre-converted asset.
A CMYK JPEG still passes through as /DeviceCMYK, a separated CMYK TIFF embeds as true DeviceCMYK (see "Print production"), and a gray image is embedded as DeviceGray - black ink only, never recolored (see Gray images in CMYK output). The conversion is ICC-managed - AcroColor, relative colorimetric, into the same CMYK profile the file declares as its OutputIntent (SWOP, or outputProfile) - and its source is the image's own embedded profile when it carries a usable RGB one (an Adobe RGB or Display P3 photo), else sRGB, the same transform text, rules and charts use.
So an untagged image stays color-consistent with the rest of the page, and a tagged one is converted from what its numbers actually mean (see Image color profiles below). An image tagged with the engine's own bundled sRGB profile (sRGB IEC61966-2-1 noBPC) converts to exactly the numbers an untagged one does.
Any other sRGB variant, including the common sRGB IEC61966-2.1 profile that Photoshop, cameras and macOS embed, converts through its own profile and can land a few ink levels per channel away from the untagged result, so a cmyk-mode document that places such photos may print slightly different inks than it did before profiles were read.
Sample: cmyk-image.
Note: Placed PDF pages are converted too. A pdf block's page, a page template and a contact sheet's thumbnails are imported whole, so their own operators used to reach a cmyk-mode file as they were - DeviceRGB fills and RGB images in a PDF/X or a PDF/A + cmyk file (PDF/A-2b fails 6.2.4.3 for it).
In cmyk mode the source page's RGB is now converted before it is imported, by APDFL's colour converter with the same settings the engine's own colours get: relative colorimetric, no black-point compensation, untagged RGB read as sRGB, into the press profile (SWOP or outputProfile), written as DeviceCMYK.
An exact neutral RGB fill or stroke - black text, a 0 0 0 rg barcode, a grey rule - becomes DeviceGray first, so it prints in black ink only, as the engine's own neutrals do. Spot colours (Separation, DeviceN) keep their plates; gray, CMYK and Lab are left as they are.
The source file is never written: the conversion happens to the copy open for the render. rgb mode is unchanged.
Gray images in CMYK output
Until 2026-09-26 a gray PNG printed as four-colour rich black. The PNG, TIFF and GIF decoders expand gray to R = G = B, and cmyk mode converted those neutrals like a photograph: black came out 75/68/66/90 (299% ink) under SWOP and 90/78/62/96 (327%) under FOGRA39, a 75% gray as a CMY highlight with no black, and a 1-bit line-art scan on four plates - while vector gray on the same page was already K-only and a gray JPEG already DeviceGray.
Now, in colorMode: "cmyk", pdfx, and PDF/A with cmyk, a file that is gray by its own header is embedded as one component, DeviceGray:
| format | gray when |
| PNG | colour type 0 (gray) or 4 (gray + alpha), 1-16 bits; or a palette whose every entry is neutral |
| TIFF | photometric MinIsBlack / MinIsWhite with one colour sample (alpha allowed), 1-16 bits; or a neutral palette; or no photometric tag and one colour sample, which libtiff's reader decodes as MinIsBlack |
| GIF | the frame's palette is all neutral |
| JPEG | one component (as before - passed through, or re-encoded gray) |
| HEIC / HEIF | the image's space is monochrome (with alpha it is drawn as RGB, then collapsed back) |
The mapping is vector gray's: K = 1 v, no C, M or Y, total ink at most 100%. A DeviceGray sample v prints as black ink 1 v under any CMYK intent (ISO 32000-1 §10.3.5), so the image and a { "gray": v } fill print the same plate: 0 -> K 100%, 64 -> 75%, 128 -> 50%, 191 -> 25%, 255 -> no ink. 16-bit samples are reduced to 8 (v x 257 -> v), MinIsWhite is inverted to the visual level, and a PNG gAMA is ignored (as it is in rgb mode). Alpha, a PNG tRNS key included, stays a separate DeviceGray /SMask, unchanged.
A gray + alpha TIFF (chunky, associated or unassociated alpha, 1-16 bits, strips or tiles) is read by the engine itself, not by libtiff's RGBA reader, whose gray + alpha routines drop a 16-bit file's alpha and never premultiply an unassociated one. Until 2026-09-26 such a 16-bit file lost its /SMask and printed its premultiplied gray opaque - twice as dark in the translucent area - and an 8-bit unassociated one came out too light there, in rgb mode too; a 4-bit one did not load. Its gray is now straight and its alpha a /SMask in both modes.
Why DeviceGray and not K-only DeviceCMYK. Both print the same black plate. DeviceGray is about 28% smaller (a K-only image carries three all-zero planes; 681 KB against 949 KB Flate on a 960 x 1280 photograph), can be DCT-encoded (so images.format: "jpeg" applies to a gray raster, and a gray JPEG keeps its passthrough), and is legal under a CMYK OutputIntent: veraPDF passes PDF/A-2b and PDF/A-4 with cmyk, and PDF/A-2b + PDF/X-4, all with FOGRA39 and DeviceGray images; PDF/X-4 forbids only uncharacterised DeviceRGB. The engine never sets overprint on an image, so a gray photo knocks out what is beneath it. (On screen, a viewer draws DeviceGray as plain gray but vector gray through the CMYK profile, so the two can look slightly different in a preview; on press they are identical.)
A Gray profile is set aside, out loud. The image prints K = 1 v as an untagged one does, and one embedded ICC profile ignored (in CMYK output a gray image prints in black ink alone ...) line is printed per document (not under images.colorProfiles: "ignore"). Mapping the profile's tone curve onto the black plate is not done yet. A gray palette PNG or TIFF carries an RGB profile - the only kind a palette PNG may have - and it is set aside the same way, with the same line ending its RGB profile, which a palette file carries even when every entry is neutral, is not applied. (It used to read a RGB profile on an image embedded as Gray, as if the author's correctly tagged file were wrong.) Under a matrix RGB profile such as sRGB, R = G = B is neutral, so nothing visible is lost; an RGB profile whose three curves differ would have tinted those neutrals, and that tint is not reproduced.
RGB is not gray. An RGB PNG whose pixels happen to be neutral, a palette with even one coloured entry, and a WebP (which has no gray mode) are RGB pictures: they convert through the ICC transform like any RGB image, as before. rgb mode is unchanged byte for byte: gray PNG, TIFF and GIF files are still DeviceRGB there.
A different press (outputProfile)
colorMode: "cmyk" converts for, and PDF/X and PDF/A declare, U.S. Web Coated (SWOP) v2 - an American web-offset press. A job for a European sheetfed press (FOGRA39), a GRACoL proof, a Japan Color run or a newspaper needs its numbers computed for that press, and its OutputIntent to say so. Name the press's ICC output profile:
{ "pdfx": "x-4", "outputProfile": "profiles/CoatedFOGRA39.icc", "blocks": [ ... ] }or, to give the OutputIntent a registered name,
"outputProfile": { "profile": "profiles/CoatedFOGRA39.icc", "identifier": "FOGRA39",
"condition": "Offset commercial printing, coated paper",
"info": "Coated FOGRA39 (ISO 12647-2:2004)", "registry": "http://www.color.org" }The path resolves against the spec file's directory, like every other file the spec names (a book's against the book file's). The key does nothing on its own: it takes effect only in cmyk mode - colorMode: "cmyk", or pdfx, which forces it. Use it when you know the press; leave it out and nothing changes, byte for byte.
What converts to it - everything the cmyk mode converts, through one transform per document: vector fills and strokes, base-14 and embedded-font text, SVG, charts, tables, rules, gradient and shading stops, placed RGB raster images (from their own embedded profile when they carry a usable one, else from sRGB) and color-emoji bitmaps. Exact neutrals stay K-only whatever the press. White stays inkless.
What does not. Colors that are already ink are written as authored and are now read as this press's ink: cmyk colors, spot alternates, the printer's-marks color bars, CMYK JPEGs and TIFFs, and vector gray's 0 0 0 1-v k. Gray images are DeviceGray, K = 1 v, whatever the press. Placed PDF pages are never converted. There is no CMYK -> CMYK conversion (from SWOP numbers to FOGRA numbers); author those colors for the press you name. Gradients interpolate in DeviceCMYK between their converted stops.
What the OutputIntent says. Only a document that claims a standard gets one - pdfx, pdfa, or both; cmyk alone converts and declares nothing, as before.
| entry | default | with the object form |
| DestOutputProfile | the profile file's bytes (at its declared size), /N 4 | same |
| OutputConditionIdentifier | Custom | identifier |
| Info | the profile's own description (its desc tag) | info |
| OutputCondition (PDF/X) | the profile's own description | condition |
| RegistryName (PDF/X) | not written - Custom is not a registry entry | registry, default http://www.color.org |
Custom is always valid PDF/X-4: the embedded profile is the characterization. Give a registered identifier (from the ICC characterization registry: FOGRA39, CGATS TR 001, CGATS21-2-CRPC6, ...) only when the profile really characterizes that condition - the engine never guesses one from a description. PDF/A + cmyk declares the profile as GTS_PDFA1; PDF/A + PDF/X writes both intents over one shared profile stream (PDF/A 6.2.3). A non-ASCII info or a v4 profile's Unicode description is written as a UTF-16 text string. A v2 profile's desc is read 7-bit ASCII first, as ICC defines it; when its bytes are not ASCII, its own Unicode copy is used, else the bytes as UTF-8 when they are well-formed UTF-8, else - the profile saying nothing about its encoding - they are read as Mac OS Roman when a byte falls in 0x80-0x9F and as Latin-1 otherwise, and a document that declares that name says so once:
aplayout: outputProfile "cafe.icc" description read as Latin-1 ("Café Press 2024": its desc tag is neither ASCII nor UTF-8 and has no Unicode copy) - set outputProfile's info and condition to word the OutputIntent exactly (at /outputProfile)
Giving info (and, under PDF/X, condition) replaces the guess and the line. Naming APDFL's own USWebCoatedSWOP.icc (the bundled bytes) is the same file as naming nothing, unless you override its words.
Refusals. The profile must be something the engine can convert to. Each document says, once, on stderr, why one is not - and then converts to and declares the built-in SWOP, never one without the other:
aplayout: outputProfile "CoatedFOGRA39.icc" cannot be read (not found) - the document converts to and declares the built-in U.S. Web Coated (SWOP) v2 (at /outputProfile) aplayout: outputProfile "sRGB.icc" ignored (a display (monitor) profile, not a press characterization) - the document converts to and declares the built-in U.S. Web Coated (SWOP) v2 (at /outputProfile)
in this order: a file that cannot be read (not found, a folder, permission denied, empty, a URL - the unreadable-file notice's words); a profile of the wrong kind - a display (mntr), input (scnr), color-space (spac), device-link, abstract or named-color profile, or a printer profile for Gray, RGB or other inks than CMYK; a damaged header or tag table (the checks image profiles pass: acsp, declared size, 8 MB, ICC version 2-4, every tag inside the profile); and finally the color engine, which builds the real transform - sRGB -> the profile - and runs it: a profile it cannot build a transform to, or whose conversion puts ink on white paper or gives red and green no (or the same) ink, is refused. That last test is the destination direction on purpose: a FOGRA39 with its B2A tables zeroed passes every header check and the source-direction test image profiles get. With pdfa/pdfx absent the notice ends "the document converts to the built-in ... SWOP v2". In rgb mode the key is ignored with one line and the file is never opened -
aplayout: outputProfile ignored, the document renders in RGB - set colorMode "cmyk" or pdfx to convert to it (at /outputProfile)
- because an RGB document has nothing to convert and its PDF/A intent must stay sRGB.
Servers and batches. A profile is identified by its bytes, not its path, read once per render. The color transform is built once per distinct profile per process (up to eight are kept; past that a document builds and releases its own), and the prepared-image cache key carries the profile, so a resident apDocLib server or a -batch rendering one PNG for SWOP, then FOGRA39, then SWOP again never serves one press's pixels to another. Every refusal is said again in every render.
Notes. Conversions are relative colorimetric unless the document says otherwise - see Rendering intent. A v4 profile (GRACoL2013, CGATS21) is accepted and passes veraPDF PDF/A-2b and PDF/A-4; it adds about 2.6 MB compressed to the file. Under PDF/X-4 a v2 or v4.0-4.2 profile is the standard's own (ISO 15930-7 defines its ICC profiles by ISO 15076-1:2005, which is 4.2); a 4.3 profile is used with a notice that a strict preflight may reject it ("Print production").
Rendering intent (renderingIntent)
How colours the destination cannot print are mapped. One document key, and an image block may name its own:
{ "colorMode": "cmyk", "renderingIntent": "relative",
"blocks": [ { "type": "image", "src": "flower.jpg", "renderingIntent": "perceptual" }, ... ] }| value | what it does | for |
| "relative" (default) | relative colorimetric: printable colours exactly, the rest clipped to the nearest printable one | flat brand colours, rules, charts |
| "perceptual" | the whole gamut compressed, so gradations survive | photographs |
| "saturation" | vividness over accuracy | business graphics |
| "absolute" | relative colorimetric that also simulates the paper's white | proofing |
The PDF names ("Perceptual", "RelativeColorimetric", "Saturation", "AbsoluteColorimetric") are accepted too; anything else is said once and the document stays relative.
- In cmyk mode (colorMode: "cmyk", or pdfx) every RGB -> CMYK conversion runs at it, into SWOP or the outputProfile: vector colours, text, gradients, emoji, images (from their own profile when they have one) and placed PDF pages. Measured on SWOP, #00ff00 is C 0.639 relative, 0.608 perceptual, 0.678 saturation, 0.518 absolute. An exact neutral vector colour stays black ink whatever the intent.
- In every mode the file says it, so a RIP or viewer converting the file onward uses it too: a document intent other than relative opens every page with /<Intent> ri, and an image whose own intent differs from the document's carries it as its XObject's /Intent. Relative colorimetric is PDF's default, so a document without the key writes exactly what it always did. In rgb mode nothing is converted - only this is written.
- One photo placed at two intents is two XObjects - the one whose intent differs from the document's carries /Intent, and in cmyk mode their inks differ too; the prepared-image cache of a resident server or a -batch run is keyed on the intent, so one intent's pixels are never served to another document.
- PDF/A and PDF/X restrict a rendering intent to these four names (ISO 15930-7 clause 6.23), so every value is valid in both; veraPDF passes 2b and ua1 with the ri stream.
Image color profiles (ICC)
A raster file may carry the ICC profile its numbers were made in - a camera's Adobe RGB, a phone's Display P3, a press's CMYK. Until 2026-09-24 every one was parsed by the decoder and thrown away, so each image was written as DeviceRGB / DeviceGray / DeviceCMYK by its component count alone and a wide-gamut photo printed as if it were sRGB, with nothing said. The profile is now kept.
| where the profile is | read from |
| PNG | the iCCP chunk |
| JPEG | the APP2 ICC_PROFILE segments, reassembled in sequence order |
| TIFF | tag 34675 (RGB and separated-CMYK decoders alike) |
| WebP | the ICCP chunk, when the VP8X header's ICC flag is set |
| HEIC / HEIF (macOS) | the color space ImageIO decodes it in - see "Vector graphics, images and SVG" |
What happens to it depends on the document's color mode:
- colorMode: "rgb" (the default). The image is written in its own space, [/ICCBased n 0 R], with /N equal to the embedded component count - an RGB profile on an RGB image, a CMYK profile on a CMYK JPEG or TIFF. Each distinct profile is written once per document (Flate, /N, no /Alternate) and shared by every image that carries it; an image carrying exactly the bundled sRGB shares the PDF/A OutputIntent's own profile stream. Soft masks stay DeviceGray. Legal under every PDF/A level the engine emits - see "PDF/A archival output".
- colorMode: "cmyk" and PDF/X. No ICCBased space is written anywhere: the mode's promise is a file of DeviceCMYK and DeviceGray under one CMYK intent. An RGB image's profile becomes the source of its SWOP conversion instead (a per-image AcroColor transform, relative colorimetric, released as soon as the image is converted). A profile AcroColor will not build a transform from is refused before either mode uses it (below), so its pixels are converted as sRGB, as before, and the engine says so. A CMYK image's ink numbers pass through as DeviceCMYK, exactly as they did, and its profile is not applied. A gray image is DeviceGray and its Gray profile is set aside with a notice - embedded ICC profile ignored (in CMYK output a gray image prints in black ink alone, K = 1 - gray, as vector gray does; its Gray profile's tone curve is not applied) - and so is a gray palette's RGB profile (the notice then says its RGB profile) - see Gray images in CMYK output.
A profile the engine cannot trust is refused out loud, never embedded: shorter than an ICC header, no acsp signature, a declared size larger than the data, over 8 MB, a version other than 2-4, a link / abstract / named-color profile, a color space other than Gray / RGB / CMYK, a damaged tag table, a broken APP2 sequence (a segment missing, repeated or out of range), a profile whose component count does not match the samples being embedded, or a profile whose tags the color engine cannot read (a curve whose entry count overruns its tag, zeroed tag data, a profile holding nothing but a description). The image falls back to its Device space and stderr carries one line per document per file, with the placing block's pointer when there is one:
aplayout: /path/photo.jpg: embedded ICC profile ignored (a CMYK profile on an image embedded as RGB) (at /blocks/3)
[!] The header check alone is not enough, so AcroColor is asked too. The header and tag table can be flawless while a tag's contents are damaged, and APDFL's renderer (Acrobat's) then draws nothing for the image: no fallback to the device space, no error, and veraPDF still passes the PDF/A. So every profile that passes the header check must also let AcroColor build a transform from it (to the bundled sRGB), once per distinct profile per process; one it refuses is reported as the color engine cannot build a transform from it: a tag it needs is damaged or missing, in both color modes.
A PNG's iCCP chunk is checked by libpng first, and libpng drops one it rejects (no acsp, a color space that does not suit the color type, a device link). That is reported the same way, as the PNG reader refused its iCCP chunk: ... with libpng's reason, not as libpng's own pointer-less libpng warning: line. Under colorProfiles: "ignore" nothing is said about it.
[!] The check is against the DECODED samples, not the file. The PNG decoder expands gray to RGB and libtiff's RGBA reader turns gray, CMYK and Lab TIFFs into RGB, so a profile that was right for the file can be wrong for the stream - in rgb mode a Gray profile on a gray PNG is therefore refused (with the notice) rather than written over three-component samples. (In cmyk mode the image is collapsed back to gray first, so the profile matches, and is set aside as above.)
"images": { "colorProfiles": "ignore" } turns all of this off: every image uses the plain device space for its component count, as if untagged, an RGB image converts to CMYK as sRGB, and no refusal is reported. (A HEIF under ignore is decoded into sRGB, since there are no file-space numbers to keep.) Any other value is reported and embed is kept.
A PNG with only an sRGB, gAMA or cHRM chunk and no iCCP stays DeviceRGB, as before.
PDF/UA, PDF/A, and embedded text
- PDF/UA (tagged) imposes no color-management rule, so CMYK and spot pass veraPDF --flavour ua1 with no OutputIntent.
- Embedded-font text uses true DeviceCMYK (via APE_Color::SetCMYKColor); spot text falls back to the spot's CMYK alternate at the tint (a /Separation isn't applied to glyphs in the embedded path). Graphics - rules, table/chart fills, the base-14 text path - use the real scn separation. In colorMode: cmyk the embedded-text path also converts RGB/gray to DeviceCMYK so embedded glyphs stay DeviceRGB-free.
- PDF/A + CMYK is supported via colorMode: cmyk (above): a tagged CMYK PDF/A-2a file with spot color passes veraPDF --flavour 2a and --flavour ua1 with 0 failures.
Table cell background fill
A table cell paints its style.background in any color space - the fill uses the same ColorOp as rules and text, so a cell background may be RGB, gray, CMYK, or a spot ink at any tint. It is drawn as a Layout artifact behind the cell content (and behind the grid border), so it never enters the structure tree and tagged tables stay PDF/UA-clean.
{ "style": { "background": { "spot": "PANTONE 185 C", "tint": 0.5, "cmyk": [0,0.91,0.76,0] } },
"blocks": [ { "type": "paragraph", "text": "50%" } ] }For overprinting spot ink (printing over the process color instead of knocking out), bleed, and printer's marks, see "Print production".
Known limitations / follow-ups
- A spot given an RGB color instead of cmyk has no CMYK alternate: its /Separation is written with the alternate 0 0 0 0, so off press (any viewer, apDocLib) it renders white - in a solid fill and, now that a spot ramp keeps its plate, in a gradient too. Its on-screen preview also multiplies the RGB by the tint, which darkens a light tint toward black instead of lightening it. Give spot inks a cmyk alternate.
- ~~A solid spot fill in an RGB-mode PDF/A file fails veraPDF 2b (clause 6.2.4.3).~~ Fixed 2026-10-01: drawn in RGB at its tint with a notice, as a spot gradient there always was - and so is a cmyk colour and spot text (see How spot color works).
- Spot text is not a plate. Embedded-font text draws a spot as its alternate x tint in DeviceCMYK (the /Separation is not applied to glyphs on that path); shapes, rules and gradients keep it.
- Tagged docs may emit one /Separation object per spot use (APDFL re-normalizes the merged tagged content stream); the colors are correct and de-duplicate by colorant name at the RIP, but the file carries redundant (identical) colorspace objects. Untagged output de-duplicates per page.
- Adobe APP14 inverted CMYK JPEGs. The APP14 transform flag is parsed but no /Decode inversion is written, so a Photoshop CMYK JPEG draws as uninverted as it always has - now as ICCBased /N 4 when tagged. Needs a real Photoshop file to verify against before it is changed.
- A gray PNG or TIFF with a Gray profile is refused in rgb mode rather than embedded as ICCBased /N 1, because both decoders expand gray to RGB there (cmyk mode collapses it back to gray); keeping it in rgb mode needs the same collapse, which changes rgb bytes.
- A Gray profile is not honoured in CMYK output. A tagged gray image prints K = 1 v like an untagged one; mapping its tone curve onto the black plate is not done yet.
- Gray that is not gray by its header - an RGB PNG whose pixels happen to be neutral, a gray-looking WebP (the format has no gray mode), an RGB scan - is an RGB picture and converts four-colour in cmyk mode, as any RGB image does.
- ~~Untagged DeviceCMYK under an RGB-mode PDF/A. An untagged CMYK JPEG or TIFF in a colorMode: "rgb" PDF/A file is DeviceCMYK under an sRGB intent and fails veraPDF, before and after this change (a tagged one now passes, as ICCBased). Wrapping it in ICCBased(SWOP) is not done; use colorMode: "cmyk" for CMYK assets.~~ [done] FIXED 2026-10-02 - the wrap is in. An untagged (or refused) four-component image in an rgb-mode PDF/A file is [/ICCBased n 0 R] with /N 4 over the bundled U.S. Web Coated (SWOP) v2 - the same profile the engine converts to and declares in cmyk mode - so the file validates at every level (2b/2u/2a/3b/3u/4, each measured). The ink numbers are embedded byte for byte as before: the wrap writes down what they MEAN, it does not recolor. Said once per document, because it names a press the author did not. colorProfiles: "ignore" still wins and keeps DeviceCMYK, with a line saying the file will not validate.
- The refusal notice names the block only for an image block; a textFill image, the watermark, a band logo, an SVG <image> or an inline figure reports the file alone.
Samples
- colour-alpha - the 8-digit form on a shape fill, on a stroke and on text, plus the case that matters: a half-alpha fill inside a block at opacity 0.5, which paints at a quarter.
- color-spaces - the four spaces as labeled bars, a spot ink at four tints, cell background swatches (a colored-cell table in all four spaces + a spot tint ramp), and a bar chart mixing a CMYK and a spot series. Passes veraPDF --flavour ua1, 0 failures.
- pdfa-cmyk - a tagged PDF/A-2a document in colorMode: cmyk with CMYK + spot color, a CMYK OutputIntent, colored cells, and a CMYK chart. Passes veraPDF --flavour 2a and --flavour ua1, 0 failures.
- cmyk-image - an RGB PNG placed in a colorMode: cmyk PDF/A-2a document, recolored to DeviceCMYK at embed time (the image XObject is /DeviceCMYK; the file carries no DeviceRGB). Passes veraPDF --flavour 2a and --flavour ua1, 0 failures.
# PDF/A archival output
How PageWright produces a PDF/A-2, PDF/A-3, or PDF/A-4 file - an ISO 19005 archival PDF that renders identically far into the future, with no dependency on external fonts, color profiles, or software state. PDF/A-3 (and PDF/A-4f) additionally let the document carry embedded associated files (e.g. the machine-readable source of a hybrid invoice); PDF/A-4 is the PDF 2.0-based revision. Companion to tagged output (a PDF/A-2a / -3a file is also a tagged PDF/UA file) and to "Fonts and embedding".
The engine generates the conformance machinery itself - there is no APDFL PDFAConvert / PDFProcessor post-process. Because PageWright authors the content, it already produces clean, embedded-font, DeviceRGB output, so reaching PDF/A only takes a few additive pieces rather than a fix-up pass.
Turning it on
Top-level JSON: "pdfa": "2b" | "2u" | "2a" (PDF/A-2), "3b" | "3u" | "3a" (PDF/A-3), or "4" | "4e" | "4f" (PDF/A-4), or the boolean "pdfa": true for a smart default (2a when the document is tagged, otherwise 2u - the engine always writes ToUnicode, so 2u is free). The level normalizes to ""|"<part><letter>", e.g. "3A" / "4F". Strings are forgiving: PDF/A-3u, pdfa3u, 3-U, PDF/A-4f all parse.
| Level | Adds over the previous | How PageWright satisfies it |
| 2b (basic) | renders identically forever | embedded fonts + an OutputIntent + pdfaid XMP + trailer /ID |
| 2u (unicode) | all text has Unicode mapping | the engine always emits ToUnicode CMaps for embedded fonts |
| 2a (accessible) | tagged structure + reading order | turns tagging on (so everything a tagged file carries applies) + a pdfuaid extension schema (and a pdfd one when the file also declares WCAG) |
| 3b / 3u / 3a | + may embed any associated file | same as the matching 2* level, plus the attachments machinery below (pdfaid:part = 3) |
| 4 | the PDF 2.0 revision; mandatory Unicode | bumps the document version to PDF 2.0 (%PDF-2.0); XMP pdfaid:part = 4 + pdfaid:rev = 2020 (no a/b/u letter); same OutputIntent + font embedding |
| 4f | + may embed any file (like 3) | base 4 + the attachments machinery (pdfaid:conformance = F) |
| 4e | + rich media / 3D (engineering) | base 4 + pdfaid:conformance = E (the engine emits no rich media, but the level is accepted) |
Selecting any *a level forces tagged: true (an accessible PDF/A is a tagged PDF). Any PDF/A level forces font embedding: a doc with no embedFont auto-embeds system Helvetica (subset), exactly as a tagged doc does - Base-14 fonts cannot be PDF/A. The difference between PDF/A-2 and PDF/A-3 in this engine is part 3 permits embedded files (PDF/A-2 permits only embedded PDF/A files); declaring attachments therefore auto-promotes a 2* level to the matching 3* level - or PDF/A-4 to 4f. PDF/A-4 has no a/b/u letters (accessibility lives in PDF/UA-2 instead) so it never forces tagging, and it is the only level that changes the PDF version (to 2.0).
Samples: sample/pdfa.json (2b), sample/pdfa-tagged.json (2a), sample/pdfa3-attachments.json (3b, embedded XML payload), sample/pdfa4.json (4), sample/pdfa4f.json (4f, embedded XML payload).
Embedded files / attachments (PDF/A-3)
A top-level attachments array embeds one or more associated files - the basis of hybrid electronic-invoice formats (Factur-X / ZUGFeRD), or simply shipping a document's source data alongside its rendered form:
"attachments": [
{ "file": "attachments/invoice-data.xml", "name": "invoice-data.xml",
"mime": "application/xml", "relationship": "Source",
"description": "Machine-readable invoice payload" }
]| Field | Meaning |
| file (required) | path to the source file, resolved against the spec's directory |
| name | displayed file name (/F + /UF); defaults to the basename of file |
| mime | the embedded file's /Subtype MIME; guessed from the extension if omitted |
| relationship | /AFRelationship: Source (default Unspecified), Data, Alternative, Supplement, FormData, Schema, EncryptedPayload |
| description | human-readable /Desc |
BuildAttachments() (in FinishDocument) reads each file and, for every one, writes a Flate-compressed /EmbeddedFile stream (/Subtype MIME, /Params with Size + ModDate from the file's mtime), wraps it in a /Filespec (/F, /UF, /AFRelationship, optional /Desc, /EF), and links it from both the Catalog /AF array (associated files - required by PDF/A-3) and the /Names /EmbeddedFiles name tree (so viewers list it). Any attachment forces pdfaid:part = 3. veraPDF --flavour 3b (and 3u/3a) validates 0-failure, and the extracted file is byte-identical to the source.
What the engine emits
All of this is added as the document is finished (FinishDocument), when a PDF/A level is asked for.
1. OutputIntent + embedded sRGB ICC (BuildOutputIntent)
PDF/A requires every color to be reproducible against a characterized condition. The engine emits DeviceRGB/DeviceGray, so it adds one /OutputIntents entry on the Catalog:
/OutputIntents [ << /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB IEC61966-2.1) /Info (sRGB IEC61966-2.1) /DestOutputProfile «ICC stream, /N 3, FlateDecode» >> ]
(/S /GTS_PDFA1 is the subtype for every PDF/A part, not just PDF/A-1.) The ICC profile is the standard 3 KB sRGB IEC61966-2.1 profile, bundled as a byte array in the engine (SRGBICCProfile() / SRGBICCProfileLen()), so the output is self-contained - no runtime dependency on the APDFL install path.
The stream is built with CosNewStream(..., encodeTheSourceData = true, attrs = {/N <3|4>, /Filter FlateDecode}) so APDFL Flate-compresses the raw profile bytes on write.
Note: CMYK / spot color + PDF/A. PDF/A requires DeviceCMYK to have a CMYK OutputIntent and DeviceRGB an RGB one, and a file may carry only one OutputIntent. So an archival CMYK document sets "colorMode": "cmyk" (see "Color spaces"), which (a) converts every RGB color to DeviceCMYK so the file is DeviceRGB-free and (b) switches this OutputIntent to a CMYK intent: the bundled U.S. Web Coated (SWOP) v2 profile (/N 4, same provenance as the sRGB profile).
BuildOutputIntent picks the profile (SRGBICCProfile vs CMYKICCProfile), the /N, and the condition name from pInfo.cmykMode. A tagged CMYK PDF/A-2a file with spot color then passes veraPDF --flavour 2a and --flavour ua1 with 0 failures. Sample: pdfa-cmyk. A cmyk document can name its own press instead of SWOP with "outputProfile" (see "Color spaces"): the PDF/A intent then carries that profile's bytes (OutputConditionIdentifier Custom, Info its description), the numbers in the file are converted to it, and a co-declared PDF/X intent shares the same profile stream.
FOGRA39 (v2) and GRACoL2013_CRPC6 (v4.2) are checked with veraPDF PDF/A-2b and PDF/A-4 . In rgb mode the key is ignored - an RGB PDF/A keeps its sRGB intent. Spot gradients under a CMYK intent are /Separation shadings (one spot) or /DeviceN ones (several spots, or spot + process) with a Type-4 tint transform and a /Colorants entry per spot - every /Separation array of one name in the file built by the same MakeSeparationCS, so ISO 19005-2 6.2.4.4's "same name, same alternate and tint transform" holds by construction for a spot authored with one alternate.
veraPDF 2b does test that rule (6.2.4.4 test 2) for the arrays the page's content actually paints with - a solid fill's /ColorSpace resource, a shading's colour space, a DeviceN's /Colorants: a 185 shading whose alternate is 0.01 magenta off the solid swatch's fails it, so a clash fails the veraPDF gate rather than passing silently.
(An array no content operator reaches is not compared.) A regression check asserts both halves: every same-name array in the file content-equal in every mode (PDF/X and plain cmyk too, which veraPDF does not see), and veraPDF failing its PDF/A file on 6.2.4.4 test 2 once one shading's alternate is moved.
PDF/A-2b + cmyk with all of them passes veraPDF 2b with 0 failures. A spot name over 127 bytes (UTF-8) is not written into a gradient in PDF/A-1/2/3 - 6.1.13 caps a name there, so the ramp is simulated in process colour with one notice; PDF/A-4 has no such cap and keeps the plate, and a solid fill of such a name is simulated the same way (since 2026-10-01; it used to fail 6.1.13).
In an RGB-mode PDF/A a spot - gradient, solid fill, stroke or text - is simulated in DeviceRGB at its tint, and a cmyk colour is drawn as its RGB equivalent, each with one notice, because DeviceCMYK (a Separation's alternate included) is not allowed under the sRGB intent (6.2.4.3; the solid cases failed silently until 2026-10-01).
One spot name keeps the FIRST alternate the document gave it, everywhere, so its Separation arrays always agree (6.2.4.4), and a second alternate is said once. A regression check holds the solid cases under veraPDF.
1b. Images that carry their own ICC profile
An image whose file embeds a usable profile (see "Color spaces") is written in an [/ICCBased n 0 R] space instead of a Device one. That is legal under every level the engine emits, whatever the OutputIntent: PDF/A needs Device color to be covered by the intent, while an ICCBased space characterizes itself. The two conditions PDF/A does place on it are guaranteed before the stream is written - /N equals the image's component count (a mismatched profile is refused, with a notice, and the image falls back to its Device space) and the profile is version 2-4 (anything else is refused the same way). A third condition, which veraPDF does not test, is enforced as well: AcroColor must be able to build a transform from the profile. One with a damaged tag passes veraPDF, yet Acrobat's renderer draws nothing at all for the image, so it is refused the same way. Each distinct profile is one Flate stream carrying /N and no /Alternate; an image tagged with exactly the bundled sRGB shares the OutputIntent's DestOutputProfile stream rather than writing it twice. A regression check renders an ICC-tagged fixture at "pdfa": "2b" and requires veraPDF to find 0 failures.
One consequence is a fix: a tagged CMYK JPEG or TIFF in an RGB-mode PDF/A file is now ICCBased /N 4 and passes.
AND SO DOES AN UNTAGGED ONE, BECAUSE THE ENGINE SUPPLIES THE PROFILE (2026-10-02). Clause 6.2.4.3 admits DeviceCMYK only under a CMYK OutputIntent, and an rgb-mode document's intent is sRGB, so a separated CMYK TIFF or a four-component JPEG with no profile of its own used to make the whole file invalid - one failed check per such image - while a tagged one beside it passed. Such an image (and one whose own profile was refused: a mismatched /N, a bad version, a tag the color engine cannot read) is now wrapped in [/ICCBased n 0 R] with /N 4 over the bundled U.S. Web Coated (SWOP) v2 - the same bytes this engine converts to and declares as its CMYK OutputIntent in cmyk mode, so the numbers and the label cannot name two different presses. Measured compliant at 2b, 2u, 2a, 3b, 3u and 4. The samples are embedded byte for byte as before: untagged CMYK has to be read as something, every renderer already assumes its own working space, and the wrap only writes that assumption down. It is still an assumption the author did not make, and it costs the profile's bytes (~386 KB deflated, one stream however many images share it), so it is said once per document with the two ways to decide it instead: tag the image, or render under colorMode: cmyk. images.colorProfiles: "ignore" still wins - the author asked for Device spaces - and that file keeps a DeviceCMYK that will not validate, with a line saying so rather than in silence. A regression check holds all of it to veraPDF.
In colorMode: cmyk no ICCBased space is written at all: an RGB image's profile is the source of its conversion to the CMYK intent's DeviceCMYK (SWOP, or outputProfile), so the file keeps its one-intent, Device-only shape. A gray image there is DeviceGray (black ink only, K = 1 v), which PDF/A-2 and -4 accept under a CMYK OutputIntent (§6.2.4.3 allows DeviceGray under any OutputIntent; only DeviceRGB and DeviceCMYK must match the intent's space): veraPDF passes PDF/A-2b and PDF/A-4 with cmyk, and PDF/A-2b + PDF/X-4, each holding every gray test fixture.
2. pdfaid XMP (BuildXMP)
The XMP packet (which already mirrors /Info and, when tagged, carries pdfuaid:part) gains the PDF/A identifier:
xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" <pdfaid:part>2|3|4</pdfaid:part> <pdfaid:conformance>B|U|A</pdfaid:conformance> (PDF/A-2 / -3) <pdfaid:rev>2020</pdfaid:rev> (PDF/A-4 - replaces conformance, + optional E/F)
The part is the first character of the level (2*->2, 3*->3, 4*->4). For PDF/A-2/-3 the conformance letter is the last character (2B->B, 3U->U, 3A->A). PDF/A-4 drops the a/b/u model: it emits pdfaid:rev = 2020 (the standard's revision year) and only adds pdfaid:conformance when the level is 4E or 4F.
2a. PDF 2.0 version bump (PDF/A-4 only)
PDF/A-4 is built on ISO 32000-2 (PDF 2.0). FinishDocument calls PDDocSetAdobePDFVersion(theDoc, 0x00020000) for any 4* level just before saving, so the file header is written as %PDF-2.0. Every other document - including PDF/A-2 and PDF/A-3 - stays PDF 1.x. The save still uses PDSaveCompressed (object + cross-reference streams), which is valid in PDF 2.0.
PDF/A-4 cannot also claim PDF/X-4, which is a PDF 1.6 standard: with "pdfx" beside "pdfa": "4" (or "pdfua": 2) the PDF/X claim is not written and one notice says so - until 2026-10-01 the file was %PDF-2.0 with a GTS_PDFX intent and pdfxid claiming X-4. A PDF/A-2 or -3 level carries both claims; there the XMP also gets PDF/X-4's pdf:Trapped, xmpMM:VersionID and xmpMM:RenditionClass, and because pdf:Trapped is not one of PDF/A-2's predefined XMP properties it is described in the pdfaExtension block beside pdfxid (veraPDF 2b passes; it failed 6.6.2.3.1 without it). See "Print production".
3. pdfuaid extension schema (2a only)
PDF/A allows only predefined or PDF/A-extension XMP schemas (clause 6.6.2.3.1). pdfaid and the Dublin-Core / xmp / pdf schemas are predefined, but pdfuaid (which a 2a file emits because it is also PDF/UA) is not - so when both PDF/UA and PDF/A are asserted, BuildXMP adds a pdfaExtension:schemas block describing the pdfuaid schema and its part (Integer, internal) property. Without it, veraPDF --flavour 2a fails 6.6.2.3.1 (this was the only failure on the first 2a attempt).
meta.rights needs no such block: it is written as Dublin Core's dc:rights (a Lang Alt, x-default), and dc is predefined. veraPDF 2b and 4 pass with it. A character XML 1.0 cannot carry at all - NUL, U+FFFE, U+FFFF, a lone surrogate - is left out of every XMP value, and any other C0 control is written as a space; stderr names the field. Until 2026-10-10 a U+FFFE in meta.rights cut the packet off at that point (APDFL reads it as a byte-order mark), losing pdf:Producer, pdfaid:part and pdfuaid:part, so a PDF/A file failed validation while the engine exited 0.
The same clause applies to PDF Declarations: pdfd is not predefined either, so a PDF/A file carrying a declarations array gets a second extension schema describing it (clause 9 of the PDF Declarations specification states the requirement from its own side). The block is emitted for whichever combination the document actually asserts - pdfuaid alone, pdfd alone on an untagged archival file, or both at once. A document asserting PDF/A-2a, PDF/UA-1 and WCAG 2.2 AA together validates against all three.
4. Font embedding + trailer /ID
A PDF/A doc reuses the tagged path's font handling: auto-embed Helvetica when no embedFont, and StripCIDSets() removes APDFL's inconsistent /CIDSet (optional in PDF/A-2). The trailer /ID that PDF/A requires is written by APDFL on PDDocSave (verified present; veraPDF would flag it otherwise).
# Print production
How PageWright produces a press-ready PDF: bleed + page boxes, printer's marks, full-bleed page backgrounds, overprinting spot ink, and CMYK raster assets. Companion to "Color spaces" (CMYK / spot color, the "colorMode": "cmyk" mode and the CMYK OutputIntent) and "PDF/A archival output" (archival conformance).
{
"pdfa": "2a", "colorMode": "cmyk",
"pageSetup": {
"size": "Letter",
"bleed": 9,
"marks": true,
"background": { "spot": "PANTONE 7499 C", "tint": 0.25, "cmyk": [0, 0.02, 0.15, 0] }
},
"blocks": [ ... ]
}Bleed and page boxes - pageSetup.bleed
"bleed": <points> (commonly 9 pt = ") - or per-edge insets {"top": 9, "right": 9, "bottom": 18, "left": 9} - grows the MediaBox past the trim while the layout keeps working in trim coordinates - the MediaBox simply gets a negative origin, so no content shifts:
| box | value (Letter, 9 pt bleed, marks) |
| /MediaBox | [-33 -33 645 825] (trim + bleed + 24 pt marks area) |
| /BleedBox | [-9 -9 621 801] (trim + bleed) |
| /TrimBox | [0 0 612 792] (the finished page) |
Imposition software cuts at the TrimBox; ink that must run to the cut edge extends into the bleed. The boxes are set per page in BeginPage (PDPageSetBox, atoms TrimBox / BleedBox - the full key names). Bleed and marks are per-pageSetup, so sections can differ (e.g. a full-bleed cover section, plain body sections).
Printer's marks - pageSetup.marks
"marks": true draws, outside the bleed (the media adds a 24 pt marks area):
- Corner crop marks - two hairlines (0.3 pt, 16 pt long) per corner on the trim lines, starting 3 pt past the bleed edge so they never touch live ink.
- Registration targets - a circle + cross centered on each edge, 12 pt + bleed out from the trim.
All marks stroke in the registration ink - a real /Separation /All colorspace - so they print on every plate of a separated job. With per-edge bleed, each edge's marks sit just past that edge's allowance.
Two more slug-area aids:
- "colorBars": true - eight process patches along the bottom slug: K 100/75/50/25 % plus C, M, Y solids and a composite. CMYK inks in a colorMode: cmyk document; RGB equivalents otherwise (so an sRGB PDF/A stays valid).
- "slug": true | "<template>" - a 6 pt job-info line in the top slug area. true renders {{title}} - {{date}} - p. {{page}}; a string template may use those three variables.
Marks, color bars, slug, and background are all drawn as a Page artifact (/Artifact <</Type /Page>> BDC ... EMC), so tagged output stays PDF/UA-clean; a tagged PDF/A-2a file with bleed + marks passes veraPDF --flavour 2a and --flavour ua1 with 0 failures.
Page background - pageSetup.background
Any color (RGB / gray / CMYK / spot at a tint - see "Color spaces") painted under everything to the full bleed extent, so trimming never exposes a white sliver. Drawn inside the same Page artifact.
Overprinting spot ink - top-level "overprint": true
By default PostScript/PDF ink knocks out: a spot bar over a cyan field punches a white hole in the cyan and prints the spot into it. With "overprint": true, every spot (separation) fill/stroke is wrapped q /GSop gs ... Q where GSop is an ExtGState with /OP true /op true /OPM 1 - on press the spot lays down over the process ink.
Spot gradients overprint the same way. A gradient of one spot's tints is a /Separation shading on that spot's plate, and two spots together a /DeviceN of spots (see "Color spaces"); under "overprint": true either is painted inside q /GSop gs ... sh Q - and a gradient-filled heading carries the same OP state on its own element, as spot text does. A gradient that mixes a spot with process stops is a DeviceN that names those process inks, and it knocks out like a process fill: /OPM 1 is defined for DeviceCMYK only, so its zero process components would still clear the plates beneath. A gradient without a spot never overprints.
Note: Overprint + tagged PDF. APDFL's content normalization hoists graphics-state changes outside marked-content containers when it merges embedded text, which violates PDF/UA rule 7.1 (content must be tagged or artifact). So pair overprint with untagged output - including PDF/A-2b, which does not require tagging and passes cleanly (sample overprint: PDF/A-2b + CMYK OutputIntent + overprint, veraPDF 0 failures).
A tagged file with overprint is still archival-valid PDF/A-2a - only strict PDF/UA flags it.
The q...Q wrapping is essential for another reason: a persistent gs op would make the PDE serializer emit compensating state resets between containers (breaking PDF/UA even without overprint semantics). Balanced save/restore leaves nothing to compensate.
Embedded text overprints too: spot-colored text and 100 % K black text get a PDE-level ExtGState (/OP /op true /OPM 1) attached to the text element, which serializes inside the text's marked container - so text overprint never trips the PDF/UA content rule the raw-stream gs would.
CMYK raster assets
In colorMode: cmyk the engine recolors RGB rasters to DeviceCMYK at embed time (see "Color spaces"), so any RGB image works in a CMYK document - converted from the image's own embedded ICC profile when it has one (an Adobe RGB or Display P3 photo keeps the colors it was made in), else as sRGB. Press-supplied CMYK assets are still honored exactly:
- CMYK JPEG - passed through verbatim (DCTDecode) with /DeviceCMYK (4-component SOF detected by JpegInfo).
- CMYK TIFF - a separated TIFF (PHOTOMETRIC_SEPARATED, 8-bit chunky, inkset CMYK) decodes to raw ink values (DecodeTIFFtoCMYK) and embeds as a FlateDecode /DeviceCMYK image - no round trip through RGB. Extra channels (alpha/spot) are dropped; planar/16-bit/other-inkset TIFFs fall back to the RGB path.
- Gray images - a PNG, TIFF, GIF, JPEG or HEIF that is gray by its own header (any bit depth, with or without alpha; a PNG, TIFF or GIF palette whose entries are all neutral) - embed as DeviceGray and print on the black plate alone, K = 1 v, the same ink as vector gray. (Until 2026-09-26 a gray PNG, TIFF or GIF was recolored like a photo: its black was a 299% SWOP / 327% FOGRA39 rich black, and a line-art scan printed on four plates.) See "Gray images in CMYK output" in "Color spaces".
- Everything else (RGB PNG/GIF/WebP/TIFF/JPEG, and HEIC on macOS) is recolored to DeviceCMYK as above, so a colorMode: cmyk PDF/A or PDF/X file carries no DeviceRGB and no ICCBased image. A CMYK asset's own profile is not applied: its ink numbers pass through. (Outside colorMode: cmyk, a CMYK asset that embeds a CMYK profile is written as ICCBased /N 4 - see "Color spaces".)
The bundled sample/sample-cmyk.tif (four ink ramps + a rich-black strip) exercises the TIFF path.
Resolution preflight - images.minPpi
A photo that is too small for the size it is printed at looks fine on screen and soft on paper, and nothing in the file says so. "images": { "minPpi": 250 } asks the engine to say so:
aplayout: image "harbor.jpg" is placed at 118 ppi, below images.minPpi (250) - it will print soft; supply a larger file or place it smaller (at /sections/1/blocks/4)
- Effective ppi is the embedded image's pixels per inch of picture as drawn, on the lower of the two axes. The pixels are counted on the displayed axes - after the file's own EXIF / TIFF / HEIF orientation (when autoOrient is on) and a quarter-turn rotate - and the picture is the box for fit: "contain" and "fill", the cropped picture for "cover" including zoom (which spreads the same pixels further), and one pixel per point, i.e. 72 ppi, for "none". focus only moves the crop, so it never changes the answer. The box is the final one, after any shrink to fit the page.
- As drawn includes the transform in force: the block's own scale / skew and every enclosing group's. A positioned image at "scale": 3 spreads its pixels over three times the paper; a canvas element at "scale": 0.25 packs them four times tighter.
- Embedded means after the maxPpi downsample, which the check models exactly as the backend applies it: the higher of the two axes is brought down to maxPpi and the other follows, so a fill box squashed well off the picture's aspect can land its lower axis far below the floor even with minPpi under maxPpi (a 400 px square in a 180 x 72 pt box is 80 x 200 ppi under maxPpi: 200), and a quarter rotate on an image with no orientation tag is paired the way the downsample pairs it. A CMYK JPEG, which is embedded as it is, is judged at full size. With minPpi above maxPpi every downsampled image lands below the floor by construction; each is reported, and the engine also says once, at parse time ((at /images/minPpi)), that the two keys contradict each other.
- It is a report, never a change: the image is drawn exactly as without the key. One line per block and file per render, however many pages a master frame stamps it on; two blocks placing the same file each get their own line and pointer. The ppi is rounded down, so the line can never read "150 ppi, below 150", and an image exactly at the floor is not reported.
- A block from another file says which: a book chapter, a -data ... -merge record or an included partial adds (in chapter 2 "k2.json"), (in record 3) or (in partial "addr.json" included at /blocks/4) before the pointer, and each chapter, record and include site reports on its own. A partial's block has no pointer of its own, so its line ends with its include's ((at /blocks/4), the outermost include when partials nest).
- The MCP server files the line under engine_warnings, not errors: the document rendered.
Not counted yet: images that are not image blocks (a textFill image, the watermark, band logos, inline figures, an SVG <image>); and image blocks inside running header / footer bands. The file is never judged when it cannot be read.
PDF/X-4 - top-level "pdfx": "x-4"
"pdfx": "x-4" badges the file as PDF/X-4 (ISO 15930-7), the modern press-delivery standard. It builds on everything above and turns on the rest of what a prepress RIP checks, reusing the PDF/A output-intent machinery:
- a GTS_PDFX OutputIntent whose DestOutputProfile is the bundled U.S. Web Coated (SWOP) v2 CMYK ICC profile - or the document's own outputProfile (below) - plus a human-readable OutputCondition and, for a registered condition, a RegistryName;
- cmyk color mode is forced, so the file carries no unmanaged DeviceRGB - the one thing a characterized-CMYK intent cannot describe;
- pdfxid:GTS_PDFXVersion = PDF/X-4 in the XMP packet, with what ISO 15930-7 asks beside it: the trapping state - /Trapped /False in /Info and pdf:Trapped False in the XMP (the engine traps nothing, so a RIP's trapping step still has to run) - and xmpMM:VersionID / xmpMM:RenditionClass beside the DocumentID APDFL writes. A document with no title gets "Untitled document" (PDF/X wants dc:title, as PDF/UA does). In a PDF/A-2 or -3 file that also claims PDF/X, pdf:Trapped is described in the PDF/A extension schema too (it is not one of PDF/A-2's predefined properties; veraPDF passes);
- a TrimBox on every page (plus a BleedBox when bleed is set), the PDF header set to 1.6, all fonts embedded, and encryption skipped (PDF/X forbids it).
PDF/X-4 permits live transparency and layers, so nothing is flattened; only X-4 is offered (X-1a would additionally require transparency flattening). Sample: pdfx4 (bleed + marks + CMYK). veraPDF has no PDF/X validation profile, so conformance is checked structurally - the GTS_PDFX intent, the /N 4 CMYK profile, the pdfxid metadata, the trapping state and version identifiers, the 1.6 header, the page boxes, embedded FontFile2 streams and the absence of any DeviceRGB.
PDF/X-4 is a PDF 1.6 standard, so it cannot share a file with a PDF 2.0 one. "pdfa": "4" (PDF/A-4) and "pdfua": 2 (PDF/UA-2 - also what a tagged PDF/A-4 document is promoted to) need %PDF-2.0; with either, the PDF/X claim is not written - no GTS_PDFX intent, no pdfxid - and one notice says so. The document still converts to CMYK for the press, and a PDF/A-4 file declares that press in its own OutputIntent. For both claims in one file use a PDF/A-2 or -3 level ("pdfa": "2b") or "pdfua": 1. (PDF/X-6, the PDF 2.0 successor, is not implemented.)
ICC versions. ISO 15930-7 defines its ICC profiles by ISO 15076-1:2005 - ICC version 4.2 - so a v2 or v4.0-4.2 outputProfile (GRACoL2013, CGATS21) is PDF/X-4's own and belongs under its 1.6 header, although the PDF 1.6 reference itself stops at 4.1. A newer profile (4.3, ICC.1:2010) is outside that definition: it is used, and one notice says a strict preflight may reject the file and that a v2 or v4.2 profile of the same press avoids it.
PDF/X for a press that is not SWOP - "outputProfile"
PDF/X-4 says which press the CMYK numbers were made for. Name that press's ICC output profile and the whole document is converted for it, and declares it:
{ "pdfx": "x-4", "outputProfile": "profiles/CoatedFOGRA39.icc", "blocks": [ ... ] }The GTS_PDFX intent then embeds the profile file's own bytes as DestOutputProfile, with OutputConditionIdentifier Custom, the profile's description as Info and OutputCondition, and no RegistryName - valid X-4 for any profile, because the embedded profile is the characterization. When the profile characterizes a condition in the ICC registry, say so with the object form, { "profile": "...", "identifier": "FOGRA39" }, and the registry name is written too. A profile the engine cannot convert to (a monitor or RGB profile, a Gray press, damaged tables, a missing file) is refused with one notice and the job converts to and declares SWOP. Colors that are already ink - cmyk values, spot alternates, the color bars, CMYK images - are not converted; author them for the press you name. The full rules, the refusals and the notices are in "Color spaces".
# Watermarks and stamping
How PageWright stamps page furniture on every page: text watermarks (DRAFT / CONFIDENTIAL / COPY), image stamps, and a letterhead PDF underlay - the generation-time equivalent of the Appligent stamping engine that powers StampPDF. Everything here is a pageSetup key, so sections inherit the document's setting and may override it.
{
"pageSetup": {
"watermark": { "text": "CONFIDENTIAL", "mode": "dual", "opacity": 0.22 },
"template": { "src": "letterhead.pdf", "on": "first" }
}
}Text watermarks - pageSetup.watermark
| key | values | meaning |
| text | string or array | the message; \n (or array entries) make multiple lines |
| position | diagonal (default), diagonal-down, center, top, bottom, angle | placement. diagonal runs corner to corner bottom-left -> top-right; diagonal-down top-left -> bottom-right. Both use the page's own diagonal angle atan2(height, width), so the angle changes with page size and orientation ( 52° on portrait Letter, 38° landscape, 45° on a square page) |
| angle | degrees CCW | with position: "angle" (setting it implies the position) |
| mode | solid (default), outline, dual | filled letters, hollow stroked letters, or both |
| layer | under (default), over | beneath or above the body content (dual draws both) |
| opacity | 0-1 (default 0.25) | constant alpha |
| size / autoScale | points / % of page height | explicit size; default fits the text to the extent (72 % of the diagonal, 78 % of the width) |
| color | any spec color | RGB / gray / CMYK / spot - spot fades by tint |
| justify | left, center (default), right | multi-line alignment. For top/bottom it also anchors the block on the page; for diagonal / diagonal-down / center / angle it justifies each line within the rotated text block (left = leading edges aligned, right = trailing edges aligned, center = each line on the block axis) |
| offsetX, offsetY | points | nudge from the chosen position |
| pages | all, even, odd, first, rest | page targeting (first/rest are per section) |
A multi-line watermark (text as an array, or \n) stacks its lines and, on a diagonal, rotates the whole block together; justify then aligns the lines inside that block - so a short line in a DRAFT-over-DO NOT DISTRIBUTE stamp sits flush-left, flush-right, or centered as you choose. This mirrors the classic Stamping Engine (StampPDF) box convention. Sample: watermark-multiline.
dual is the workhorse (StampPDF's classic): a solid pass under the body + a hollow outline pass over it at full opacity - the mark reads on text-heavy and image-heavy pages. The text is drawn in bold with the document's own (embedded) fonts, so any Unicode works in embedded-font documents.
Opacity vs. tagged PDF - how it stays PDF/UA-clean
APDFL's PDE re-serialization hoists graphics-state changes outside marked content, so a naive /ca ExtGState breaks PDF/UA rule 7.1 (the overprint lesson). The engine sidesteps it:
- An underlay sits on a uniform field (the page, or its background fill), so constant alpha is visually identical to blending the color toward that field - RGB/gray blend toward the background, CMYK inks and spot tints fade toward paper. No ExtGState at all -> tagged output is untouched.
- dual's outline overlay runs at full opacity (hairline strokes barely occlude) -> also no ExtGState.
- An explicit translucent overlay (layer: "over", opacity < 1) uses a real /CA /ca ExtGState. Text: fine in untagged output (including PDF/A-2b); in tagged output it can trip PDF/UA 7.1 - use the underlay, dual, or a full-opacity overlay there. Images are exempt: overlay images ride their own content stream appended after the body (never re-serialized), so a translucent image stamp is PDF/UA-clean.
The sample watermark (tagged PDF/A-2a: dual + angled multi-line + an overlay image stamp) passes veraPDF --flavour ua1 and --flavour 2a with 0 failures.
Image stamps - watermark.image
image (alias src) stamps a raster instead of text: same position / rotation / opacity / pages vocabulary, sized by scale (% of the page width, default 50). Overlay stamps always use real alpha (see above); a translucent underlay image in tagged output keeps its alpha gs and may trip PDF/UA 7.1 - prefer the overlay, or a pre-faded asset.
An image that cannot be read (missing, empty, cut short, not a picture) stamps a grey placeholder box - a square, since the file gave no aspect - in its place on every page it marks, and the render says so once: aplayout: watermark image "draft.png" cannot be read (not found) - a placeholder box is stamped in its place (at /pageSetup/watermark). See "Vector graphics, images and SVG", Files that cannot be read.
Letterhead - pageSetup.template
{ "src": "letterhead.pdf", "page": 1, "on": "first" } (or just the path as a string) draws one page of an external PDF stretched to the full trim page under everything except the page background - corporate stationery beneath generated content. The page is imported once as a Form XObject (vector, cached per source), wrapped as a Page artifact.
- on: all | first | rest - per section, so a letter's continuation sheets can stay plain (first), or the reverse.
- Painting order per page: background -> letterhead -> underlay watermark -> body -> overlay watermark.
- PDF/UA: the template source's fonts must be embedded (same constraint as the pdf placement block; an unembedded base-14 source fails 7.21.4.1).
- A source that cannot be read - or a page it does not have - fills every page with a grey placeholder under the content and is said once: aplayout: page template "letterhead.pdf" cannot be read (<why>) - a placeholder box fills the page under its content (at /pageSetup/template). (It used to print a warn: DrawPdfPage ... line per page, and a page past the end drew the source's last page.) See "Placing existing PDF pages".
Samples
watermark (dual + angle + image stamp, tagged 2a), letterhead (stationery on the first page, tagged 2a).
# Design-tool support
Five engine features exist specifically so an interactive design tool (a layout / form-design GUI) can sit on top of PageWright: the layout map (hit-testing rendered geometry back to spec nodes), absolute positioning (direct manipulation + form overlays onto existing PDFs), named stylesheets (style edits that ripple), font enumeration (a picker that only offers fonts the engine can resolve), and the TextShop (the engine's own answer, per keystroke, to what a text block's style and line geometry are - the WYSIWYG editor's oracle).
1. The layout map (-map)
pagewrightapp spec.json out.pdf -map out.map.json
Server: LayoutDocument -infile spec.json -outfile out.pdf -mapfile out.map.json (the -mapfile parameter is consumed by the command - it does not persist to later commands).
Alongside the PDF, the engine writes where everything landed, collected on the final render pass (counting passes never record):
{
"version": 1,
"units": "pt",
"origin": "top-left",
"pages": [
{ "width": 612, "height": 792, "label": "iv", "section": 0,
"content": { "x": 54, "y": 54, "w": 504, "h": 664.8 },
"header": { "x": 54, "y": 36, "w": 504, "h": 22 },
"footer": { "x": 54, "y": 743.6, "w": 504, "h": 12.4 },
"columns": [ { "x": 54, "y": 54, "w": 241, "h": 664.8 },
{ "x": 317, "y": 54, "w": 241, "h": 664.8 } ] }
],
"blocks": [
{ "path": "/sections/1/blocks/3", "type": "container", "id": "intro",
"fragments": [ { "page": 2, "x": 54, "y": 54, "w": 504, "h": 503.2 },
{ "page": 3, "x": 54, "y": 54, "w": 504, "h": 16.8 } ],
"visual": [ { "page": 2, "x": 130, "y": 160, "w": 378, "h": 16 } ] },
{ "path": "/sections/1/blocks/4", "type": "columns",
"fragments": [ { "page": 3, "x": 54, "y": 90, "w": 504, "h": 120 } ],
"columns": [ { "page": 3, "x": 54, "y": 90, "w": 246, "h": 120 },
{ "page": 3, "x": 312, "y": 90, "w": 246, "h": 120 } ] }
]
}- Coordinates are PDF points with a top-left origin, y increasing downward - the engine's own layout space, which matches a raster of the page (e.g. Do_PageToImage output) after scaling by dpi / 72.
- pages - one entry per physical page in order: trim size, the body content box (mirrored on verso pages when mirrorMargins is set; empty for inserted blank versos), the formatted folio label ("iv", "7", "000123"), and the owning section index.
- header / footer / columns - the page's GUIDES, for a tool that draws margin, band and gutter rules over a preview. Each is omitted when the page does not have it: no header key means no running header at all, which is not the same as a band of zero height, and columns is absent on a single-column page because there the one column is content. All three follow the same per-page reality the body does - mirrored margins flip the bands with the content on verso folios, a headerMargin of -1 resolves to the top margin, and a column count that leaves no room has already collapsed to one. Take them from here rather than recomputing them from pageSetup: the recompute agrees on the easy pages and drifts on exactly those three cases.
- blocks - one entry per spec block that placed, keyed by path, a JSON Pointer into the source spec (/blocks/3, /sections/1/blocks/0, /blocks/2/items/1, /blocks/5/rows/2/cells/0/blocks/0). Paths address the actual JSON shape, including implicit nodes: a bare-string list item or table cell is addressed by the item/cell pointer itself. The pointer is the GUI's edit handle - hit-test a click to a path, mutate that node, re-render. Threaded story content (poured into a frame) is addressed in its story, not the frame: /stories/0/blocks/2 (or /stories/<key>/blocks/<j> for the object form). A story block that splits across frames produces one map entry per frame it touches, so a click on any continuation resolves to the same source paragraph.
- type - the block's own type as the spec spells it, lower-cased. Where one engine class parses from several spellings this is the spelling the author wrote, not the class: a loc reads "loc" and not "lof", so a tool grouping or filtering blocks by kind can tell a list of charts from a list of figures. Blocks the engine converts still report what was drawn (a mermaid becomes "svg"), because that is what the geometry beside it describes.
- story / threadIndex / storyMore / storyMissing (frames only, all omitted when they say nothing) - a frame that pours a story reports which story, and where it sits in that story's thread: threadIndex is 0-based in placement order, which is the order the text actually flows, not necessarily document order. storyMore means content remained for the next frame - so only the LAST frame of a thread can still report it, and a thread whose final frame says storyMore is overflowing. storyMissing means the frame names a story the document does not define. Do not derive overflow from a missing entry. A story that overflows simply omits its tail from the map - and so does a false when, an empty pre, and an if block that did not fire. Those four are one observation to a consumer counting fragments; storyMore is the engine saying which it was. A master-page frame never reports any of these (see frames): it is stamped once per page, so any thread number it carried would describe one stamp and be published over every page's fragments.
- images (image blocks only; omitted when the file cannot be read) - what the block drew, one record per placement: {"page", "src", "pixels": [w, h], "crop": [x0, y0, x1, y1], "ppi"}. A -merge record, a repeat clone and a book chapter reuse one path, each with its own photograph, so a path placed on three pages has three records. pixels is the photo upright (its own EXIF / TIFF / HEIF orientation applied, before rotate), and crop is the part the box shows, as fractions of that upright photo, 0 = left / top - the frame focus is written in, so a tool can read a crop and write a focus without converting: rotate and flipH / flipV turn the picture but do not change which part of the photo it shows, and the crop says the same. contain and fill show [0, 0, 1, 1]. ppi is the lower axis's resolution as the page carries it - the transform in force and any images.maxPpi downsample counted - the figure images.minPpi compares. A crop keeping half a portrait photo's height is a perfectly good picture on the page; this is where a workflow finds out it happened (the postcard workflow's report reads it).
- overset / oversetLines (positioned blocks only, both omitted when the block fits) - a block with a position height whose content did not fit it. What does not fit is dropped, so the drawn fragments alone cannot tell a full box from one that clipped: overset says some content was not drawn, and oversetLines how many lines of a paragraph, heading or pre were left out (absent when the leftover is not a run of lines - a container's later children, a table). The same render prints one aplayout: positioned ... is clipped ... notice per block, ending in its pointer (a block from an included partial has none of its own and ends in its include's). A pointer placed more than once - a repeat's clones, a book's chapters - keeps the count of its first clipped placement, the one the notice gives. A frame never reports overset: its leftover threads on, and storyMore says so.
- fragments - the flow band(s) the block consumed: full slot width x used height, one per page/column slot it touched (a split paragraph or container has one fragment per page). This is what a structure editor highlights. A fragment of a transformed positioned block also carries transform, that placement's own matrix (the entry-level key's form; omitted when upright): a pointer placed many times - repeat clones, -merge records - is one entry whose placements may each be turned their own way, and the entry-level transform / angle / pivot are only the first turned placement's. A fragment of a positioned block with repeatAt also carries repeats: that placement's copies, [{"x": 270, "y": 0}, ...], in the spec's own shape - points, y down, and a page-space step. Copy k is everything this placement drew, exactly as drawn, moved by repeats[k]: the fragment's box as its transform turns it, and the entry's visual boxes and images records on that page, each moved by the same offset. A turn stays with the drawing and the step does not turn with it - a card turned 90° and stepped 270 pt right is a row of turned cards across the page, and a positioned block inside a turned container steps across the page too, not along the container's axes. The copies are one drawing, so they are not entries or fragments of their own: a click on any of them is a click on this path, and a tool subtracts the copy's offset from the point before asking where in the block - or in which of its children - it landed. A master-page frame with repeatAt reports them on every page's fragment. Omitted when the block has none, so every other map is byte-identical.
- transform, angle, pivot (a transformed positioned block only) - transform is the composed page matrix [a b c d e f] in PDF operand order; angle (degrees counter-clockwise) and pivot are written only when that matrix is a pure rotation, and then turning the untransformed box by angle about pivot reproduces transform exactly. The angle is the matrix's turn, not the written key: mirrorH + mirrorV (or scale: [-1, -1]) is a half turn, so "angle": -8 with both mirrors reports 172, exactly as a plain 172 block does. A written angle that already describes the matrix is reported as written (400, -270); a derived one is in (180, 180]. A reflection, scale or skew reports transform alone, and a block inside a transformed group reports transform alone too (see "Vector graphics, images and SVG", Groups).
- visual (when present) - precise drawn boxes where the flow band is not the truth: the actual image / placed-PDF / chart / barcode rectangle (floats consume ~no flow height, so their band alone is useless), and each form-field widget box (a radio group records one per option). This is what a form designer drags. Any block that narrows itself records one - a width on a chart, image, svg, math, barcode, shape, bar or table, and a table's tableAlign, all shrink or shift the drawn box inside the flow band. A frame records one too, and it is the frame's whole drawn box: a frame's fragments are only where its poured TEXT landed, so an empty frame, or the blank lower half of a part-filled one, had no geometry at all and a click there fell through to whatever sat behind it. Prefer visual over fragments for hit-testing and selection: a 50%-wide chart's band is the full column, and selecting from the band alone grabs twice the chart. Use fragments for what the block consumed (a structure outline, revision bars), visual for what it drew. A table records one per drawn fragment, spanning its GRID only - the columns' own width, and the rows' own top-to-bottom, so it excludes spaceBefore/spaceAfter and agrees with colEdges/rowBands. A fragment that inks nothing records nothing, so a block may have more fragments than visual boxes.
- columns (a columns block only) - the box each of its columns occupies, left to right, one per column per placement. The page-level twin of pages[].columns above, in the same shape and for the same purpose: a tool ruling the gutters of a multi-column block. Omitted for every other block type and for a one-column block, which has no inner edge to rule. Do NOT derive these from the children's fragments/visual: a child reports what it drew, and a right-aligned figure or a shrink-to-content table sits nowhere near either edge of the column holding it.
- tagSlots (a table only, when the table was tagged) - one entry per cell as the tagger emitted it, in draw order: p page, r spec row index, c logical column (the author's index, not the drawn one), n colSpan, m rowSpan, t tag (TH/TD), s scope (omitted on a TD), x: 1 when the cell is synthetic - a rowGroups heading's deferred TH, which belongs to no spec cell at all. This is the one part of a table a tool cannot derive: geometry is already complete in colEdges/rowBands, but under rowGroups the engine rewrites what it tags - a group heading becomes a rowspanned TH Scope=Row on the next row, a total row's label is widened over the synthetic columns - and re-implementing BuildRowGroupPlan outside the engine would make any accessibility badge drift into lying. A repeated header band records nothing: a continuation fragment re-draws those rows as a Pagination artifact, and the structure tree emits them exactly once however many pages the table spans. So the slot count equals the table's TH + TD count in the tagged PDF, exactly - 527 for the budget sample, 179 TH and 348 TD, matching what ap_addstruct extract reports. Omitted when empty, so a map for a document with no tagged table is unchanged.
- id is echoed when the block declares one. Ancestry is derivable from the path string itself (/blocks/3/blocks/0 is inside /blocks/3), so a GUI can resolve a click to the deepest entry and walk outward.
apparatus (root key, written only when the document has an index, a glossary or a bibliography) - what those blocks did with what the author wrote, which is the one thing the rendered page cannot say. Three arrays, each row carrying the JSON Pointer of the authored entry so a tool can select the node that needs fixing:
- indexTerms - path, label, sortKey, heading (the letter or stroke group it files under), level (0 main, 1 sub), occurrences, pages (the range-collapsed labels, formatted by the same rule the printed index uses), plus labelOnly: 1 and printed: 1 when true. One row per authored term, including the ones that occur nowhere - that is the point of the array. Three states an author cannot otherwise tell apart: occurrences: 0 with no printed means scanned for and dropped; occurrences: 0 with printed means it reached the page as a bare header (a labelOnly group whose subentries hit); and a term with occurrences is ordinary. heading is computed unconditionally, even when letterHeadings is off, because an editor shows "files under" regardless and recomputing it outside the engine would mean porting the embedded Unihan reading table.
- glossaryEntries - path, term, and linked: 1 when autoLink found a first body use and hyperlinked it. Covers the entries that survived parse: an entry with an empty term is dropped at parse time and has no row.
- bibEntries - path, key, number (the printed citation number; 0 = never cited), and pruned: 1 for a work citedOnly removed. Without pruned, "cited nowhere and therefore dropped" and "never written down at all" look identical afterwards, and they have completely different fixes.
[!] The three arrays do not line up with what is printed, and each is asymmetric in its own way - see each bullet. [!] The pointers use the key the author wrote: an index block accepts terms or words, and the pointer is built from whichever appeared, so never hard-code one. Sizing: a 5,000-term index adds roughly 400 KB to the map.
- Engine API: LayoutEngine::Render(doc, target, &map) + LayoutEngine::WriteLayoutMap(map, path, err).
2. Absolute positioning (position)
Any block may carry
{ "type": "field", "field": "text", "name": "email", "tooltip": "Email address",
"labelPos": "none", "height": 16,
"position": { "x": 131, "y": 240, "width": 378 } }- Coordinates are points from the trim page's top-left (not the content box). width defaults to 144; height 0/omitted sizes to content, otherwise content past the height is clipped (the pagination remainder is dropped) - and said so: an aplayout: notice naming the block's pointer, once per render, and overset on its map entry (a block inside a running header or footer band is not yet reported). A positioned paragraph, heading or pre keeps every line that fits (no line is held back for widow control, since there is no next page), and a paragraph or heading with "shrinkToFit": true shrinks its text until it fits instead - every run by one ratio, none smaller than minFontSize (default 6 pt), and a run written smaller keeps its size. When it cannot help (the text is already at its floor, or padding fills the box) the notice says so.
- "verticalAlign": "middle" / "bottom" (default "top") places the content of a positioned paragraph, heading, pre or container at the middle or bottom of its position.height when it is shorter than the box. The offset is (height - content height) / 2 or (height - content height), with the content height taken from a dry run of the SAME placement (same lines, same shrinkToFit size, same padding), so it is exactly what is drawn. A text block moves whole; a container's box stays where it is drawn and its children move inside it (a child with its own position stays put). Content as tall as the box or taller does not move, and clips and reports exactly as before. The map entry keeps reporting the author's box; a container's children's entries, and every TextShop line, carry the moved placement. A TextShop dry re-place - a patched block (reflowed), a -width override, the inked band - solves the offset again for the text it places, so an edit that changes the content's height is shown where the next layout will draw it (a container's content moves by the change in height of the child that holds the edit - for a paragraph in a table cell, the row's change, since a row is as tall as its tallest cell). Refused with a notice, and ignored, on a block with no position.height, on any other kind of block, on text set along a textPath d curve (the curve is stated in fractions of the whole box, so it is drawn across all of it; the arc form aligns), and on vertical text whose columns fill the box - columns run the full height they are given unless a paragraph's or a heading's boxHeight is shorter, and then they are placed like any other content.
- "blockAlign": "center" (a style key; also "left" / "right") sets a paragraph's lines as one block inside the box: every line keeps its own align, against a box as wide as the longest line, and that box is centred (or put left / right) in the width. The address on a postcard: flush left line to line, centred as a group - with verticalAlign: "middle", in both directions. TextShop's lines[].boxX / boxW report that block, the box each line really aligned in, and resolved shows blockAlign when it is set.
- A positioned block leaves the flow: it renders on whatever page the flow has reached at its spec position, consumes no flow height, never splits, and ignores the page's float exclusions. Tag order still follows spec order, so keep the spec sequence sensible for PDF/UA reading order.
- Positioned form fields pin the widget box exactly to the position rectangle (the flow path's breathing space and row-centering are disabled), which is what a drag-and-drop form designer wants: map visual rect == position rect.
- An empty container with a border/background and an explicit height paints that exact box (a checkbox square, a signature box, a frame).
- A rule with position draws a line at fixed coordinates (width = length).
- A positioned block inside a positioned container is still placed in PAGE coordinates, not the container's - unless the container opts in (below); the container's "clip": true does crop it, and its angle turns it with the group. A repeat in the container's body gives each clone its own position.
- "childOrigin": "container" on a container measures its positioned descendants' x / y from the container's border box instead - the box the map shows: a positioned container's fragment (its position rect), a flow container's visual rect (recorded for a relative flow container even when it paints nothing; on a page a split one continues onto, that page's fragment). The nearest container that writes the key decides ("page" resets; an unmarked one is transparent), and width / height are unchanged.
{ "type": "container", "childOrigin": "container",
"position": { "x": 200, "y": 200, "width": 288, "height": 180 },
"blocks": [ { "type": "shape", "shape": "ellipse", "fill": "#c22",
"position": { "x": 10, "y": 10, "width": 40, "height": 40 } } ] }The dot draws at page 210, 210, and everything the engine reports stays in page coordinates: the dot's map fragment is x: 210, y: 210, as are its TextShop lines, links and notices. So a tool that drags a sticker converts once, on the way back into the spec: written x = dragged page x the corner of the nearest childOrigin: "container" ancestor's box (from that ancestor's map entry), and the same for y. A turned container's children turn with it: convert in the container's upright frame (invert its transform first), exactly as for a child written in page coordinates. See "Vector graphics, images and SVG", Container-relative coordinates.
- Bound from data. x, y, width, height - or the whole position - and the transform (angle, scale, skew, mirrorH, mirrorV) may be "{{path}}" tokens, so each record of a template places its own sticker; the map, TextShop and every notice see the record's numbers. A record without a usable x / y is reported and the block is not drawn. See "Data-driven documents", Numbers from data: a block's position, size and transform. A design tool that writes a dragged box - or a rotate / scale / flip handle - back should write numbers only when the key was numbers: overwriting a token pins every record to one place.
The form-overlay recipe
Re-host an existing paper form as a fillable, tagged AcroForm (sample/form-overlay.json, built over sample/sample-form.pdf):
- pageSetup.template: { "src": "original-form.pdf", "page": 1 } draws the original page as a full-trim underlay artifact. For PDF/UA output the source's fonts must be embedded.
- Position one field block per blank, labelPos: "none", a tooltip for the accessible name (PDF/UA needs it when there is no visible label), explicit height for the widget.
- Multi-page forms: one section per source page (each section restarts the template with its own page).
- The GUI loop: render with -map, display a page raster, drag the visual rects, write the new position back through the block's JSON Pointer, re-render. The overlay sample passes veraPDF PDF/UA-1 and PDF/A-2a with zero failures.
3. Named stylesheets (styles)
"styles": {
"brand": { "color": "#1b3a5c" },
"note": { "background": "#eef2f8", "border": "#b8c2cf", "padding": [8,10,8,10] },
"warning": { "basedOn": "note", "background": "#fdf0ee", "color": "#7a2e20" }
}- Any "style" value below - on a block, a table cell, a running band, the document defaultStyle - may be a string (a reference into the table) or an object with basedOn (start from the named entry, override just the keys given).
- basedOn chains flatten at parse time, any depth; cycles are broken safely (the unresolved base is ignored). Sample: sample/stylesheet.json.
- For a GUI this is the "edit the style, the document follows" primitive: keep styles in the table, reference by name, and a single table edit restyles every use on the next render.
4. Font enumeration (-fonts)
pagewrightapp -fonts
prints every system font APDFL can resolve, sorted, as JSON:
{ "fonts": [ { "name": "Arial-BoldMT", "type": "TrueType" }, ... ] }A font picker should offer these (plus the Base-14 names) rather than the OS font panel, so every choice is renderable and embeddable by the engine. (The spec's embedFont takes a family name like "Arial"; the engine derives the " Bold"/" Italic" style variants itself.)
5. The TextShop (-textshop, and the server's text* verbs)
The layout map says where a block landed. An editor also needs to know what the engine made of the text - the style it actually set a block in (after every cascade, heading default, fitWidth fit and side-head rule), and where every line and every word landed - and it needs to ask that per keystroke, for one block, in a millisecond, and get the same answer the next full render will draw. That is the TextShop: a resident session holding the spec, a render target whose font caches stay warm, and the placement the last layout captured for every authored text block.
Capture. On the final render pass, when a caller asks (LayoutMap::text), every paragraph, heading and pre records what it was placed with (the parent style handed to it, the resolved style it used, the rect, the context flags in force) and every line DrawOneLine painted for it - box, baseline, per-piece text/x/width, an interned style index. Off, the hook costs one null test per line; on, it changes no drawn byte (gated: the whole corpus renders identically with the capture on). A block split across pages records one fragment per page; a repeated table header records one placement per repeat.
Dry re-place. A patched block (or a "what if this column were narrower" question) is answered by running the same Block::Place again on a counting target seeded from the captured placement - through the same capture hooks - so line breaks and piece positions can never be a second implementation of the layout. A parity check proves that: for every captured block in the corpus the dry re-place equals the render's record to the piece - 14,727 of 14,876 identical, the rest refused by design (a story paragraph re-flows per frame; a repeat clones one path; a paragraph hosting a run-in heading), zero unexplained.
Layout again. Layout() re-parses the retained spec with the patch overlay applied and renders map-only into the resident target. It re-parses on purpose: LayoutEngine::Render numbers headings, notes and citations by mutating the model, and is not idempotent on a second pass - a fresh model per layout is what keeps numbering right. Parse is ~4% of a settle; the win is the resident target (CJK newsletter 184 -> 1.2 ms; a 79-page manual 407 -> 220 ms).
Where a piece came from. Every drawn piece carries a src - (run, byte, len) into the rendered block's runs, reported alongside as runs (each flagged synthesized when a numbering pass inserted it, which is the difference between the document the engine laid out and the one the author edits). len: 0 with a run present means synthetic-but-anchored: a cross-reference, a note marker, a citation, an inline field. That is the anchor an editor maps an engine line break back through - without it the only way from "the engine broke here" to "character 37 of this paragraph" is to re-derive the breaking, which is the second implementation this whole file exists to avoid.
A tab claims its own byte (it draws an advance, not glyphs - without the claim two adjacent tabs are indistinguishable from one and every offset after the first tab is quietly wrong), and a badge claims its run in full, because the loader put the badge's LABEL in the run's text: it is authored text an editor can map to, not a synthetic marker. A path several blocks drew under is reported cloned on BOTH the dump and the wire - its runs are one twin's, so a span read against them is not answerable, and the caller has to be able to know that.
Six transformations re-cut a token's text after it is tokenized and each carries the span through: the CJK fallback expander (which splits an authored run before tokenizing, hence RunOrigin), small-caps expansion, hyphenation, the emergency overwide split, the bidi/shaping split, and optical-margin hanging punctuation. They run on BOTH line builders - one list, PrepareParagraphTokens, serves the plain path and the token path (a frame, a float, a drop cap, a run-in body) since 2026-10-18; a drop cap's shortened first run composes its RunOrigin with the expander's, so its spans still name the authored bytes. The emergency split cuts a word's span through the author's soft hyphens (two source bytes each, gone from the drawn text), as hyphenation always did: until 2026-10-19 it counted drawn bytes, so on every path the chunks of a soft-hyphenated word wider than its line quoted the wrong letters and its tail was claimed by nothing - the per-corpus check had no such word to see it with, and a dedicated check now has one. A coverage check asks two questions of every corpus block - does each span quote its piece, and do a block's spans cover its run text exactly once - and it is the coverage half that has teeth: a map that silently stopped halfway through a paragraph would pass quotation, because every span it did emit still quotes correctly. 16,001 blocks, 237,631 pieces, zero failures. --selftest shifts one span by one byte and requires the run to fail, which it does; a gate that cannot fail proves nothing, and one byte is exactly the size of the two drift bugs found below.
Two properties of that gate were wrong when it was first written and are worth stating, because both are the shape a gate fails silently in. Coverage iterates the runs that EXIST, not the spans that were found - keyed off the spans, a run nothing drew has no entry and is never examined, which hid 41 real corpus runs. And the fixedHeightBox trailing-gap exemption is a ceiling, not a permission: the flag records "was placed in a fixed box" (true of 60% of fragments), while the exemption is genuinely needed exactly once in the corpus, so it is counted and the count is enforced. The other exceptions are read off the engine's own report rather than assumed (dropCap's floated initial is drawn outside the line model, a cloned path is several blocks sharing one pointer, a tab piece draws no glyphs to quote).
Open again. A re-open is the editor's normal case, not an exception: an app that edits its own copy of the document re-opens the session from the changed text after every change, so Open() runs far more often than Layout() does. It therefore drops only the session and leaves the resident target standing - LayoutInternal rebuilds it anyway if the new document changes the one thing a target is built for (pageSetup.embedFont), so keeping it cannot answer for the wrong document. Close() is what drops the target. Measured with -reopen (2 pp · 6 pp · 101 pp): a re-open cost 5.4 / 13.3 / 224 ms when it rebuilt the target and 1.6 / 10.0 / 196 ms when it kept it - i.e. exactly what the layout costs, with the warm-up paid once per process rather than once per edit.
pagewrightapp -textshop <spec.json> -dump capture.json # every block's placement pagewrightapp -textshop <spec.json> -box /sections/0/blocks/3 # one block: style + lines pagewrightapp -textshop <spec.json> -patch /sections/0/blocks/3 frag.json -box /sections/0/blocks/3 pagewrightapp -textshop <spec.json> -caret /blocks/1 -at 82.4,105 # which character is at a point pagewrightapp -textshop <spec.json> -caret /blocks/1 -of 0,7 # ...and where an offset's caret goes pagewrightapp -textshop <spec.json> -parity # the gate; exit 1 on drift pagewrightapp -textshop <spec.json> -caretsweep # the caret gate; exit 1 on drift pagewrightapp -textshop <spec.json> -relayout 3 -reopen 3 # residency: what a settle / an edit costs pagewrightapp <spec.json> out.pdf -textdump capture.json # the capture of a REAL render
The server (apdoclibapp) exposes the same as verbs on both carriers (stdin and -socket): textOpen -infile <spec> · textPatch -path <ptr> (fragment as the request body after the command line's newline on the socket, -fragmentfile on the pipe) · textBox -path <ptr> [-width W] · textCaret -path <ptr> -at <x>,<y>[,<page>] | -of <run>,<byte>[,t] · textLayout [-mapfile <f>] · textClose. Replies are JSON lines; refusals carry a code (E_NO_SESSION, E_PARSE, E_BAD_PATH, E_FONTDIR_FROZEN, E_NOTCAPTURED, E_ENGINE). textOpen refuses a document naming a font folder the server was not started with (APDFL learns its folders once) - the client's remedy is one respawn with -fontdir.
The answer JSON (TextBoxToJson): path, source (placed = the render's own record; reflowed = a dry re-place), provisional (a fresh fragment: note numbers and heading numbers absent until the next layout), fragments[] each with type, pathTaken, page, placeRect, parent, resolved (the style fields the editor cascades), firstBaselineY, lineAdvance, lines[] (boxX, boxW, top, baseline, align, pieces[] with text, x, w, style index), and styles[] (the interned piece styles).
A positioned block with a height adds two fragment keys, each only when it has something to say: shrinkRatio - the scale shrinkToFit applied: the block's largest text size (its own, or a bigger run's) as set over as written, and every other size the same multiple of its own, held at minFontSize (resolved already carries the shrunk size, which then also shows shrinkToFit and minFontSize; a dry re-place solves the size again from the block's own position height) - and droppedLines, the lines laid out and not drawn because the box discards the rest, which lines[] alone cannot say.
bold and italic are the face that was DRAWN, not the style that was asked for. Every style resolution ends in a fallback to the family's regular cut so a family shipping one face still draws - the right drawing decision, and for a long time the record reported the request anyway. A heading in Arial Unicode MS came back "bold": true while the engine printed "has no bold face - drawn in the regular face instead" about the same run, and an editor drawing from the record opened it bold over a page that is roman. The produced PDF settles it: one embedded face, no bold companion.
Three keys carry the rest of the answer, each present only when it has something to say:
| key | meaning |
| boldAsked / italicAsked | true when the author asked for the style and the page did not get it. Emitted only when it differs from what was drawn, so the author's intent survives for an inspector row or a "this family has no bold cut" notice. |
| styleUnknown | true when the backend could not answer - it had not resolved that face, or the target has no font machinery. bold/italic then echo the request. Without this key that state would be byte-identical to "asked and got it", which is the same silence the change removed. It does not arise on the shipped corpus. |
[!] resolved answers about the block's own cascaded family, which its runs may override. A Georgia document whose only heading run names Minion Pro embeds Minion Pro alone, and resolved reports Georgia bold: true - truthfully, since Georgia's bold is real - while every piece of ink is Minion Pro roman, which styles[] reports as bold: false, boldAsked: true. To know what the INK looks like, read the piece styles. A regression check referees the record against the faces in the rendered PDF.
caret - which character is at a point, and where a character's caret goes
textCaret answers the one question the record cannot: inside a word. A piece is a whole word with a single width, so a consumer walking into it can only interpolate by character count - two or three characters out in a long word in any proportional face. The engine has the face, the size, the tracking and the stretch the line was measured with, so it measures the prefixes exactly; anything else is a second implementation of the measurement.
-at <x>,<y>[,<page>] (page points, the record's own space; the 0-based page the point is on - a block taller than a page has lines on two pages at the same y; without it, the page of the line the search without columns finds: the first in draw order whose band holds y, else the nearest) -> the source position there. -of <run>,<byte>[,t] -> where that position's caret goes. Both answer the same JSON:
{"run":0,"byte":7,"x":81.34,"top":101.41,"height":9.25,"page":0,"line":0,"piece":1}- run / byte - an index into the RENDERED block's runs and a byte offset inside it: the same currency a piece's src speaks. run is -1 where the point has no source under it at all (a tab's gap); the geometry is still answered.
- x / top / height - the caret to draw: the line's top and its ascent + descent.
- trailing - AFFINITY, and it is not bookkeeping. One offset has TWO positions: the byte after a piece's last character is also the byte before the next piece's first, and those are different places whenever anything sits between them - a justification gap (CJK justifies with no spaces at all, so every offset is shared), a line break, a hyphenated word, a style split. -at reports which one it found; -of ...,t asks for the trailing one. In a bidi line the two can be at opposite ends of the line.
- rtl - the piece runs right-to-left, so its first source character is drawn at its RIGHT edge. Pieces carry the same flag in textBox now - on EVERY route a paragraph takes: a record whose pathTaken is tokens (a story's frame, a float), dropCap, runIn or continuation describes its words as a plain one would for the same text - reversed and split where the levels change, rtl set, spans cut where the words are, the line's align the paragraph's start edge: line for line where the line is the plain path's width, word for word where it is not (the lines break elsewhere, so a word hyphenated at one record's line end can be whole in the other's, and a word wider than its line is cut at THAT line's width). Until 2026-10-18 those routes skipped bidi and shaping altogether, so their Hebrew came back as logical-order pieces with no rtl, aligned left, describing a page that read backwards. A regression check sweeps every one of these cases with textSweep.
- synthetic - the piece is engine-composed (a note marker, a cross-reference): addressable, not editable. Its span is len 0 at its anchor in EVERY direction - until 2026-10-18 a marker in a right-to-left paragraph came back with a span one byte past the end of its run and no synthetic, because the bidi pass re-cut it like a word. approx - the piece draws a different number of characters than its source has (an Arabic lam-alef ligature, small caps), so the position was scaled between the two ladders rather than mapped character to character.
By point, x picks the column before y picks the line (2026-10-16). One block can have lines side by side at one height on one page - a paragraph flowing on through the page's, a section's or a frame's columns, a story threaded through frames set side by side, a repeat drawn once per card of a grid - and the search used to take the first line in draw order whose band held y: a click in column 2 answered column 1's line at that height, and a point below column 2's text answered column 1's first line. Now:
- A column is a fragment: each page column, frame and column of a frame is already its own placement, on one page. Its extent across is the union of its lines' boxes [boxX, boxX+boxW] - the box, not the glyph extent, so the empty end of a short line (a column holding one spilled word) still belongs to its own column.
- Two fragments are a pair only where they stand side by side: their extents are apart across (touching is apart - columnGap: 0 leaves the boxes 1e-14 over each other, inside a 0.01 pt slop) and a line of one shares a height with a line of the other. A wide frame above two frames overlaps both across, so it is in no pair and never joins them into one; one column wrapped past a left panel and then a right one is one fragment; frames stacked but staggered never share a height.
- Of each pair, the fragment the point is not in leaves the line search. Best first: the one whose extent holds x (above or below its text too: that column's start, its end); else one beside the point - y within its lines' height - the nearer across; else the nearer in the plane. Ties go to draw order. "Beside" is the fragment's height and not a line's band, so a point in a gutter beside column 1's text stays with column 1 in the leading between its lines as well as on them.
- Within what is left, the line rule is unchanged: the line whose band holds y, else the nearest.
A block with no pair on the page - every single-column block, and one whose fragments never stand side by side - is searched exactly as before, by construction. Without a page, the page is the one the search without columns answers on (the whole answer before this change) and only that page's pairs count, so a block that is one column on each page answers as before however its columns move from page to page. Measured over the corpus against the engine before this rule: every answer on a page with no pair is unchanged, byte for byte, with the page and without it; the only pairs in the corpus are the cloned card grids. Right-to-left text is decided the same way (the rule is geometric), and vertical (直排) text is not captured at all (below), so textCaret refuses it as before. A regression check runs thirteen column cases and seven one-column controls, judged probe by probe against an oracle of this rule, with the page and without.
The gate is -caretsweep: every piece of every captured block, probed across its width, with five assertions - the byte is inside the piece's own span, it moves one way as x crosses (the other way for an RTL piece), it is a UTF-8 character start, feeding it back returns the same x, and the line that answered is on the probed line's page and in its column (2026-10-16; before it, an answer in another column passed as "snapped into a neighbour"). 57 documents, 291,609 probes, 0 failures when it was written; a regression sweep runs it over the whole corpus.
Not captured, by design: vertical text, run-in headings drawn inline, captions, list markers, footnote/sidenote bodies, running bands, master-frame text (stamped on every page), generated rows (TOC, index, glossary).
Gotchas
- Pass absolute paths to the CLI. A relative spec path makes relative asset references (template.src, pdf blocks) resolve relative to the process CWD, and APDFL-opened sources fail silently - the underlay just doesn't draw. The output path must be absolute regardless (APDFL raises on a relative save path).
- The map records spec blocks only: engine-synthesized content (running bands, footnote bodies, repeated table headers, watermarks) has no spec pointer and is deliberately absent.
- Fragments of a block split across pages accumulate on one entry - pagination remainders inherit the source block's pointer (and its id, which also fixes cross-references to keep-with-next-deferred headings).
- position interacts with the counting pass consistently (absolute blocks consume no flow height on either pass), so TOC/index page numbers stay correct.
# Hierarchical tables: row groups and 508 parity
Status: SHIPPED, 2026-08-19 - structural parity with the ground truth, verified.
A parity check reports IDENTICAL to ground truth (1 table(s), 88 TR, 527 cells, scope/span/text/headers all equal) for the budget-hierarchy-table sample. veraPDF UA-1 compliant, ap_addstruct audit 0 findings, 3 pages, ink unchanged.
What is NOT at parity, by design: the remediated file flattened our <Sect> wrapper and tagged one running-header instance as <P> rather than a Pagination artifact (its bare /Artifact BDCs are its tool's re-encoding of the band). Those are outside the table and were not copied: the <Sect> is the engine's document structure, and a running header is the textbook Pagination artifact under PDF/UA. The parity check compares TABLES. Flagged here rather than decided silently.
Original status line: DESIGN, 2026-08-19. Ground truth is 508/budget-hierarchy-table_508.pdf, a professional 508 remediation of sample/budget-hierarchy-table.json. The goal is that PageWright emits that structure tree for that spec: the same elements, scopes, spans, /Headers and artifacts. Nothing in the remediated file is to be second-guessed here; where it differs from what the engine does today, the engine is what changes.
1. What the remediator built
The rendered page is pixel-identical to ours. Only the tags changed. So the structure does not follow the drawn grid - it is a logical table laid over the ink.
The six drawn columns become eight logical ones
drawn: [code][description][2024][2025 O][2025 P][2026 B] logical: [SECTION][SUBSECTION][code][description][2024][2025 O][2025 P][2026 B]
Two synthetic leading columns carry the hierarchy. The "Revenues" row is not a row at all: it is a TH Scope=Row rowSpan=38 in logical column 0, spanning every row of the section. Each subsection heading is the same in logical column 1. So a heading row of the visual table becomes a rowspanned header cell that sits in the first data row of its group.
Every element and attribute, from the file
| visual row | logical cells (left -> right) |
| column band (once, top) | TH Scope=Column colSpan=4 (covers the 4 header columns, empty) · TH Scope=Column x4 (the years) |
| first data row of a section + subsection | TH Row rs=N_section · TH Row rs=N_subsection · TH Row code · TH Row description · TD x4 |
| first data row of a later subsection | TH Row rs=N_subsection · TH Row code · TH Row desc · TD x4 |
| ordinary data row | TH Row code · TH Row desc · TD x4 |
| subsection subtotal | TH Row colSpan=2 (label over code+desc) · TD x4 |
| section grand total | TH Row colSpan=3 (label over subsection+code+desc) · TD x4 |
Measured rowspans, all of which add up exactly against the data:
Revenues 38 = 30 data rows + 7 subtotals + 1 grand total Expenditures 49 = 42 + 6 + 1 Property Taxes 6 = 5 + 1 ; Intergov 9 ; Grant 5 ; Fees 4 ; Licenses 4 ; Misc 7 ; Interfund 2 Personnel 13 ; Commodities 12 ; Services 15 ; Capital 3 ; Interfund Expense 2 ; Debt 3
/Headers - one rule, no exceptions
Every cell's /Headers = [ the row header immediately to its LEFT , its column header ].
- section TH -> [COL0] (nothing to its left)
- subsection TH -> [section, COL0]
- code TH -> [subsection, COL0]
- description TH -> [code, COL0]
- a year TD -> [description, COLn]
- a subtotal's TD -> [subtotal-label, COLn]; the subtotal label itself -> [subsection, COL0]
- a grand total's TD -> [total-label, COLn]; the label -> [section, COL0]
COL0 is the empty TH colSpan=4 in the band - it is the column header of all four logical header columns. A left-to-right chain, each cell naming its nearest header on each axis.
The rest
- One <Table> for all three pages (88 TR inside it). No THead, no TBody.
- The repeated column band on pages 2 and 3 is an artifact - the column headers exist once, in the single table. (108 /Artifact BDCs against our 102: the six cells of the band on two pages.)
- Section and subsection heading rows are not TRs - 103 visual rows -> 88 TR.
- Scopes used: Row x174, Column x5. No Both, no RowGroup.
- veraPDF UA-1: isCompliant="true", failedRules="0". ap_addstruct audit: 0 findings.
2. Why the engine cannot do this today
PageWright builds the tag tree from the layout grid: a cell is a grid position, a TR is a drawn row, a TH is decided by headerRows / headerCols / row.header. The remediator worked on the tree directly and could build any logical table over the ink. Four gaps:
| # | gap | today |
| 1 | one <Table> across pages | deliberately one per page ("each fragment is a complete table") |
| 2 | structure that diverges from the grid | impossible - no concept of a logical column that is not drawn |
| 3 | per-cell Scope | derived from position only; a mid-table header:true row gets Column, which is wrong here |
| 4 | /Headers derived | author must write all 522 by hand |
Gap 2 is the design. Gaps 1, 3, 4 fall out of it.
[!] One element across pages is LEGAL. A /K array holds marked-content references, each naming its own /Pg; nothing in ISO 32000 limits an element to one page. The per-page split is an engine habit (the TagScope lives for one placement), not a format rule. This was written down wrongly earlier in the same day and corrected against this very file.
3. The model: rowGroups
A table declares that certain of its rows are group headings, and how deep the hierarchy goes. Everything else is derived.
{ "type": "table",
"headerRows": 1,
"rowGroups": { "levels": 2, "headerCols": 2 },
"rows": [
{ "cells": [ "", "", "2024\nActual", "2025\nOriginal", "2025\nProjected", "2026\nBudget" ] },
{ "group": 1, "cells": [ { "colspan": 6, "blocks": [...] } ] }, <- "Revenues"
{ "group": 2, "cells": [ { "colspan": 6, "blocks": [...] } ] }, <- "Property Taxes"
[ "400101", "Property Taxes - Current", "22,279,049", ... ],
...
{ "total": 2, "cells": [ { "colspan": 2, ... }, "23,911,726", ... ] }, <- subsection total
...
{ "total": 1, "cells": [ { "colspan": 2, ... }, ... ] } <- section grand total
] }| key | on | meaning |
| rowGroups.levels | table | how many nesting levels of group heading (2 here). Each becomes one synthetic logical column at the left |
| rowGroups.headerCols | table | how many of the DRAWN leading columns are row headers (code + description = 2). Replaces headerCols when rowGroups is present |
| row.group | row | this visual row is a heading that OPENS a group at level N. It is not emitted as a TR; its text becomes a TH Scope=Row in logical column N1 of the group's first data row, with rowSpan = rows until the group closes |
| row.total | row | this row CLOSES the open group at level N. Emitted as a TR; its first cell's colSpan is widened by the synthetic columns it is covering (see §4) |
Note: labelColumn was never a key. Earlier revisions of this document listed rowGroups.labelColumn in the worked JSON above and in this table, described as informational. The parser reads only levels and headerCols of rowGroups, and the schema declares additionalProperties: false on the object - so a document copied from the old example is told "labelColumn" is not a key this accepts (at /blocks/0/rowGroups/labelColumn).
It did nothing when it was accepted and it is refused now; the heading is drawn where the author put it, which is what "informational" was trying to say. Removed rather than implemented (P02 E3).
A group at level N closes when: a total: N row is reached, a group: M row with M <= N is reached, or the table ends.
What a group row has to look like
Two rules the engine enforces and this document did not state. Break either and the render stops with error: internal: a deferred <TH> structure element was never adopted into the tree - which reads as an engine fault but is the backend refusing a document it cannot tag. The code says so itself: "A GroupHead with nothing after it would never be adopted; that is a document error the backend refuses at FinishDocument" (pass 1 of BuildRowGroupPlan).
- A group row carries exactly ONE cell. Its text becomes a single TH Scope=Row in a synthetic column; there is no second cell for a second cell to become. Writing a full-width row - the same number of cells as the data rows - is the natural mistake and it is refused.
- A group row must be followed by a row that is drawn. The head is opened deferred and adopted by the next <TR>, so a group that ends the table, or one immediately followed by another group row that also ends it, has nothing to attach to.
Rule 2 is the one that bites a data-bound table, and not in the direction you would guess. A for-bound group row over an empty array is fine - the head is never opened, and the table renders. It is a for-bound data row that expands to nothing which strands the group above it: the head was opened, the row that was to adopt it never arrived. So a table that groups correctly against sample data can fail against real data where one group happens to be empty.
Why levels is a count and not inferred. The engine has to know the logical column count before it lays out a single row - every /Headers list and every colSpan on a total row depends on it. Inferring it from the deepest group: seen would mean a two-pass parse for one number the author already knows.
Why group/total are on the ROW and not the cell. The hierarchy is a property of rows; a heading row has one cell spanning the table, a total row's label spans several. Putting the marker on the row object (which already exists - header, keepTogether, minHeight) keeps the cell model untouched.
4. Derivation - exactly what the engine emits
Let L = rowGroups.levels, H = rowGroups.headerCols, and C = the drawn column count. Logical columns number 0 .. L+C1; the first L are synthetic, L .. L+H1 are the drawn row-header columns, the rest are data.
The column band (headerRows)
The band's first H cells are drawn empty in the ground truth. Logically they become one TH Scope=Column colSpan=L+H with a generated /ID - the column header of every header column. The remaining band cells are TH Scope=Column, one per data column, each with a generated /ID.
Those first H cells may carry labels ("Code", "Description"), and whatever they draw lands inside that one TH: column 0 opens it and holds it, the other collapsed cells resume it. Until 2026-09-14 only column 0's content did - the others drew into the <TR> directly, which PDF/UA forbids (veraPDF 7.2-10), and nothing failed because every sample left those cells empty. A regression check now carries a fixture with labels there.
A group: N row
Not a TR. Its content is held and emitted as a TH Scope=Row in logical column N1 of the next TR (the group's first data row), with rowSpan = the count of TRs this group will contain.
[!] THE INK DOES NOT MOVE. ONLY ITS PARENT DOES. Verified in the ground truth: the "Revenues" TH rowSpan=38 contains <P> -> MCID 6, which is the very paragraph our render drew for the "Revenues" heading row, at its original position on the page. The remediator re-parented our existing marked content into a new cell; nothing was redrawn, no text was invented, no /ActualText was added. So this is exactly the kind of change the engine can make: draw the heading row where the author put it, and open the TH structure element around that drawing inside the next row's TR instead of inside a TR of its own. That means the structure-open for a group heading is DEFERRED - the ink is emitted on the heading row's own baseline, but its marked-content sequence is attached to an element created one row later. The MCID is page-scoped and already assigned at draw time, so deferring the element is a tree-building question, not a drawing one. That count is known only after the table is parsed, so the tree is built in two passes: pass 1 assigns every visual row to its open groups and counts; pass 2 emits.
/Headers of that TH = [ TH of the enclosing group (level N1) if any , COL0 ].
An ordinary data row
TR containing: the synthetic group THs that OPEN on this row (if any, leftmost first), then H cells as TH Scope=Row (the drawn code + description), then data TDs.
/Headers: each cell -> [ the row-header cell immediately to its LEFT in logical order , its column header ]. The leftmost header cell on the row names only [COL0] if it is a section header, or [its parent group's TH, COL0] otherwise.
[!] "Immediately to its left" means the nearest logical cell to the left that is a TH - which, for the first header cell on a row that did NOT open a group, is the rowspanned TH from an earlier row that still covers this row. That is why "Property Taxes - Back Tax" (TR2) has its code's /Headers = [subsection, COL0] though no subsection cell was emitted on TR2.
A total: N row
A TR. Its first cell is the label; the author writes it with colspan = k over the drawn header columns (k = 2 here: code + description). It is emitted as TH Scope=Row with
colSpan = k + (L N)
- the authored span plus the synthetic columns from this level down that the row still sits under. A subsection total (N=2, L=2) sits under the section's open span in column 0 but its own subsection span is closing, so it covers no synthetic column: 2 + 0 = 2. A section total (N=1) is under nothing, so it covers the subsection column too: 2 + 1 = 3.
Verified against all 15 total rows of the ground truth: 13 at colSpan 2, 2 at colSpan 3, the formula holds for every one.
/Headers of the label = [ the TH of the group at level N that it closes , COL0 ] - it names the heading it summarises. Verified: every subsection-total label names its subsection's rowspanned TH; both grand-total labels name their section's (rs=38 and rs=49). Its data TDs -> [label, COLn].
Page breaks
One <Table>. The element is opened on the table's first placement and held across fragments - PlaceTableFrom and RowSplit::Place share the scope rather than each opening one. Since 2026-08-28 this is no longer particular to row groups: every tagged table emits one <Table> across every page it is drawn on, and the row-group path is one caller of a general mechanism rather than the only one. [!] "Held" means the element is detached from the open structure stack between fragments and re-attached by ResumeHeldStruct for the next one - not left open. Left open, it adopted whatever was drawn in the gap: a footnote band at the foot of a spanned page arrived as <Table><Note> and failed PDF/UA 7.2-3. That shipped in this path and was invisible because no sample paired a row-grouped table with a footnote. The repeated header band on later pages is drawn inside an ArtifactScope (Layout) rather than re-tagged. A rowspanned group TH whose rows straddle a page is emitted once, on the page of its first row; its /K references content on that page only and its rowSpan still counts rows on later pages (the remediated file does exactly this: "Revenues" rs=38 spans pages 1-2).
4b. Mechanism - how the engine does this without moving ink
The backend builds the structure tree IN MEMORY (pNodes, each with a parent index and a kids list stamped by a reading-order clock) and writes /K and /P once, at FinishDocument. That is the whole reason this is tractable: a node's parent is an integer, and nothing about the content stream changes when it does.
Four render-target primitives, all layout-driven, all no-ops when untagged. The names are deliberately generic rather than table-specific, because nothing about them is about tables:
| primitive | does |
| int OpenStructDeferred( const StructAttr & ) | create a node with no parent, push it as the open scope, return its handle. Content drawn now attaches to it as usual |
| void AdoptStruct( int inHandle ) | make that node a child of the currently open element. One argument - the sort stamp is computed inside the backend, not passed in; see below for why that is not a detail |
| int HoldStructOpen() | the innermost open element survives the caller's scope ending. Returns a handle |
| void ReleaseHeldStruct( int inHandle ) | close it. Together these keep one <Table> open across a page break: PlaceTableFrom opens it once and hands the handle on, and the repeated band on later pages draws inside an ArtifactScope |
Drawing a group heading row
- The heading row is reached in DrawGridRows. Instead of TagScope("TR") -> TagScope("TD"), the layout calls OpenStructDeferred(TH, Scope=Row, rowSpan=N, id=...), draws the heading exactly where it always drew it, closes the deferred scope. Ink: unchanged. Tree: a parentless TH holding that ink.
- The next visual row that IS a TR opens its TR as normal, and before drawing its own cells calls AdoptStruct(handle) for every pending group TH, leftmost level first. The THs become the TR's first children, in level order, ahead of the cells drawn after - which is exactly the ground truth's cell order [section][subsection][code][description][data...].
- Their rowSpan is known because the counting pass (§4) ran before drawing.
Why the stamp is computed inside, and got wrong once
The kids list is sorted by seq at write time. An adopted node must sort BEFORE the cells drawn in the same TR, and those cells were drawn AFTER the TR opened - so the adoption cannot use the current clock, which has advanced past the heading's own draw.
[!] ONE STAMP FOR EVERY ADOPTION INTO THE SAME PARENT, COMPUTED FROM THE PARENT'S NON-ADOPTED KIDS. The first draft took min(all kids) 1 per adoption, which is subtly different and wrong: the second adoption then saw the first one's stamp among the kids and landed before it, swapping the two synthetic columns. That produced 40 cell differences against the ground truth, every one of them [subsection][section] where it should read [section][subsection]. The parity check found it; nothing else would have.
Same-stamp ties fall back to append order, which is what keeps two group THs adopted in level order in level order.
Page breaks
A group TH is adopted into the first TR of its group; that TR may be on a different page from a later portion of the group. Nothing special is needed - rowSpan is a count, not geometry, and the MCRs in the TH's /K already name their /Pg. The <Table> element is what must span, and HoldStructOpen / ReleaseHeldStruct are what do that.
6. What is NOT in scope
- THead / TBody: the ground truth has neither. When rowGroups is present they are not emitted. Tables without rowGroups keep them.
- Scope=RowGroup: not used by the ground truth; not emitted.
- A TFoot: not used.
- Any change to the DRAWN output. Pixel parity with today is a requirement.
# Performance
Note: [!] 2026-08-11 - READ "Corpus profile" AT THE BOTTOM FIRST. The headline below is superseded. Everything down to the horizontal rule was measured in the 53-sample era, when the only thing profiled at document scale was a pure-text document and a single-shot CLI process. Measured across the 237-sample corpus with the process warm - which is what the server and -batch actually do - the dominant cost was never the font warm-up and never the layout algorithms.
It was MeasureText, at 62% of all render time, asking APDFL for the width of the same word over and over. Memoizing it made the corpus 2.3x faster. The older sections are kept because their method is sound and their scaling results still hold; their conclusions about where the time goes do not.
Measured on the 53 sample documents in sample/ plus parametric scaling and feature-isolation experiments, on macOS / Apple Silicon (OSX_ARM), APDFL 18.0.5a, release build of pagewrightapp.
Method
- Warm wall time: one warm-up render, then the min of 4 runs (min rejects scheduler noise). Cold runs (first invocation, dyld loading the APDFL frameworks) are ~3-5x slower and are reported separately.
- CPU + memory: one /usr/bin/time -l run (user/sys, max RSS).
- Fixed overhead isolated with a trivial 1-paragraph doc; layout_ms = wall fixed.
- The CLI pays process + APDFL init per invocation; the long-running apDocLib server pays it once, so server figures = the per-document layout numbers below.
Headline result
The flow / pagination / tagging engine is very fast. The one large cost - the font subsystem warm-up - turns out to be a one-time, process-global cost, not a per-document one. It matters only to the CLI; the long-running server amortizes it.
Note: CLI vs. server - the single most important distinction. A single-shot CLI process that embeds a font takes ~590 ms, but ~540 ms of that is a one-time warm-up of APDFL's system-font subsystem (loading/parsing the system TrueType once). APDFL caches that process-globally, so the second and every later embed document in the same process costs only ~8-10 ms.
The apDocLib server is long-running, so it pays the warm-up once at startup and then serves every document - base-14 or tagged/embedded - in single-digit-to-low-tens of ms. The per-document figures in this document's CLI tables therefore reflect cold-process cost; for the production (server) path, see "CLI vs. server, measured" below.
| Document class | server (warm, per request) | CLI (cold, per process) |
| Base-14, untagged, no fields | 2-12 ms | 2-12 ms (+ ~48 ms proc init) |
| Tagged / embedFont / valued field | ~8-10 ms | 540-850 ms (one-time font warm-up) |
- Fixed process + APDFL init: ~48 ms warm, ~250-280 ms cold (CLI, per invocation).
- Font subsystem warm-up (APE_Font -> APDFL system-font load): ~540 ms, content-independent, paid once per _process_ (not per document) the first time any font is embedded; APDFL caches it process-globally thereafter.
- Max RSS: 37-40 MB base-14 -> 68-157 MB with an embedded font.
CLI vs. server, measured
The "font embedding is expensive" story is a per-process effect. Two independent measurements show the per-document cost is single-digit ms once the process is warm.
(a) In-process repeat (the CLI rendering one document N times, a fresh APDFLRenderTarget per iteration - the same object lifecycle the server uses per request):
| repeats | total | per-document |
| 1 | 603 ms | 603 ms |
| 5 | 629 ms | 126 ms |
| 10 | 674 ms | 67 ms (and falling) |
(b) The real apDocLib server (-start -noprompt, N successive LayoutDocument requests for embed.json - Arial, 4 embedded variants - piped to stdin in one process):
| requests | total | marginal / request |
| 1 | 597 ms | - |
| 8 | 651 ms | 7.7 ms |
| 16 | 749 ms | 10.1 ms |
Marginal cost (total total)/15 8-10 ms; the ~540 ms balance is the one-time warm-up. The server source confirms the mechanism: Do_LayoutDocument allocates a fresh new APDFLRenderTarget(...) per request, while the PDF library is initialized once at startup - so the font subsystem is loaded once and reused. The production path already runs at full speed; no caching work is required.
Feature isolation (400 paragraphs 27 pages; warm min, ms)
| Variant | ms | vs base | takeaway |
| untagged base-14 | 104 | - | baseline |
| + 2 columns | 106 | +2 | ~free |
| + 3 columns | 107 | +3 | ~free |
| + hyphenation | - | +4 | ~free |
| + tagging (structure tree only) | 796 vs 789 | +7 | tagging itself is nearly free |
| + font embedding | 789 vs 104 | +685 | the dominant cost |
| + auto-TOC and its counting pass | 730 vs 634 | +96 | modest (incl. the extra TOC page) |
| + index / glossary | - | (shares the counting pass) | same fixed-point loop as the TOC - the body is laid out a few extra times; no new font work |
| 100-row table, base-14 | 81 | - | cheap |
| 100-row table, tagged | 739 | +658 | ~530 of it is the forced font embed |
The key insight: tagging a document is computationally almost free (~7 ms), but PDF/UA requires embedded fonts, so tagging forces the ~600 ms font warm-up. What looks like "the cost of tagging" is really the cost of the font subsystem - and that warm-up is one-time per process (see "CLI vs. server"), so on the server a tagged document costs ~8 ms after the first request. These isolation numbers are cold single-CLI runs.
Scaling / complexity (all linear except where noted)
| Axis | base-14 | tagged |
| body text | ~2.1 ms/page (470 pg/s) | ~7.9 ms/page (127 pg/s) + ~600 ms font |
| table rows (5 cols) | ~0.8 ms/row (linear) | ~4.3 ms/row, super-linear (~3x per row-doubling) |
| heading + section (w/ TOC) | - | ~3 ms each (linear) |
| empty form field | - | ~0.1 ms each (flat - 5 vs 40 fields: 589 vs 591 ms) |
Body text, tables (base-14), headings/TOC and form fields all scale linearly. The one exception is tagged tables, which grow faster than linear for hundreds of rows (a 320-row tagged table is 1900 ms): the structure re-tagging of repeated header rows across many page fragments compounds with size.
Hotspots & recommendations (highest value first)
- Font subsystem warm-up - already amortized by the server; nothing to build. The ~540 ms "font cost" is a one-time, process-global warm-up of APDFL's system-font subsystem, not a per-document cost. The long-running server pays it once and then serves tagged/embed documents at ~8-10 ms each (measured - see "CLI vs. server, measured"). An engine-level font cache was investigated and ruled out: it would re-implement a cache APDFL already provides one layer down. The only place the warm-up still bites is per-invocation CLI batch use - there the fix is operational: use the server, or keep a warm CLI process pool. Optionally warm the font at server startup (render a 1-glyph throwaway during init) so the first real request is fast too.
- A valued text/choice field calls RebuildOneAppearance, which manufactures its own font (~500 ms) - route it to reuse the document's already-embedded face.
- Tagged tables scale super-linearly - for very large tables (100s of rows), revisit per-page-fragment header re-tagging and /K array construction. Fine for ordinary documents; relevant for data-table-heavy output.
- Everything else is already excellent - base-14 layout, the structure tree, hyphenation, multi-column, and TOC are all single-digit-to-low-tens of ms. No work needed there.
- CLI cold start (~250-280 ms, dyld) makes the server the right choice for high-volume/batch use; for CLI batches, a warm pool helps.
Batch parallelism, 2026-08-11 - -jobs N|auto (another 3.6x)
After the memoization above, the corpus still rendered on one core of eighteen (96% CPU of a possible 1800%). Documents in a -batch list are independent by construction - one spec in, one PDF out, nothing shared - so the work was already parallel; only the runner was serial.
Processes, not threads - and that is not the lazy choice
The render path reaches shared mutable statics that no lock protects: APE_Font:: sFontsToSubset inside apedit, the system-font index, and APDFL's own per-process state, which additionally wants its own initialization per thread. A thread pool here would be a data race wearing a speedup. Separate worker processes give each worker a private copy of all of it. The price is that every worker re-pays APDFL's ~560 ms font warm-up, and that price is exactly why the scaling curve flattens and then reverses.
Measured (237 samples, one machine, 18-core M5 Max)
| workers | 1 | 2 | 4 | 8 | 12 | 16 |
| wall | 9.60 s | 7.00 | 4.17 | 3.62 | 2.94 | 3.39 |
AutoWorkerCount picks two thirds of the logical cores, capped at 12 and never more than the job count - a heuristic fitted to this one curve, written down as such in the code so nobody mistakes it for a law. Measure and pass -jobs N on a different machine.
Rendering the whole sample corpus with -jobs auto went from 24.4 s to 4.4 s, end to end.
What the flag does and does not change
- Default is 1 - serial, byte for byte what -batch always did. Parallelism multiplies peak memory by the worker count, so it is opted into. That matters on the 8 GB PageWright Studio VM.
- Round robin, not contiguous blocks. The corpus is read in directory order, so the four big guides sit together; handing worker 0 the first quarter would give it all of them and the run is only as fast as its slowest worker.
- The per-document wrote ... lines interleave. Each arrives whole (children line-buffer stdout), but not in list order. The summary line and the exit status are unchanged.
- Failure accounting is preserved. A worker's exit status carries its failure count, so the summary total is real rather than just "something failed"; 255 is reserved for "this worker could not initialize APDFL". A worker killed by a signal cannot say how far it got, so its whole slice is charged - over-reporting failures, never over-reporting success, which is the safe direction for a gate to be wrong in.
- Windows falls back to serial with a notice; the pool is fork.
Verified: same failure counts as serial on a list with two bad specs (2 ok, 2 failed, of 4 both ways), exit 1 for render failures / 2 for a bad -jobs value or unusable list, the full sample check passed, and - the check that matters - page ink compared against a serial run of the same binary. Parallel differs from serial in fewer documents than two serial runs differ from each other (finishing prints {{time}}, so two runs in the same second agree and two spanning a second do not). Always run that same-binary control; without it the comparison is unreadable.
