PeachPDF Architecture

PeachPDF converts HTML and CSS into PDF documents entirely within .NET, with no external process dependencies. The pipeline passes through seven distinct phases: HTML parsing, DOM construction, CSS parsing, stylesheet application, layout, painting, and PDF rendering. SVG content is a cross-cutting subsystem that plugs into the DOM, layout, and painting phases — see SVG Rendering below.

HTML Input
    │
    ▼
HTML Parsing (MimeKit)
    │
    ▼
DOM Construction (CssBox tree)
    │
    ▼
CSS Parsing (ExCSS fork)
    │
    ▼
Stylesheet Application
    │
    ▼
Layout (CssLayoutEngine)
    │
    ▼
Fragmentation (FragmentTree)
    │
    ▼
Painting (RGraphics)
    │
    ▼
PDF Rendering (PdfSharpCore fork)
    │
    ▼
PDF Output

Lineage

The core rendering engine originally derived from HtmlRenderer. Since then it has been substantially rewritten: modern CSS standards are much better supported, and features not relevant to static PDF output (interactive elements, JavaScript hooks, scroll handling) have been removed.


Resource Loading

Key types: RNetworkLoader (Network/RNetworkLoader.cs), RNetworkResponse (Network/RNetworkResponse.cs), RUri (Network/RUri.cs)

Resource loading is a cross-cutting concern that spans multiple pipeline phases: the HTML document itself must be fetched before parsing begins, external stylesheets are fetched during stylesheet collection, and images are fetched during painting. All three use the same RNetworkLoader abstraction, which means the caller controls how every byte of external content enters the rendering pipeline.

The abstraction

public abstract class RNetworkLoader
{
    public abstract Task<string> GetPrimaryContents();
    public abstract Task<RNetworkResponse?> GetResourceStream(RUri uri);
    public abstract RUri? BaseUri { get; }
}
Member Purpose
GetPrimaryContents() Returns the root HTML string. Called once at the start of the pipeline when null is passed to PdfGenerator.GeneratePdf.
GetResourceStream(RUri) Returns a RNetworkResponse (stream + HTTP-style headers) for any external resource URI. Called for stylesheets and images.
BaseUri The document’s base URL. Used to resolve relative URIs in href, src, and url() values. If null, relative references resolve against the current working directory as a file: URI (the default working-directory base is supplied by PdfSharpAdapter) — unless local file access is disabled, in which case there is no base to resolve against and the reference goes unresolved.

RNetworkResponse is a simple record: (Stream? ResourceStream, Dictionary<string, string[]>? ResponseHeaders). The headers are inspected by StylesheetLoadHandler to validate the Content-Type is text/css before accepting the body as a stylesheet.

RUri wraps System.Uri with special-case handling for data: URIs, which System.Uri can parse but not round-trip correctly through AbsoluteUri. It also supports constructing absolute URIs from a base+relative pair, which is how the <base href> element and BaseUri are applied to relative resource references.

How the pipeline uses it

HTML document — when GeneratePdf is called with a null HTML string, GetPrimaryContents() is called to obtain the document. The HttpClientNetworkLoader fetches the URL passed to its constructor; the FileUriNetworkLoader reads the file passed to its constructor; the MimeKitNetworkLoader extracts MimeMessage.HtmlBody from the MHTML archive.

Stylesheets and imagesStylesheetLoadHandler.LoadStylesheet and ImageLoadHandler.SetImageFromPath both resolve their href/src to an absolute URI against the document base (a <base href> element, else BaseUri, else the working-directory file: default) via the shared CommonUtils.ResolveAgainstDocumentBase, then call adapter.GetResourceStream(uri). There is a single resolution path — local files, data: URIs, and remote resources all flow through GetResourceStream uniformly, with no separate file-system branch. Stylesheet responses are only accepted when the Content-Type is text/css; a resolved SVG image is detected from its Content-Type: image/svg+xml (or .svg extension).

data: and file: URIs — regardless of which RNetworkLoader is configured, PdfSharpAdapter.GetResourceStream handles data: URIs internally via DataUriNetworkLoader and file: URIs internally via FileUriNetworkLoader. This ensures inline base64 resources and local files always work without the configured loader needing to implement either scheme. Setting AllowLocalFileAccess to false removes the file: half of that: every file: request then resolves to “not found”, and the working-directory base above is withdrawn, so a document cannot reach the file system by either route.

Built-in implementations

PeachPDF ships four concrete loaders:

DataUriNetworkLoader (Network/DataUriNetworkLoader.cs)

The default loader when no NetworkLoader is set in PdfGenerateConfig. It handles only data: URIs, decoding the base64 payload into a MemoryStream. Any other remote URI scheme returns null, meaning remote resources (remote stylesheets, remote images) are silently skipped — the safest default for server-side environments where network access must be controlled explicitly. Local file: resources still resolve, because PdfSharpAdapter routes them through FileUriNetworkLoader and supplies a working-directory base URI.

FileUriNetworkLoader (Network/FileUriNetworkLoader.cs)

Serves the root document and referenced resources from the local file system via file: URIs. Constructed with no arguments, its BaseUri is the current working directory; constructed with a file path, its BaseUri is that file’s own file: URI and GetPrimaryContents() reads the file — the way a browser bases a locally-opened document. GetResourceStream reads the file and synthesizes a Content-Type header from MimeTypeResolver.

MimeTypeResolver uses the operating system’s own MIME mechanism by default — the Windows shell file-association API on Windows, the Uniform Type Identifiers API on macOS/iOS, and the /etc/mime.types database on Linux — validating the result is a real type/subtype value (so a shell “no content type registered” property descriptor is rejected rather than used). When the OS provides nothing usable, it falls back to a built-in map covering HTML, CSS, SVG, common raster image extensions (PNG, JPEG, BMP, GIF, TGA, PSD, HDR — this map only resolves an extension to a MIME type string; it is unrelated to which of those formats PeachPDF can actually decode, see below), and the TTF/OTF/WOFF/WOFF2 font formats, and finally application/octet-stream. Results are memoized per extension. If a local file uses an extension outside the built-in set, register its MIME type with the OS (e.g. the shell Content Type association on Windows, or an /etc/mime.types / ~/.local/share/mime entry on Linux) so the OS lookup can resolve it. (TGA/PSD/HDR resolve a MIME type either way but aren’t decodable — see below.)

Consistent with browsers, PeachPDF gates only stylesheets on MIME type — a linked stylesheet body is accepted only with a text/css Content-Type — while images and fonts are identified by their bytes (raster decoding via PeachImage; FontFormatConverter sniffing), not their declared type.

MimeKitNetworkLoader (Network/MimeKitNetworkLoader.cs)

Wraps a MimeKit-parsed MHTML archive. GetPrimaryContents() returns MimeMessage.HtmlBody. GetResourceStream(uri) walks the MIME body parts and finds the part whose Content-Location matches the requested URI. This allows a fully self-contained .mhtml file to be rendered without any network access: all resources are read from the archive. BaseUri is null because the base URL is embedded in the document itself.

HttpClientNetworkLoader (Network/HttpClientNetworkLoader.cs)

Fetches resources over HTTP using a caller-supplied HttpClient. The primaryContentsUri passed to the constructor sets BaseUri and is used by GetPrimaryContents() to download the root HTML. All subsequent resource requests (stylesheets, images) resolve against that base. The caller controls the HttpClient lifetime, so custom headers, authentication, proxies, timeouts, and HttpMessageHandler chains are all supported without any PeachPDF-specific API.

Implementing a custom loader

To integrate PeachPDF into an environment with custom resource-resolution logic — for example, reading assets from a cloud blob store, a bundler manifest, or an in-memory dictionary — subclass RNetworkLoader and set the instance on PdfGenerateConfig.NetworkLoader:

public class MyLoader : RNetworkLoader
{
    public override RUri? BaseUri => null;

    public override Task<string> GetPrimaryContents() =>
        Task.FromResult("<html>…</html>");

    public override Task<RNetworkResponse?> GetResourceStream(RUri uri)
    {
        var bytes = MyAssetStore.Load(uri.OriginalString);
        if (bytes is null) return Task.FromResult<RNetworkResponse?>(null);
        return Task.FromResult<RNetworkResponse?>(
            new RNetworkResponse(new MemoryStream(bytes), null));
    }
}

1. HTML Parsing

Library: MimeKit

Entry point: HtmlParser.ParseDocument (Html/Core/Parse/HtmlParser.cs)

Raw HTML is tokenized by MimeKit’s HtmlTokenizer, which produces a flat stream of HtmlTagToken and HtmlDataToken objects. HtmlParser walks that stream and builds a CssBox tree in a single pass.

Token handling

The parser maintains a current box cursor that advances up and down the tree as tags open and close:

MHTML support

MimeKit also drives MHTML ingestion. MimeKitNetworkLoader opens the MIME multipart, extracts the root HTML part, and makes all embedded resources (stylesheets, images, fonts) available by their Content-Location URL before the rendering pipeline begins. This means the HTML parser and all subsequent phases see a normal document; the resource-loading layer transparently resolves references into the MIME archive.


2. DOM Model — CssBox

Key types: CssBox (Html/Core/Dom/CssBox.cs), CssBoxProperties (Html/Core/Dom/CssBoxProperties.cs)

Each HTML element becomes a CssBox. The tree mirrors the source HTML structure and is the central data structure that all subsequent phases read from and write to. Every box is assigned a monotonically increasing Id so boxes can be identified and ordered throughout the pipeline.

Properties

CssBox extends CssBoxProperties, which stores a field for every CSS property the engine understands. Properties are stored as raw strings (matching CSS value syntax) and parsed on demand into computed numeric values that are cached in parallel _actual* fields. This avoids re-parsing values on every access while keeping the raw strings available for inheritance and currentColor resolution.

Key computed values include actual border widths, padding, corner radii, word spacing, text indent, and border spacing — each resolved relative to the containing block width or the current font size.

Box display and flow

CssBox exposes several boolean helpers that the layout and painting phases query constantly:

Property Meaning
IsBlock display: block
IsInline display: inline, inline-block, or inline-table
IsFloated float: left or float: right
IsOutOfFlow floated, absolutely, or fixed positioned
IsFixed this box or any ancestor has position: fixed
IsTableRowGroupBox display: table-row-group/header-group/footer-group
IsTableCell display: table-cell

The containing block for a box is found by walking up the parent chain to the nearest block-level, table, or table-cell box.

Inline content

Text inside a box is held in a Words list of CssRect objects:

Words are collected into CssLineBox instances during layout. After layout, each box records its per-line paint rectangles in a Dictionary<CssLineBox, RRect> Rectangles map so the painting phase knows exactly where each fragment of the box appears on each line.

Specialised subtypes

Subtype Purpose
CssBoxImage <img> — manages an ImageLoadHandler to load and decode the image
CssBoxFrame <iframe>
CssBoxHr <hr> — renders as a horizontal rule
CssSpacingBox Anonymous spacing boxes injected into inline formatting contexts
CssProxyBox Wrapper boxes created by the table layout engine to satisfy CSS anonymous box rules

Pseudo-elements and generated content

::before and ::after pseudo-elements are represented as real CssBox instances with IsBeforePseudoElement / IsAfterPseudoElement flags set. The CssContentEngine evaluates the content CSS property on these boxes, resolving string literals, counter() references, string() named-string lookups, and attr() expressions into plain text that is injected as anonymous text boxes.

When the content value is an image — url() or any CSS gradient function — CssContentEngine.ApplyContent sets CssBox.ContentImage instead of CssBox.Text. The box is kept in the tree as a non-text box; its image is loaded via EnsureLoadedAsync during word measurement and painted via CssImagePainter.Paint into the box’s client rectangle. This uses the same CssImage pipeline as background-image and list-style-image. Image content requires display: inline-block with explicit width/height on the pseudo-element.

CSS counters (counter-reset, counter-increment) are tracked per-box by CssCounterEngine. Named strings (string-set) used for running headers and footers are tracked by CssNamedStringEngine.

Pagination metadata

Because the output is paginated, CssBox carries a PageBreakBottoms dictionary that CssLayoutEngineTable populates when a table row group breaks across pages. The painting phase uses this to clip table borders to the actual content height on each page rather than drawing them beyond the page break.


3. CSS Parsing

Library: ExCSS (forked and merged into PeachPDF)

Entry point: CssParser.ParseStyleSheet (Html/Core/Parse/CssParser.cs)

CSS is parsed by a fork of ExCSS merged directly into the PeachPDF source tree under src/PeachPDF/CSS/. The fork exists for two reasons:

  1. Internal API access. ExCSS does not expose the internals of a parsed stylesheet through its public API. PeachPDF needs direct access to the parsed token and rule structures to efficiently resolve selectors, apply the cascade, and evaluate property values. Merging the library removes the public-API boundary and gives the rendering engine full access to those internals.

  2. Independent CSS property support. Adding support for a new CSS property (e.g. background, gradients, container) requires changes to the parser. With ExCSS as an external dependency this would mean waiting on an upstream release; with the fork merged in, new properties can be added and shipped immediately.

Rule types

The parsed output is a Stylesheet object containing a typed collection of at-rules and style rules. The rule types are defined under src/PeachPDF/CSS/Rules/:

Rule class CSS construct
StyleRule (via IStyleRule) Regular selector + declaration block
MediaRule @media — wraps child rules with a media query
FontFaceRule @font-face
ImportRule @import
ContainerRule @container
KeyframesRule @keyframes (parsed but not animated)
ViewportRule @viewport
DocumentRule @document

Property definitions

Every CSS property PeachPDF understands has a corresponding class under src/PeachPDF/CSS/StyleProperties/, organised by category (Background, Border, Font, Text, etc.). Each property class declares the property name, whether it is inherited, its initial value, and a value converter that validates and normalises the parsed token stream.

Value parsing

CssValueParser (Html/Core/Parse/CssValueParser.cs) translates raw CSS value strings into the numeric/computed forms that CssBoxProperties stores in its _actual* fields. It handles length resolution (px, em, rem, %), colour parsing, and shorthand expansion. All unit→number conversion is centralized in the CSS-OM Length struct’s ToPixels (CSS/Values/Length.cs): the engine’s internal layout unit is 1 PDF point, and every unit — including spec-correct CSS px at 1px = 1/96in = 0.75pt via the single Length.PointsPerPx constant — resolves through that one implementation, so body layout, font sizes, @page geometry, and image intrinsic sizes all agree by construction (see Length units for the full unit table).


4. Stylesheet Application

Key types: CssData (Html/Core/CssData.cs), DomParser (Html/Core/Parse/DomParser.cs)

DomParser.GenerateCssTree orchestrates both the HTML parse and the full stylesheet application in a single method call, returning the styled CssBox root ready for layout.

Stylesheet collection

Stylesheets are collected and merged from four sources in cascade order:

  1. User-agent defaultsCssDefaults.DefaultStyleSheet is a string constant that mirrors the CSS 2.1 default stylesheet for HTML. It sets display values for all HTML structural elements, default margins, font sizes for headings, monospace for <pre> and <code>, and so on. It is always loaded first.
  2. Caller-supplied stylesheet — before rendering, the caller can pre-parse a CSS string into a PeachPdfCssContent object using PdfGenerator.ParseStyleSheet(css) and pass it to GeneratePdf as the cssData parameter. When provided, this stylesheet becomes the starting CssData that document stylesheets are subsequently merged into, making it behave like an additional author stylesheet applied before any document-level styles. The combineWithDefault parameter on ParseStyleSheet controls whether the caller’s stylesheet is merged on top of the W3 user-agent defaults (true, the default) or replaces them entirely (false).
  3. Author stylesheetsCascadeParseStyles does a depth-first walk of the raw CssBox tree and accumulates parsed stylesheets from <link rel="stylesheet"> and <style> elements in document order. External stylesheets are fetched through the configured RNetworkLoader (HTTP, file system, or MHTML archive). Each parsed Stylesheet is appended to CssData.Stylesheets.
  4. Inline styles — handled per-box during CascadeApplyStyles (see below).

@page rules

Before style cascading begins, CascadeApplyPageStyles reads @page rules from the collected stylesheets and writes their margin values (margin-top, margin-right, margin-bottom, margin-left) onto the HtmlContainerInt, overriding any margins specified in the PdfGenerateConfig. It also captures a PageLengthContext snapshot (root em/rem size and page width, in true points) that per-page @page rules resolve their relative-unit margins against later, at band-geometry/paint time — the same bases the base rule used, so identical declarations produce identical geometry in base and per-page rules.

Per-page content bands (and full-page bleed)

Per-page @page rules (:first, :left/:right, or a named page) can override margins for individual pages, and a top/bottom override is layout-affecting: it changes that page’s content-band height, so content paginates against variable bands rather than one uniform page height. That variable geometry lives in PageGeometryTable (Html/Core/PageGeometryTable.cs), which holds one PageBandGeometry per pagination slot: the slot’s document-space band top and height (internal-pixel layout space) plus its four resolved margins (true PDF points). Slot k+1 begins where slot k’s band ends, so a page with a taller-than-base top margin shifts everything after it down.

The table is built forward-incrementally and lazily, with no fixpoint relayout: a slot’s applicable rule depends only on the page number (known a priori for :first/:left/:right) and the named page active at the slot’s start — and because a page-name change always forces a break onto a fresh slot (CssBox.PerformLayoutImp), registered at that slot’s top before the named box’s children lay out, that name is fully determined by content laid out earlier. A named-page registration invalidates only cached slots at or after its own Y (InvalidateFrom). Vertical overrides vary the band height: HasVerticalMarginOverrides OR HasSizeOverrides (a per-page size change alters a slot’s own sheet height too, typically with no margin override at all — e.g. a landscape named page) gates the height machinery, so a document with neither stays on HtmlContainerInt’s closed-form uniform page arithmetic and never consults it.

Left/right overrides vary the content-box width: each page’s own margins (and, via the same HasSizeOverrides gate, its own sheet width) define its measure (CSS Paged Media’s “the edges of the page area act as a containing block for the layout that occurs between page breaks”), so main-column block content re-wraps to that page’s own width rather than merely being shifted at paint time. HasHorizontalMarginOverrides OR HasSizeOverrides (HtmlContainerInt.UseVariableInlineMeasure) gates this the same way, and HtmlContainerInt.PageContentRightOf(y) is its horizontal analogue of PageBandHeightOf — it returns a page’s own right edge (at the base left origin, computed from that slot’s own PageBandGeometry.SheetWidthPt, not the document’s base sheet width), which CssLayoutEngine.GetBoxWidth substitutes for the containing block’s right edge when the containing block is the main column (<html>/<body>/the root). Layout keeps content anchored at the base left origin in layout space, so the painter’s existing per-page deltaX translate still moves it to the page’s physical left edge — with the content already at the page’s own width, the right edge lands correctly and paint needs no change. Because a box’s width is resolved before its position is assigned, HtmlContainerInt.PerformLayout runs one bounded reflow pass (keyed off each box’s previous-pass Location.Y) until every box’s page assignment stabilises; for :first/:left/:right this converges in a single re-pass, and a box that opens a named page now measures against that page’s own geometry immediately rather than the previous page’s stale, pre-registration one (CssBox._measureResolvedAgainst). A box’s own outer border box still keeps a single (start-page) measure across its fragments, but its text content now re-wraps line-by-line to whichever page each line actually lands on (css-break-3 §5.1), rather than sharing one measure the way CSS Fragmentation’s general model describes for a box as a whole. See the @page rule reference for the authoring-facing behavior and its known boundaries.

Full-page bleed falls out of this model directly: @page :first { margin: 0 } collapses that slot’s content band to the entire physical sheet, so a cover element sized to the full sheet reaches all four physical edges with no margin gutter. Margins are resolved in true points by PageRuleResolver.ResolvePageMargins, then scaled into layout space exactly once. See the @page rule reference and the broader CSS Paged Media section for the authoring-facing rules and their known boundaries (e.g. only main-column blocks reflow to a page’s own width and a spanning block’s own outer size keeps its start-page measure even though its text re-wraps; percentage heights resolve against the base band).

@font-face rules

CascadeApplyStyleFonts iterates every @font-face rule and resolves each font family name and source. If a local() source matches an installed system font it is used directly; otherwise the url() source is fetched through the adapter and the font file is loaded into the font subsystem before layout begins.

Style cascade

CascadeApplyStyles applies styles to every box with a recursive tree walk:

  1. Initial valuesCssDefaults.InitialValues are written onto the box first so every property has a defined starting point.
  2. InheritanceCssBox.InheritStyle copies inheritable properties from the parent box.
  3. Matching rulesCssData.GetStyleRules yields every IStyleRule from every stylesheet (filtered to the print media type) whose selector matches the current box.
  4. !important tracking — property names marked !important are recorded in a HashSet<string>. Subsequent rules cannot overwrite them.
  5. Global keywords (inherit, initial, unset, revert, revert-layer) — all five are resolved at assignment time in DomParser.AssignCssBlock. inherit reads the property value from the parent box (or initial’s value at the root); initial reads from CssDefaults.InitialValues; unset acts as inherit for properties in CssDefaults.InheritedProperties and as initial otherwise, per spec. revert/revert-layer both resolve against a snapshot of the box’s own property values taken immediately before the current cascade phase (UA/author/inline × normal/!important, six phases run in origin order per box) — RulesUseRevertKeyword skips the snapshot entirely for phases that don’t use either keyword, since CssUtils.SnapshotProperties is otherwise a needless per-box allocation. PeachPDF doesn’t model CSS cascade layers, so revert-layer collapses to the same behavior as revert. Custom properties (--foo) are resolved separately in AssignCustomPropertyDeclaration, where initial removes the property entirely (matching --foo’s guaranteed-invalid initial value) rather than resolving to a stored default.
  6. Custom properties and var()--foo declarations are kept in a per-box dictionary, cloned (not shared) from the parent at inheritance time so a child’s local override never leaks to its parent or siblings. Regular declarations whose value contains var() are deferred until the box’s entire cascade (UA, author, inline) has finished, then resolved in one pass via a graph-based, memoized, cycle-safe substitution against the box’s final custom-property values — this makes resolution correct regardless of declaration order and safely short-circuits cyclic references (--a: var(--b); --b: var(--a);) instead of looping.
  7. Presentational attributesTranslateAttributes maps HTML attributes such as align, width, border, bgcolor, valign, cellspacing, and cellpadding to their CSS equivalents so they participate in the cascade.
  8. Inline style attribute — parsed on the fly and applied last (with highest author specificity).
  9. currentColorCssUtils.ApplyCurrentColor resolves any currentColor keyword references.
  10. Text decoration propagation — because text-decoration does not inherit through the CSS inherit mechanism but does visually propagate to inline children, it is explicitly copied down to child boxes that contain actual text.

CssDefaults.InitialValues/InheritedProperties and the actual per-property parse-validate-and-assign step behind CssUtils.SetPropertyValue/GetPropertyValue (and the equivalent for SVG presentation properties, SvgTreeBuilder.ApplyCommon) are generated at build time from a single JSON source, src/PeachPDF/css-properties.json, by a Roslyn source generator (src/PeachPDF.SourceGenerators) rather than hand-written per property. This keeps every property’s inheritance flag, initial value, and value grammar in one place instead of several hand-synchronized tables — and is exactly what real @supports evaluation (§CSS At-Rules) queries for “does this renderer actually accept this declaration,” via CssPropertyRegistry/SvgPropertyRegistry’s SupportsDeclaration. See CLAUDE.md’s “CSS/SVG property registry generator” section for the schema and authoring workflow.

Post-styling corrections

After the cascade, DomParser runs a series of correction passes to make the tree structurally valid for layout:

Method What it fixes
CorrectTextBoxes Removes whitespace-only anonymous boxes that cannot affect layout
CorrectImgBoxes Ensures <img> boxes have the correct display and size constraints
CorrectLineBreaksBlocks Wraps <br> elements correctly in their inline context
CorrectInlineBoxesParent Ensures block children of inline parents get a block wrapper (CSS anonymous block generation)
CorrectAbsolutelyPositionedInlineElements Promotes absolutely positioned inlines to block
CorrectBlockInsideInline Splits inline boxes that contain block-level descendants, as required by the CSS spec
CorrectAnonymousTables Generates missing table wrapper boxes (anonymous table, tbody, tr) to satisfy the CSS table model

5. Layout

Key types: CssLayoutEngine (Html/Core/Dom/CssLayoutEngine.cs), CssLayoutEngineTable (Html/Core/Dom/CssLayoutEngineTable.cs), CssLayoutEngineFlex (Html/Core/Dom/CssLayoutEngineFlex.cs)

Layout computes the position and size of every box. It runs in two sub-passes: word measurement and box layout.

Word measurement

CssLayoutEngine.MeasureWords does a depth-first walk of the tree and calls CssBox.MeasureWordsSize on each box. This calls RGraphics.MeasureString to ask the PDF graphics context for the pixel width of each word using the box’s resolved font. Image sizes are resolved by MeasureImageSize, which respects width, height, min-width, max-width, min-height, max-height, and aspect-ratio constraints.

Box sizing constraints (min/max-width, min/max-height)

min-width/max-width/min-height/max-height are enforced differently for each axis, because width and height are resolved in opposite directions:

Who assigns a block box’s position

Sizing a block-level box and positioning it are two different questions, and only one of them is the box’s own. How wide it is follows from its own style and its containing block (CssBox.ResolveOwnInlineSize, over CssLayoutEngine.GetBoxWidth). Where it goes does not: the answer is margin collapsing against whatever came before it, which fragmentainer that predecessor ended in, and the run of preceding boxes chained to it by break avoidance — none of which a box can see from inside itself. So the position is assigned by the frame above it, and that is where a block box’s layout is entered: the frame’s own child loop drives each child’s pass, rather than each child running its own and reaching back out mid-layout to ask where it goes.

A pass has three phases, and CssBox.DriveBlockChildPass runs them in the order their dependencies force:

  1. The child opens the pass (BeginBlockPass): it picks up any resumption record left for it and runs its once-per-layout prologue — measuring its words, applying string-set, resolving its used page name, and settling whether a forced break falls before it.
  2. The frame places and sizes it (PlaceAndSizeBlockChild): ResolveBlockChildOffset decides the offset, the child resolves its inline size against the page that offset lands on, and CommitBlockChildOffset writes the position. Both halves need what phase 1 settled; the size needs the offset, and nothing in the offset needs the size.
  3. The child lays out its own content (LayoutContents), inside the position it was given. Everything laid out there resolves against an origin and a width that are already final.

Deciding an offset is synchronous, which is the same statement in another form: everything it depends on has already been measured by the time it runs. The frame can also decline to place the child at all — CSS Fragmentation 3 §5.2’s margin truncation may conclude that the break falls before the box, in which case no position is written, phase 3 is skipped, and the box contributes no fragment to the fragmentainer being filled.

Which children a frame positions is likewise the frame’s question, asked once where the pass is entered rather than by each child about itself. A flex or grid item at its engine’s commit pass is simply a child driven with the placement phase off; the block-flow arithmetic that would otherwise reposition it against a “previous sibling” the engine never used is not run at all.

The document root is the one box with no frame above it, so it stands in for its own. Nothing about it is special-cased: PreviousInFlowSibling reports null for a box the frame does not own, which is the answer a box with no parent has always had. Callers that are not a block-flow child loop — a layout engine measuring an item, the out-of-flow walk, the document root itself — go through CssBox.PerformLayout, which names the frame on the box’s behalf and is otherwise the same path.

Margin collapsing is resolved there too, by CssBox.CollapsedMarginBefore, because CSS 2.1 §8.3.1’s adjoining-margin set spans two frames. Half of it is what precedes the box — a predecessor’s bottom margin and, through a run of self-collapsing predecessors, everything those fold in — and only the frame can walk its own child list backwards to find it (FoldMarginsPrecedingChild). The other half is the box’s own top margin and the chain of first-in-flow-child margins adjoining it, which is a walk into its own subtree and stays there (FoldOwnAdjoiningBlockStartMargins). The spec collapses the whole set at once, so the two halves fold into one running set rather than being resolved separately and combined.

<hr> shows why the seam is worth having. CssBoxHr resolves its own size (a full-width rule whose height falls back to a 1px top and bottom border), but it used to position itself with a hand-rolled copy of the formula — and that copy had drifted on three rules at once: it read the predecessor’s ActualBottom rather than its static one, it asked for the previous sibling with floats included, and it added that sibling’s bottom border on top of a value that already contained it. It now calls PlaceAsBlockChild like everything else, and the copy is gone. It is one of three box kinds that replace the generic pass rather than being driven through its phases — the rule, an outside ::marker (positioned beside its item rather than in any flow), and a repeated table row group’s proxy (whose content was laid out elsewhere and is only translated here) — none of which has a prologue, a placement and a content phase that could be separated. Each still asks the frame for whatever is genuinely the frame’s.

Block formatting context

Block boxes are stacked vertically, with margin collapsing implementing all five CSS2.1 §8.3.1 scenarios: adjoining sibling margins (CssBox.CollapseMargins — same-sign margins take the larger magnitude, mixed-sign margins sum, matching Max(a,b,0) + Min(a,b,0)); a parent’s top margin escaping into its first in-flow child’s when the parent has no top border/padding and the child has no clearance; a box’s own bottom margin folding into its own reported height when it is its parent’s last in-flow child with no bottom border/padding (CssBox.MarginBottomCollapse) — deliberately gated on being the last child specifically, since a box with a following sibling already has its margin accounted for via that sibling’s own adjoining-margin collapse, and folding it into the box’s own height too would double-count it; self-collapsing empty boxes (zero height/border/padding, no in-flow content) whose own top and bottom margins merge into one pass-through value (CssBox.IsMarginCollapseThrough); and floats, absolutely/fixed-positioned boxes, and boxes establishing a new block formatting context (e.g. via a non-visible overflow) never participating in any of the above. The resolved top margin for a box is cached in _collapsedMarginTop; a box that is itself part of a multi-level chain of adjoining first-in-flow-child margins gets its position resolved by the outermost (anchor) box’s own lookahead rather than independently, since only the anchor can see the whole chain’s true collapsed value before any box in it is positioned. Auto horizontal margins (margin: … auto) are resolved by GetActualMarginLeft / GetActualMarginRight per CSS 2.1 §10.3.3: on a block with a definite width (an explicit width, or an auto width clamped smaller by max-width) they split the free space to centre it; on an auto-width block they resolve to 0 so the width fills the containing block. A max-widthd margin: 0 auto responsive wrapper therefore fills the page when the page is narrower than its max-width, and only begins centring once the page grows past it.

Inline formatting context

CssLayoutEngine.CreateLineBoxes breaks inline content into CssLineBox instances. Each word is placed onto the current line until it would exceed ClientRight, at which point a new line box is started. The algorithm then applies:

Hyphenation

Key type: HyphenationEngine (Text/HyphenationEngine.cs)

hyphens: auto is implemented as real pattern-based automatic hyphenation — Frank Liang’s classic TeX algorithm — rather than a dictionary or a heuristic. It lives in its own PeachPDF.Text namespace (not Html.Core.Dom) because the algorithm itself is general text processing with no layout-engine dependency; only its two call sites are layout code:

Pattern data. ~70 languages’ pattern sets are sourced from CTAN’s hyph-utf8 package (see tools/Update-HyphenationPatterns.ps1 for the reproducible download/build pipeline) and embedded as Brotli-compressed resources under Text/Resources/Patterns/, one file per language. Only permissively licensed pattern sets (MIT/LPPL/BSD-style/public-domain) are included; languages whose upstream pattern file is GPL/LGPL-licensed or carries no stated license are intentionally excluded — see HTML/CSS Support: hyphens for the full exclusion list. Each language is decompressed and parsed lazily on first use and then cached for the process’s lifetime (ConcurrentDictionary<string, LanguagePatternSet?>), so a document using one language never pays to load the other ~70.

Language resolution. A document’s language tag doesn’t need to exactly match a pattern file’s own tag: HyphenationEngine.ResolveLanguageTag tries the tag verbatim, then progressively shorter subtag prefixes (de-ATde-atde), checking at each step whether that prefix is itself an available pattern tag and, if not, consulting a small bcp47-tag=pattern-tag alias table (Text/Resources/language-tags.txt) for cases where a base language ships multiple pattern variants (e.g. de defaults to de-1996, the reformed orthography) or its real BCP-47 code differs from the pattern set’s own legacy tag (e.g. srsh-cyrl). Each pattern file also carries its own hyphenation minimums (leftmost/rightmost characters that must remain unbroken), parsed from a # hyphenmins: left=N right=N comment line and applied per-language rather than as a single hard-coded constant, since they genuinely vary (e.g. Afrikaans ships left=1 right=2 against English’s left=2 right=3).

The alphabet-membership check that gates hyphenation (rejecting digits/punctuation/apostrophes) uses char.IsLetter, not an ASCII range — this is what makes non-Latin pattern sets (Cyrillic, Greek, Armenian, Georgian, Ethiopic, Thai, …) actually activate rather than silently matching zero words.

Float layout

Floated boxes are removed from normal flow. CssLayoutEngine.FloatBox positions them at the left or right edge of their containing block and records their extent in CssFloatCoordinates on the container. Subsequent inline content queries these coordinates to wrap around the float. The clear property is handled by ClearBox, which advances the current Y position past all floats of the specified side.

Fit-content / min-content / max-content

GetFitContentWidth, GetMinContentWidth, and GetMaxContentWidth implement the intrinsic sizing keywords used by table column widths and width: fit-content. They work by measuring all words and recursively summing child widths without line-breaking (max-content) or by finding the longest single word (min-content).

Flex layout

Key type: CssLayoutEngineFlex (Html/Core/Dom/CssLayoutEngineFlex.cs)

Boxes with display: flex or inline-flex are laid out by a dedicated engine implementing CSS Flexbox Level 1, entered from CssBox.PerformLayoutImp in place of the normal block/inline formatting context. It runs as a sequence of phases per the spec: collect and order items (respecting order), measure each item’s hypothetical main size from flex-basis/width/height or its content size, wrap items into lines (flex-wrap), resolve flexible lengths via flex-grow/flex-shrink clamped to min/max-width/height, size and align lines on the cross axis (align-content), position items on the main axis (justify-content, with auto margins absorbing free space first), and align items on the cross axis (align-items/align-self). Flex items are blockified before measurement per spec §9.2. align-items/align-self: baseline aligns items by their first font baseline — each item’s baseline is found by descending into its first in-flow child (in document order) for the first line box, using RFont.Ascent for the offset from that box’s top — and only applies for row-direction flex; column-direction flex has no vertical baseline concept and falls back to flex-start, as does an item with no discoverable line-box content.

Multi-column layout

Key type: CssLayoutEngineColumns (Html/Core/Dom/CssLayoutEngineColumns.cs)

A box that establishes a CSS Multi-column formatting context (column-count/column-width resolved to other than auto) is entered from CssBox.PerformLayoutImp the same way flex/table are, in place of the normal block-children loop. A column is a fragmentainer in CSS Fragmentation Level 3 terms, and this engine is a driver over its own fragmentainers — the same shape HtmlContainerInt.LayoutDocument has for pages:

  1. Measurement pass — every child is laid out once, unmodified, as one tall flow at the resolved column width, with breaking suppressed. Its only product is how tall the content is, which is what column-fill: balance needs before it can choose a column height. Bisecting over real per-column fills would not be an acceptable cost, so the estimate is made against a whole-child packing of this pass.
  2. Per-column fill — for each column, a nested FragmentainerContext is established (banded to the chosen height, with the container’s own inline extent narrowed to that column), the ordinary block-children loop is run into it, and the resumption record it leaves says where the next column picks up. What the last column cannot hold travels up the ordinary chain, so the page driver opens the next page and the container resumes rather than starting over.

Every column shares one band: columns differ in the inline axis, not the block axis, which is what lets the block-axis break machinery be reused untouched — a resumed column starts at the same content edge a resumed page would. So break-inside: avoid, orphans/widows, §2 monolithic content and §5.2 margin truncation all work inside a column without knowing about columns.

Balancing belongs to the fragment holding the end of the flow, which cannot be known before filling it — so the first fragment starts from the estimate and a continuation starts from the full page budget and is re-balanced once the fill shows the remainder ends there. Where the estimate lands under what the real fill needs, the target is grown and the fill run again.

A top-level child is atomic per column, and this is a limit of the box model rather than a simplification. A CssBox carries a single Location, and columns sit side by side inside one page band — so a box split across two of them would have both halves at the same document Y and the same X, its continuation lines laid out over the ones already there, and the emitter, whose band membership is a question about Y alone, could not tell the halves apart to draw its background in both. A child too tall for its column overflows it instead. Splitting a child across columns needs geometry held per fragment rather than per box.

column-rule is painted as real line segments (CssBox.ColumnRuleSegments, set by this engine and drawn by FragmentPainter), one per gap between the columns actually used.

Table layout

CssLayoutEngineTable implements the CSS 2.1 fixed and auto table layout algorithms:

Pagination

Because the output is a paginated PDF, layout must determine page breaks for all block content, not just tables. Elements with page-break-inside: avoid (or break-inside: avoid) are kept together as a unit when possible.

Layout fills one fragmentainer at a time. HtmlContainerInt.LayoutDocument is the driver: it targets a fragmentainer, runs layout into it, and where content does not fit reads back a break token — a resumption record naming where layout stopped — then opens the next fragmentainer and re-enters at exactly that point (CSS Fragmentation Level 3 §2/§4.4). The token is a chain, one link per ancestor between the fragmentation-context root and the box that stopped, so every ancestor on the path re-enters mid-flight while boxes off the path are untouched. Its ResumeSlotIndex comes from where the break actually fell rather than from “the pass after this one” — a box can be placed far down the document, past a tall spacer or by an engine positioning its own children, so the fragmentainer it overflows is not in general the next one. A document that fits without overflowing takes a single pass, so the common case costs what the old single-flow model did.

A pass does not always fill exactly one fragmentainer, and the one it is filling is a cursor. A forced break is realized by placement — the box is put at the content top of the page the break names and the pass carries straight on from there — so a pass that opened on one page can be flowing content into a page two further on with no resumption record in between (a directional break-before: right steps over the page it refuses, §3.1’s “one or two page breaks”). The context naming the fragmentainer being filled moves on with it. Everything that asks about that fragmentainer — where its band ends, whether anything precedes a box inside it — then answers about the page the content is actually on rather than the one the pass started from. It moves forward only: a pass fills pages in document order, and a pass that has to reconsider an earlier one is re-entered by the driver with a context of its own.

A box’s layout pass splits along the seam this needs, and the frame above it is what drives the split (see Who assigns a block box’s position): a prologue (word measurement, string-set, used page name, any forced break before the box) that runs once per box and must not be repeated; placement, which the frame performs and which a resumed pass replaces with a move to the fragmentainer now being filled; contents, which a resumed pass re-enters; and an epilogue (height, the keep-with-next first-line retry, break-inside: avoid, orphans/widows, the absolute right/bottom fallbacks, named-page and named-string bookkeeping) that can only judge a finished box and so waits for the pass that completes it.

One thing stays deliberately outside the token model. (Two others used to: a multi-column container, which now drives fragmentainers of its own — see Multi-column layout — and a table, whose row loop now stops where a cell stopped and records where, so a later pass continues it.) Flex and grid measure every item at the container’s content origin and translate it into place afterwards, so breaking stays suppressed for those measurements — but a final pass, once the items are where they will finally be, moves a line or row that a break value or §2 forbids cutting onto the next page. A further, per-engine commit pass follows once that placement is settled, running each item’s content out at its now-final position, attached to a real fragmentainer rather than a detached one, so an item’s content genuinely continues onto the next page where it does not fit — the same nested FragmentainerContext a column-count descendant already establishes for itself (see Multi-column layout) now reaches a live one instead of an inherited-suppressed one. CssLayoutEngineGrid’s runs per row (in block-axis order, which — unlike flex — needs no reordering trick, since RowStart ascending already is that order): every row’s items are §2.1 parallel flows, committed together, with a row-spanning item grouped at the row it starts and a subgrid item’s adopted track geometry re-threaded across a resumed pass. CssLayoutEngineFlex’s runs per line for flex-direction: row/row-reverse (any line count, walked in block-axis order via the same index-flip wrap-reverse line relocation already uses) the same parallel-flows way, and separately, per line, for column/column-reverse: there each line’s items are a sequential flow instead, so that pass walks and commits them in turn — flex-wrap’s several side-by-side lines each run their own sequence independently of the others. A resumed pass landing in a new fragmentainer (a multicolumn column boundary, concretely) repositions every not-yet-committed item by the same delta CssBox.ResumeInTheNextFragmentainer moved the container by, since that method moves only the container, not its subtree. Left out of the commit pass, for now: a forced or avoided break value between two items of the same column-direction line — every item there still commits unconditionally, stopping a line’s walk only where an item’s own content does not fit. This is an implementation constraint rather than a claim about the content, and MonolithicContent keeps it apart from §2’s own monolithic set (replaced elements and scroll containers, which may not be broken by any user agent and which the epilogue moves whole instead of splitting). And the §4.3 correctionsbreak-inside: avoid, §2 monolithic content, orphans/widows, and the keep-with-next run pull they share — judge content that has already been placed, which a forward-only record cannot express; they stay bounded corrections within a single pass, which §4.3 sanctions. Each is stated as an EarlyBreak (Html/Core/Fragmentation/EarlyBreak.cs) naming the box the break falls before, where it lands and why, and is carried out by laying that box out again there rather than by moving it — a translation would carry the fragmentainer gap into a box whose text had already crossed the boundary. Three cases keep the move: a box that fits in no fragmentainer (starting it elsewhere cannot help, and re-flowing it would restart fragmentation from its new top), a box inside an engine that owns its placement (its coordinates are provisional, so re-flowing changes the measurement being taken), and a run whose head belongs to a fragmentainer already filled.

The driver is bounded two ways, and both end at the same recovery rather than spinning or dropping content: a check that a run which cannot advance lays the remainder out monolithically instead of re-entering, and a hard cap on the pass count for the rarer case of a run that never repeats a fragmentainer/record pair yet also never finishes — an overflowing fragmentainer beats dropped content either way, which is what §4.3’s own last-resort relaxation amounts to. A pass is a function of the fragmentainer it fills and the record it resumes from, so “cannot advance” is stated as arriving at a pair the run has already been entered with: a pass reproducing the record it was handed is the one-pass case of that, and a cycle two or more passes long — which comparing only against the previous pass cannot see — is the rest of it. Running out of the pass-count cap without ever reproducing a pair is treated the same way rather than left to fall out of the loop silently.

Three further paged-media behaviors keep the break points spec-conformant rather than naive:

After layout every CssBox has a final bounding rectangle and each CssLineBox has absolute document coordinates. Layout’s last phase turns that into its real output — the fragment tree.


6. Fragmentation

Key types: FragmentTree / FragmentainerFragment / BoxFragment (Html/Core/Fragments/Fragment.cs), FragmentEmitter (Html/Core/Fragmentation/FragmentEmitter.cs)

The phases above compute geometry on the box tree; this phase collects it into an immutable fragment tree, which is what layout actually hands downstream. It implements the box-fragment model of CSS Fragmentation Module Level 3 §2.

The model

A fragmentainer is one slot content flows into — a page, or a multi-column column, which §2 names in the same breath (“a column in multi-column layout, or a page in paged media”). It is not a DOM element. A box fragment is the portion of one box that lives in one fragmentainer: a box crossing a page boundary produces one fragment per page it appears on, and one split at a column boundary produces one fragment per column. Each fragment owns its geometry and keeps a read-only reference to its CssBox for style and paint-handler dispatch, so fragments stay cheap and style keeps a single home.

FragmentainerFragment is a page, because that is what gets materialized as a PdfPage; a column’s fragments are ordinary BoxFragments inside the page that holds it.

Type What it is
FragmentTree the document’s fragmentainers, in page order
FragmentainerFragment one page: its pagination slot, its resolved band geometry, and its root box fragment
BoxFragment one box’s portion of one page — its decoration rectangles, its words, its child fragments
LineFragment one decoration rectangle (one line box’s, or the whole border box for a block-level box)
TextFragment one positioned word

BoxFragment also records IsFirstFragment/IsLastFragment, which say where a box’s fragments begin and end (the edges a box-decoration-break value applies at are finer than this — they are per decoration rectangle, on LineFragment.Slice); WholeBoxRect, the unfragmented border box that whole-box effects (the transform pivot, the clip-path reference box) resolve against; OverflowClip, the rectangle an overflow: hidden ancestor clips this fragment to; and OverflowClipCurve, that same ancestor’s rounded-corner curve when it also has a border-radius — carried separately since a displaced fragment’s confinement band further narrows only the rectangle, never the curve. OverflowClip/OverflowClipCurve are resolved by the emitter rather than looked up from the boxes at paint time, because a box can be shown at several places in one document — a repeated table header is one source subtree standing in for every page’s proxy, and its boxes hold only the last position layout gave them. The emitter is the only thing that still knows which of those positions a given fragment came from.

Coordinates

Fragment rectangles are fragmentainer-local: local.Y = documentY - (PageTopOf(k) - MarginTop), X unchanged, since a page’s horizontal margin offset is applied by the painter’s own page translate. A position: fixed fragment subtracts nothing and is emitted in every fragmentainer, which is how fixed content repeats on every page.

How it is emitted

The fragmentainers are stated by the driver, not rediscovered geometrically. HtmlContainerInt.LayoutDocument hands each fragmentainer pass’s slots to FragmentEmitter as that pass ends: the slots from the one the pass began filling up to the later of the one it ended up filling and one below where the next resumes. That gap is not always empty — a monolithic subtree is laid out in a single pass and can cover bands past the one it started in, and a box placed far down the document means the next pass’s slot is not this one plus one. After the final pass the emitter is given the highest band any geometry reaches, which is the one geometric question the model still asks: content that no break record names (a monolithic subtree, a box that simply overflows its page) extends past the slot the pass that laid it out was filling.

Within a slot the rule is the same one as before: a box emits a fragment where its own geometry lands in that slot’s content band, or where any descendant emits. Band membership uses the painter’s own minimum-overlap epsilon, so the tree contains exactly the rectangles the painter would draw.

A fragment’s geometry is its own, which for a nested fragmentainer means layout has to state it. Every fragmentainer of the page grid differs from the last in the block axis only — §2 shares one inline size and position across a box’s fragments — so a page fragment’s rectangles can be read off the box and separated by the band. A column cannot be described that way: columns sit side by side inside one page band, so two column-fragments of one box have the same Y range and would have the same X, since a CssBox carries a single position. So the columns engine hands each column over as it finishes filling it (FragmentEmitter.RecordNestedFragmentainer) with the geometry its content had at that moment, plus the column’s own block and inline extent; membership becomes a question about both axes, and the box a column continues is moved to the next column’s origin rather than keeping the position of the one it left. Two consequences worth knowing: a box that continues has not had its height applied yet, so its fragment’s decoration area is measured from the content it placed there; and a column’s content bottom is read from the line boxes the pass kept, never from its words, because a flow that stops re-places only the words up to the break and the rest still hold the measurement pass’s positions.

A pass’s output is not always final, and the emitter can un-freeze it. The §4.3 corrections are bounded within the pass that discovers them, but a box only reaches its epilogue on the pass that completes it — for a box spanning several fragmentainers, a later pass. So break-inside: avoid can relocate a box out of a fragmentainer already frozen. Rather than forbid that, a relocation that reaches back into a frozen slot drops it, and it is emitted again once layout has settled. A pass never invalidates the slot it is itself filling or anything after it, so ordinary forward layout re-emits nothing.

Everything defined over the whole box, rather than over one slot, is resolved when the tree is materialized. A block-level box’s decoration area is its own border box, and a box that continues into a later fragmentainer has not had its height applied yet on the pass that freezes this slot; §6.2’s unbroken box is the sum of every rectangle the box produces, including ones a later pass has yet to add. Both are read at the end, from the emitter’s own record of what each slot held rather than from the live boxes — a box laid out again has its rectangles reset, so a line box a frozen slot recorded need not still exist.

IsFirstFragment/IsLastFragment come from the break record where there is one: at the end of a pass the outgoing token names exactly the boxes continuing into the next fragmentainer, and the incoming one exactly those continuing from the previous. Monolithic subtrees appear in no record, so their span comes from the slots they were emitted in.

The page grid needs layout to state a fragment too, in one case: a box whose continuation holds none of its content. css-tables-3 §6.1 fragments a row by fitting as much as each of its cells can take independently, so a row can continue with one of its cells already finished — and §6.1 continues that cell’s box with the row’s, as borders and background running the fragment’s depth with nothing in them. Nothing can be read off the box for this: a continuation deliberately leaves the finished cell’s single Location naming the fragmentainer that placed it, and a cell that finished is indistinguishable from one no pass ever entered by geometry alone. So the row loop states the rectangle (FragmentEmitter.RecordContinuationShell) once the row’s own bottom is settled, and the emitter builds a decoration-only fragment from it wherever that rectangle lands. Two properties keep it from widening anything else: the statement is honoured only for a box that already holds a frozen fragment somewhere, so it can continue a fragment but never invent one and _frozen membership is unchanged; and it never counts as printable content (CSS Paged Media Level 3 §3.2 excludes backgrounds and borders by name), so it cannot turn an otherwise empty slot into a page.

Two box kinds are not reachable by walking CssBox.Boxes and get explicit structure here. A repeating table <thead>/<tfoot> lives in a CssProxyBox whose source subtree is deliberately outside the live tree; the emitter descends into it through the proxy’s own captured geometry, so the repeated header becomes real fragments, one set per page. A rowspan placeholder (CssSpacingBox) gets the cell that spans into it as a fragment child, so that cell appears once per row it spans.

Blank-page skipping falls out of the build. CSS Paged Media Level 3 §3.2 asks user agents to avoid generating content-empty pages; a page-slot that no printable fragment landed in simply never becomes a fragmentainer. Whether a box counts as printable is DomUtils.HasOwnPrintableContent — text, generated content, an image, a visible background or border — excluding position: fixed content (which repeats everywhere and would make every slot look non-empty) and the box promoted to fill the page canvas (see Paint order). This is what lets Acid2’s own intentionally-huge 100em margins, meant to be scrolled off-screen in a single-viewport browser, skip straight past the gap instead of paginating through several blank pages.

The one exception is a page left blank on purpose. A directional forced break (break-before: recto and friends, CSS Fragmentation Level 3 §3.1) may need to step over a slot so its content lands on a left- or right-hand page; layout records that slot on the container, keyed by the box that took the break so the record can be retracted when a box’s prologue re-runs, and the emitter materializes it despite having no content to put on it. Such a reservation is write-only during layout — only the emitter reads it — so it annotates the final layout rather than feeding back into it.

What this replaced

Pagination used to be a paint-time effect: layout produced one tall strip, page count was rediscovered geometrically afterwards by intersecting a page grid against a list of printable Y-ranges, and the painter re-walked the same mutable box tree once per page with a per-page scroll offset written onto the container between paints. Fragments make which pages exist, and where content sits on each, facts produced by layout rather than reconstructed by paint. The first version of the tree was still built by a single walk over the finished box tree at the end, with the slot list derived from the document’s height; that walk is gone, and which fragmentainers exist is now a structural consequence of how layout filled them.

Fragmentation happens during layout. Layout fills one fragmentainer at a time: a pass targets a fragmentainer, and where content does not fit it stops, records where it stopped, and the next pass resumes from exactly that point (§2, §4.4) — see Pagination above. The emitter does not decide where breaks fall; it collects the already-fragmented result into the immutable tree.

A fragment’s rectangles were the last thing still derived from the box’s single position, which is why a multi-column column could not hold half of a child. They are now stated by whoever filled the fragmentainer, so a box genuinely splits across columns.


7. Painting

Key types: FragmentPainter (Html/Core/Paint/FragmentPainter.cs), IFragmentContentPainter (Html/Core/Paint/Content/IFragmentContentPainter.cs), StackingOrder (Html/Core/Paint/StackingOrder.cs), RGraphics (Html/Adapters/RGraphics.cs), BordersDrawHandler (Html/Core/Handlers/BordersDrawHandler.cs), CssImagePainter (Html/Core/Handlers/CssImagePainter.cs), CssImage (Html/Core/Entities/CssImage.cs), BackgroundImageDrawHandler (Html/Core/Handlers/BackgroundImageDrawHandler.cs)

Graphics abstraction

The rendering engine uses an abstract RGraphics base class so that all painting logic is independent of the underlying output backend. The PDF-specific implementation forwards every call to PdfSharpCore’s XGraphics. The abstract surface exposes:

Method Purpose
MeasureString Query text dimensions (used during layout too)
DrawString Render a text run with a given font, colour, and RTL flag
DrawLine Draw a straight line segment (used for borders)
DrawRectangle(RPen,…) Stroke a rectangle outline
DrawRectangle(RBrush,…) Fill a rectangle (backgrounds, solid borders)
DrawImage Blit a decoded image at a destination rectangle
DrawPath Stroke or fill an arbitrary RGraphicsPath (rounded corners, dashed borders)
DrawPolygon Fill a polygon (used for border mitre joints)
PushClip / PopClip Manage a clip-rectangle stack for overflow: hidden and page margins
SuspendClipping / ResumeClipping Temporarily remove all clips for position: fixed elements
SetAntiAliasSmoothingMode Enable anti-aliasing around borders and paths

Brush and pen objects are created through the adapter (GetSolidBrush, GetPen, GetLinearGradientBrush, GetRadialGradientBrush, GetConicGradientBrush, GetTextureBrush) so the platform-specific representation stays encapsulated.

The painter

Painting is its own phase, and it consumes the fragment tree rather than the box tree. FragmentPainter takes one FragmentainerFragment — one page — and paints its fragment subtree. Every method receives the BoxFragment being painted and reads all geometry from it, using the box only for computed style. Because fragment rectangles are already fragmentainer-local, there is no per-page coordinate offset to apply anywhere in the painter.

One painter instance paints one page, so per-page state (which fragments have already been drawn — a fragment can be reached twice, once nested and once hoisted for stacking order) belongs to the painter and is never recorded on the box tree or the container. Painting is fully synchronous: everything it draws was resolved during layout.

FragmentPainter.PaintFragment applies the CSS painters algorithm in the correct order, skipping boxes with display: none or visibility: hidden. Fixed-position boxes suspend the clip stack so they paint relative to the page rather than within any page margin clip. A fragment exists only where its box has something on that page, so the off-screen cull reduces to intersecting the fragment’s own rectangles with the current clip — a fragment that carries only descendants is always entered, since a hoisted out-of-flow descendant can paint outside it.

Boxes that cannot be expressed by the generic paint — replaced elements (<img>, <object>/<video>, inline <svg>, <iframe>), <hr> and ::marker — are drawn by an IFragmentContentPainter chosen from the box’s type (FragmentContentPainters.For). The four replaced kinds share one ReplacedFragmentPainter base, which owns the sequence they have in common (overflow clip, background, borders, then the replacement content in the content box of the phantom word that carries it) and asks the subclass only where the content comes from and how to draw it. Adding a new replaced element means adding a content painter, not paint code on CssBox.

Each box fragment is painted as follows:

  1. Background colour — fills the box’s border area with background-color.
  2. Background images, gradients, and list markers — All CSS image values (whether used as a background-image layer or a list-style-image marker) are represented as a CssImage discriminated union and painted through the single entry point CssImagePainter.Paint. The host supplies the destination rectangle and position/repeat settings; CssImagePainter dispatches by image type:
    • URL images — delegated to BackgroundImageDrawHandler.DrawBackgroundImage, which handles all four background-repeat modes (no-repeat, repeat-x, repeat-y, repeat) and background-position placement. The decoded RImage is owned by CssImage.Url via its embedded ImageLoadHandler. When the URL source is an SVG, CssImage.Url instead exposes the parsed SvgDocument; CssImagePainter renders it once into an RGraphics.CreateTile Form XObject sized to the resolved background-size (using the SVG’s own intrinsic width/height/ratio, like a raster image’s), then hands that tile to BackgroundImageDrawHandler so it positions/repeats exactly like a decoded raster image — real vector content, never rasterized.
    • Linear gradientsGetLinearGradientBrush with arbitrary colour stops and angles, including repeating-linear-gradient.
    • Radial gradientsGetRadialGradientBrush with elliptical shape, size keywords (closest-side, farthest-corner, etc.), and repeating variants.
    • Conic gradientsGetConicGradientBrush with per-stop angle positions.

    List marker images are loaded during word measurement (the same EnsureLoadedAsync call used by background layers) and painted by the ::marker box’s own content painter immediately after the child-box paint, using a font-height-sized square positioned to the left of the list item.

  3. BordersBordersDrawHandler.DrawBoxBorders draws each side independently, respecting border-style (solid, dashed, dotted, double, groove, ridge, inset, outset), border-width, and border-color. Rounded corners are rendered as RGraphicsPath arcs. For inline elements that span multiple line boxes, left and right borders are only drawn on the first and last fragment respectively.
  4. Inline content — for each CssLineBox the box participates in, its CssRect words are drawn in order. CssRectWord instances emit a DrawString call; CssRectImage instances emit a DrawImage call. Text decoration (underline, overline, line-through) is drawn as lines immediately after the text.

Stacking order (CSS2.1 Appendix E) and Acid2

The four steps above order one box’s own layers; ordering sibling and descendant boxes against each other is the stacking-context problem, which StackingOrder owns. Within a single stacking context, the painter’s stacking loop paints in CSS2.1 Appendix E order: in-flow block-level descendants first, then non-positioned floats, then in-flow inline-level content (text and inline replaced content, e.g. an inline <img>/<object>), then positioned descendants. A plain block-level wrapper whose entire content is inline (a <div> around nothing but an inline image) is treated as part of the inline pass — StackingOrder.ActsAsInline — since painting it is what paints that inline content.

Floats complicate this because a float can need to paint against siblings at a different nesting level than where it’s declared. DomUtils.NeedsStackingHoist/StackingOrder.Flatten hoist such a float, and IsLocalOrderingScope decides how far: a float hoisted past its immediate container still preserves correct local order against its non-hoisted siblings as long as that container is itself positioned (position: relative/absolute/fixed/sticky), even without an explicit z-index; a genuine stacking-context descendant (real z-index, opacity < 1, or a transform) instead escapes all the way to the nearest true stacking context, where z-order competition actually has meaning. One narrower case is a known residual: a float whose immediate container is a plain, non-positioned wrapper hoists to the nearest stacking-context ancestor rather than preserving local order against that wrapper’s siblings.

This paint-order correctness — together with the pagination fixes above (blank-page skipping and unforced-break margin truncation) — was driven and validated by the Acid2 browser conformance test, which PeachPDF now renders substantially correctly. A few of Acid2’s own edge tricks retain minor residual deviations that are inherent to a paginated (rather than single-scrolling-viewport) renderer — most visibly its interlocking-checkerboard background layers and its assumption that a position: fixed element can cover content on a single continuous canvas. The stacking-context model is documented from the authoring side in Stacking Context.

Image loading and decoding

Images are loaded on demand by ImageLoadHandler. Supported sources include file paths, HTTP URLs (via INetworkLoader), data: URIs, and MHTML-embedded resources. Decoding is handled by PeachImage (JPEG, PNG, BMP, GIF, WebP, AVIF, and TIFF — TGA, PSD, and HDR aren’t implemented there and so aren’t decodable). Decoded images are cached for the lifetime of a single render so that the same image referenced multiple times in a document is only decoded once.

ImageLoadHandler is an implementation detail of CssImage.Url: each URL image owns its handler and exposes EnsureLoadedAsync(HtmlContainerInt) for lazy loading and Dispose() for cleanup. Callers (background layer loops, list marker painting) interact only with CssImage and never touch ImageLoadHandler directly.


8. PDF Rendering

Library: PdfSharpCore (forked and merged into PeachPDF)

Source location: src/PeachPDF/PdfSharpCore/

The final phase writes the PDF file. PeachPDF embeds a custom fork of PdfSharpCore directly in the source tree. The fork has been optimised specifically for PeachPDF’s usage patterns and trimming requirements.

Adapter bridge

The adapters in src/PeachPDF/Adapters/ implement the abstract types that the rendering engine uses (RGraphics, RBrush, RPen, RFont, RFontFamily, RImage, RGraphicsPath, XTextureBrush). They translate every RGraphics drawing call into the corresponding XGraphics call in PdfSharpCore, keeping the core rendering logic completely decoupled from the PDF format.

Font pipeline

PdfSharpCore’s font subsystem is built around OpenType:

The font resolver honours the family mappings registered via PdfGenerator.AddFontFamilyMapping and discovers system fonts from the operating-system font directories at startup. On top of that base, font resolution is codepoint-aware: matching, layout, and PDF text emission all operate on Unicode codepoints (System.Text.Rune), not on the requested family alone. This is what makes per-character font fallback, @font-face unicode-range, and supplementary-plane (emoji) text work.

Codepoint-aware resolution

A font family is a FontFamilyModel (PdfSharpCore/Internal/FontFamilyModel.cs) whose Faces is a list of FontFaceEntry, each carrying the codepoint ranges it covers. Those ranges come either from an explicit @font-face unicode-range descriptor or — for a system or rangeless font — from the font’s actual cmap coverage, computed lazily by CMapCoverage.Extract (PdfSharpCore/Fonts.OpenType/CMapCoverage.cs).

FontResolver.ResolveTypeface (PdfSharpCore/Utils/FontResolver.cs) takes an optional Rune; when supplied, it first filters a family’s faces to those that cover that codepoint, then style-matches (weight/stretch/style, per the CSS Fonts Level 4 §5.2 nearest-match already used elsewhere). A codepoint-less call is byte-identical to the old behavior, so the whole existing font-matching test surface is preserved. The codepoint threads through FontResolvingOptions.CodepointXFontXGlyphTypeface.GetOrCreateForCodepoint, which keys the resolved typeface by the face it resolved to, not by the codepoint — so a multi-script run reuses one typeface per face rather than spawning one per character.

Per-character font matching

Cross-family fallback needs the whole authored font-family stack, which the ordinary cascade collapses to the first existing family. PeachPDF retains it as CssBoxProperties.FontFamilyList. During word building, CssBox.AddWord (Html/Core/Dom/CssBox.cs) splits a word into per-face fragments only when needed — a fast-path scan (NeedsPerCodepointFont, using RFont.HasGlyph) checks whether the primary font already covers every codepoint in the word, and ordinary fully-covered text stays a single word at zero added cost. When a split is required, each fragment resolves to the first family in the stack that both covers its codepoints and has a glyph for them; this split composes with the small-caps split rather than duplicating it. See Per-character font matching for the user-facing behavior.

Content-addressed font identity

PeachPDF identifies a custom font by its bytes, not by its self-reported internal name — the same model browsers use. Two different files that happen to share an internal name (a common webfont-subset pattern, where each subset file is named identically) therefore no longer collide: FontResolver._CustomFonts disambiguates on a content checksum (only when a genuine collision occurs, so the common case keeps FaceName == internal name), PdfFontTable.ComputeKey folds FontSource.Key into its cache key, and the name-keyed caches (FontFactory.CacheFontSource, OpenTypeFontfaceCache.AddFontface) tolerate a second same-name/different-bytes entry instead of throwing.

Composite fonts and the Rune-based CID pipeline

Text is written as composite PdfType0Font/PdfCIDFont (PdfSharpCore/Pdf.Advanced/) objects, and the whole emission path is codepoint-based rather than UTF-16-char-based: CMapInfo.AddChars, FontHelper.MeasureString, and XGraphicsPdfRenderer.DrawString all iterate Runes, CMapInfo.CharacterToGlyphIndex is keyed by an int codepoint, and PdfToUnicodeMap (PdfSharpCore/Pdf.Advanced/PdfToUnicodeMap.cs) emits a proper UTF-16 bfrange ToUnicode destination (an astral codepoint becomes a surrogate pair, keeping copy/paste and text extraction correct). Basic-Multilingual-Plane text is byte-for-byte identical to the pre-Rune output, so the existing suite is the regression guard for this rework.

Astral codepoints and emoji

Supplementary-plane codepoints above U+FFFF — where nearly all emoji live — resolve through a cmap format-12 subtable (PdfSharpCore/Fonts.OpenType/OpenTypeFontTables.cs): CMapTable.Read selects a format-12 subtable alongside the format-4 one, OpenTypeDescriptor.CharCodeToGlyphIndexCore routes any codepoint > 0xFFFF to CMap12.MapCodeToGlyph, and CMapCoverage.Extract adds the format-12 ranges so astral characters participate in coverage-based fallback too. A subtle word-splitter bug was fixed alongside this: CommonUtils.IsAsianCharacter was char-based and its range overlapped the UTF-16 surrogate range, so it split every astral character’s surrogate pair into two lone-surrogate (→ U+FFFD) words before the font ever saw it; it is now Rune-based.

The pipeline embeds glyf/CFF outlines, so emoji render as the font’s monochrome outline. Color emoji is not supported — color-glyph tables (COLR/CPAL, CBDT/CBLC, sbix, SVG-in-OpenType) are ignored, so a color-emoji font renders blank or monochrome; a monochrome emoji font (e.g. Noto Emoji) renders its outlines. See Per-character font matching for the exact boundaries.

No text-shaping stage

Font resolution maps each codepoint 1:1 to a glyph through the cmap; there is deliberately no OpenType Layout stage. GlyphSubstitutionTable.Read is an empty stub and there is no GPOS parser, so automatic ligatures (f+i), complex-script joining/reordering (Arabic, Indic), kerning, and the Unicode Bidi Algorithm are all absent by design — only direction: rtl whole-word mirroring exists. This keeps the codepoint→glyph path simple and predictable; see Text shaping for the full list of what this excludes.

Registering fonts at runtime

PdfGenerator.AddFontFromStream(stream) registers a font (TrueType, CFF, WOFF, or WOFF2) for the current generator instance. The AddFontFromStream(stream, IReadOnlyList<RuneRange>) overload (RuneRange.cs) additionally scopes that font to specific codepoint ranges — the programmatic equivalent of an @font-face unicode-range descriptor — so characters outside the declared ranges fall back to another registered font via the per-character matching above. See Fonts for usage.

Thread safety

PdfGenerator and everything it owns (font/brush/pen caches, the font resolver, HtmlContainer) is instance-scoped and not safe to share across threads — but PeachPDF is designed so that using one PdfGenerator per thread is safe, including the process-wide state this pipeline touches:

Image pipeline

PdfSharpCore includes importers for JPEG (ImageImporterJpeg) and BMP (ImageImporterBmp) formats. Other formats (PNG, GIF) arrive as pre-decoded RGBA bitmaps from PeachImage (see Image loading and decoding) and are written as PDF image XObjects using XBitmapImage.

Graphics context

XGraphicsPdfRenderer implements IXGraphicsRenderer and translates every drawing operation into PDF content stream operators. The page coordinate system is flipped from the CSS top-left origin to the PDF bottom-left origin at this layer. Each page in the document gets its own XGraphics context; the rendering engine drives page breaks by advancing to a new page when HtmlContainerInt signals a page boundary.

Output

PdfGenerator is the public entry point. It:

  1. Creates a PdfDocument and configures page size and orientation from PdfGenerateConfig.
  2. Resolves @page margin overrides from the stylesheet.
  3. Runs the full HTML→DOM→CSS→Layout→Paint pipeline against the document’s pages.
  4. Returns the completed PdfDocument, which the caller saves to any Stream.

Unnecessary PdfSharpCore features (WPF/GDI rendering targets, interactive form fields, XPS output) have been removed, keeping the dependency surface small and compatible with .NET 8 trimming and AOT compilation.

Tagged PDF (PDF/UA) structure tree

Key types: StructureTagBuilder / StructureTagMapper (Html/Core/Handlers/), PdfStructureElement / PdfStructureTreeRoot / PdfNumberTreeNode / PdfMarkedContentReference / PdfObjectReference (PdfSharpCore/Pdf.Structure/)

Tagged output (PdfGenerateConfig.EnableTaggedPdf, off by default — see Tagged PDF (PDF/UA) Support for the user-facing CSS property and default mapping) is built live from the same fragment paint walk that produces the visible page content, rather than from a separate pass or an event-interception layer around drawing calls (the approach upstream PDFsharp’s modern UAManager/StructureBuilder uses, which depends on an XGraphics event system this ~2016-era fork doesn’t have). PeachPDF already fully controls its own paint walk, so it is the natural place to hook in.


SVG Rendering

Key types: SvgTreeBuilder (Svg/SvgTreeBuilder.cs), SvgRenderer (Svg/SvgRenderer.cs), SvgDocument (Svg/SvgDocument.cs), ISvgSourceNode (Svg/ISvgSourceNode.cs), CssBoxSvg (Html/Core/Dom/CssBoxSvg.cs)

PeachPDF renders SVG — both inline <svg> elements in HTML and standalone SVG (<img src="x.svg"> / data:image/svg+xml) — as real vector PDF content: shapes become native PDF path-construction operators, gradients become native PDF shadings, and clips/masks/patterns become native PDF constructs. SVG is never rasterized to a bitmap. This is a cross-cutting subsystem, not a pipeline phase of its own — it plugs into DOM construction (§2), layout (§5), and painting (§6) through two specialised CssBox subtypes. For the full element/property compatibility matrix, see Supported SVG Features.

Two entry points, one pipeline

SVG content reaches the renderer through one of two CssBox subtypes, both converging on the same SvgTreeBuilder/SvgRenderer pipeline:

Entry point Source How the source is read
CssBoxSvg An inline <svg> element in the HTML document Its already-parsed descendant CssBox tree (built for free by the ordinary HTML parser — see §1) is read as a plain tag/attribute data source, never laid out or painted through the generic box pipeline
CssBoxImage <img src="x.svg"> or <img src="data:image/svg+xml,..."> ImageLoadHandler sniffs the .svg extension or data:image/svg+xml/Content-Type: image/svg+xml and, instead of decoding a raster bitmap, parses the fetched bytes as standalone XML (XDocument.Load)

Both paths build an SvgDocument scene graph once (cached for the box’s lifetime — CssBoxSvg.EnsureDocument, ImageLoadHandler.LoadSvgFromStream) and repaint it from that cached graph on every subsequent paint, including once per output page during pagination.

Source abstraction — ISvgSourceNode

SvgTreeBuilder never touches CssBox or XElement directly. Both entry points instead wrap their underlying tree behind a minimal, source-agnostic interface — Name, GetAttribute(name), Children, GetTextContent() — so the exact same tree-building code produces an identical SvgDocument regardless of which source it came from:

GetTextContent() returns only a node’s own direct text-node children, not descendant elements’ text — this matters for <text>Hello <tspan>World</tspan></text>, where “Hello” (the <text>’s own run) must stay separate from “World” (the <tspan>’s own run) for both source kinds identically.

Build phase — SvgTreeBuilder

SvgTreeBuilder.Build(ISvgSourceNode root, RAdapter adapter, RColor? contextColor) runs synchronously in two passes:

  1. CollectDefinitions — a single walk of the whole tree that registers every id-bearing node (Dictionary<string, ISvgSourceNode>) and fully resolves self-contained definitions (gradients, markers, patterns, masks, <style> text) up front. This exists because SVG allows forward references — a <use> or fill="url(#id)" can reference an id defined later in document order.
  2. Recursive tree buildBuildElement/BuildGroup/BuildPath/etc. walk the tree again, this time constructing the immutable SvgElement scene graph, resolving url(#id) references against the now-complete registry from pass 1.

Presentation properties (fill, stroke, opacity, font, etc.) are threaded down the recursion as small immutable record structs — InheritedPaint for paint/stroke properties, FontContext for <text>’s font-family/size/weight/style — so a property left unspecified on a child correctly resolves to its nearest ancestor’s value, matching CSS-style inheritance without needing CssBox’s own cascade machinery.

The result is an SvgDocument: a ViewBox/Width/Height/PreserveAspectRatio plus a List<SvgElement> scene graph (SvgPathElement, SvgCircleElement, SvgRectElement, SvgGroupElement, SvgUseElement, SvgImageElement, SvgTextElement, and others — see Svg/SvgElement.cs) plus dictionaries of gradient/clip-path/marker/pattern/mask definitions keyed by id.

Paint phase — SvgRenderer

SvgRenderer.RenderInto(RGraphics g, SvgDocument document, RRect viewportRect) is the single paint entry point shared by the inline-SVG and <img src="x.svg"> content painters. It clips to the target rectangle, computes the viewBox→viewport transform (ComputeViewportTransform, supporting all 9 preserveAspectRatio alignment keywords plus meet/slice/none), pushes that transform, and recursively paints every scene-graph element.

Critically, SvgRenderer issues nothing but ordinary RGraphics calls (GetGraphicsPath, DrawPath, GetSolidBrush, GetLinearGradientBrush, PushClip, PushTransform, DrawString, …) — the same abstraction §6’s HTML/CSS painting uses. There is no SVG-specific graphics API; an SVG <path> becomes an RGraphicsPath built from bezier/arc/line segments exactly the way a CSS border-radius corner does, and an SVG gradient becomes an RBrush from the same GetLinearGradientBrush/GetRadialGradientBrush calls background-image gradients use. This is what keeps SVG output genuinely vector: every drawing call flows through the same XGraphics-backed adapter as the rest of the document (§7).

PDF’s native shading types have no tiling/repeat concept of their own, so spreadMethod="repeat"/"reflect" (SvgRenderer.ExpandLinearSpread/ExpandRadialSpread) pre-tile the gradient’s own stop list — projecting the filled shape’s bounding box onto the gradient axis (or radius) to find how many cycles are needed to cover it, then replicating the stops per cycle, mirroring alternate cycles for reflect — before the brush is ever built. This mirrors, but doesn’t share code with, how CSS’s repeating-linear-gradient()/repeating-radial-gradient() (CssImagePainter.ExpandRepeatingStops) solve the identical underlying problem: CSS’s gradient axis is already sized to the background box before tiling starts, while SVG’s x1/y1/x2/y2 (or r) define only one author-chosen cycle, and SVG additionally needs reflect, which CSS repeating-gradients don’t have.

The viewport-transform helper is reused, not reimplemented, for every SVG construct that establishes its own coordinate system: a nested <svg>, a <symbol> reached through <use>, a <marker> instance, and a <pattern> tile all call the same RenderViewport helper RenderInto itself is built on.

PDF primitive reuse for pattern/mask

<pattern> and <mask> needed genuinely new PDF-writing capability, supplied by extending the RGraphics/RAdapter abstraction rather than adding SVG-specific PDF code:

An <a> element becomes a real PDF link annotation, reusing the same annotation-registration pipeline plain HTML <a> elements already use rather than a parallel SVG-specific one. Because painting runs once per output page during pagination, link discovery is a deliberately separate, paint-independent tree walk — SvgRenderer.CollectLinks composes transforms and bounding boxes only, issuing no RGraphics calls — so a link is registered exactly once regardless of how many pages the containing box is painted on. DomUtils.GetAllSvgLinks finds every CssBoxSvg/CssBoxImage in the box tree and calls CollectLinks on each; HtmlContainerInt.GetLinks() merges the results into the same list ordinary HTML <a> links populate.

Coverage

See Supported SVG Features for the complete element/attribute compatibility matrix, including the reasoning behind each deliberately-excluded SVG feature (SMIL animation, scripting, filter, foreignObject, legacy SVG fonts, textPath, and others).


Named Pages & Margin Boxes

Key types: PageRule/MarginStyleRule (CSS/Rules/PageRule.cs, CSS/Rules/MarginStyleRule.cs), PageNameProperty (CSS/StyleProperties/PageNameProperty.cs), CssNamedStringEngine (Html/Core/Dom/CssNamedStringEngine.cs), MarginBoxRenderer (Html/Core/Dom/MarginBoxRenderer.cs)

CSS Paged Media’s named @page rules and margin boxes (@top-center, @bottom-right, …) — the mechanism behind running headers/footers — are a cross-cutting subsystem spanning CSS parsing (§3), layout (§5), and PDF rendering (§7), similar in shape to SVG above.

Parsing

@page name:pseudo { … } selectors are parsed into a PageSelector holding one or more PageSelectorEntry(Name, Pseudo) pairs (e.g. @page dictionary:firstName="dictionary", Pseudo="first"). Each @page rule becomes a PageRule exposing Selector, Style (page-level declarations like margin/size), and Margins — the nested @top-center etc. blocks, each a MarginStyleRule (selector name + its own declaration block).

Assigning content to a named page

The page CSS longhand (PageNameProperty, surfaced as CssBoxProperties.PageName) assigns a box to a named page type. Once a box’s Location.Y is finalized during layout, CssBox.PerformLayoutImp/CssLayoutEngine register it — HtmlContainer.RegisterNamedPageElement(name, y) appends a NamedPageElement(Name, Y) to HtmlContainerInt’s tracked list. This registration has to happen strictly after Location is final (and, if a later pass like multi-column re-banding moves the box, OffsetTop/OffsetLeft must keep the recorded Y in sync — see the note at the end of Multi-column layout above) — registering against a stale Y is what silently pointed running headers at content on the wrong page in an earlier version of this feature.

Resolving which @page rule applies, per output page

PdfGenerator.GetOrderedApplicableRules computes, for each PDF page, the “active named page” — the most recent NamedPageElement whose Y precedes that page’s end, since a page assignment propagates forward through the flow rather than applying to a single page only (MarginBoxRenderer.PageBoundaryEpsilon, 0.5, absorbs floating-point boundary noise). It then scores every @page selector entry (name+pseudo highest, name-alone or pseudo-alone lower, :first always wins when it matches) into an ascending-precedence list. Three callers consume that shared list: SelectPageRule picks the single winning rule for page-level properties (margin/size, via ResolvePageMargins); SelectApplicableMarginRules merges margin-box declarations by box name across every matching rule (a low-specificity base rule’s @top-left and a higher-specificity named rule’s @bottom-right both need to render on the same page); SelectApplicablePageStyle merges page-level declarations as a font-property inheritance fallback for margin boxes that don’t set their own font.

Rendering margin boxes

MarginBoxRenderer.Render runs once per output PDF page (called from PdfGenerator.CreatePdf), given that page’s merged margin rules and page style. For each MarginStyleRule it resolves content (string literals, counter(page|pages), string(name[, keyword])), computes box geometry via GetMarginBoxRect/ComputeThreeBoxSizes (explicit width/min/max honored first, remaining space split evenly among auto boxes within each three-box row/column), and resolves font/color/alignment — falling back to the page style for font properties per the CSS Paged Media inheritance model.

Running headers/footers — string-set and string()

CssNamedStringEngine.ApplyStringSet runs during layout for any box with a string-set declaration, parsing the name content-list, name2 content-list2, … grammar (counter(), counters(), attr(), content(), string() all supported inside the content list) and producing NamedString(Name, Value, Y) records — stored both on the box itself and centrally via HtmlContainer.RegisterNamedString. MarginBoxRenderer.ResolveNamedString later implements the GCPM first/start/last/first-except selection keywords by filtering that document-ordered list down to the current page’s [pageY, pageY + pageHeight) window (again widened by PageBoundaryEpsilon) — this is what makes a @top-center { content: string(chaptertitle) } margin box show the correct running header for whatever content actually landed on that specific page, not just the first or last string-set in the whole document.

Running elements — position: running() and element()

Key types: RunningElement/RunningSelectionEngine (Html/Core/Entities/RunningElement.cs, Html/Core/Dom/RunningSelectionEngine.cs), RunningElementLayout (Html/Core/Dom/RunningElementLayout.cs), MarginBoxContentFragmentBuilder (Html/Core/Fragmentation/MarginBoxContentFragmentBuilder.cs), MarginBoxFragment/FragmentainerFragment.MarginBoxes (Html/Core/Fragments/Fragment.cs)

css-gcpm-3’s running()/element() go further than string-set/string(): instead of capturing plain text, a position: running(name) element is removed from flow entirely and content: element(name) shows it complete with formatting and descendant elements — genuine layout, not a captured string. That requires more than a document-ordered value list:


Testing

Every phase described above is exercised by an automated test suite and a continuous-integration pipeline that verifies output correctness across platforms. Beyond the automated gate, graphics-state output is verified during development by rasterizing PDFs with two independent renderers (PDFium and MuPDF) to confirm they actually look right, not merely that they contain the expected operators — a manual practice, not a CI step. See How PeachPDF Is Tested for the full picture.