HTML & CSS Support
PeachPDF renders a subset of the HTML and CSS specifications. This page documents exactly what is and is not supported. Where a feature is only partially supported, the specific gaps are noted.
Forward compatibility
Only standards-compliant documents are guaranteed to keep rendering the same way across PeachPDF versions. As support grows, PeachPDF moves toward what the specifications say — so a document written against the standard keeps working, and gets better as gaps close.
A document that relies on anything outside the standard carries no such guarantee. That includes a value or property PeachPDF happens to accept today but the relevant specification does not define; behavior that falls out of a documented gap (for example, laying content out as though an unimplemented property were absent); and quirks of how PeachPDF currently approximates a feature. Any of these may change in a release that brings the affected area closer to spec, and such a change is not treated as a regression.
Where a correction like that has a visible effect, it is called out in the release notes for the version it shipped in.
Length units
All CSS length units resolve through one shared conversion, at their spec-defined physical ratios (CSS Values & Units §6.2) — everywhere: body layout, fonts, borders, backgrounds, images, @page geometry, and SVG.
| Unit | Physical size | In PDF points |
|---|---|---|
px |
1/96 in | 0.75pt |
pt |
1/72 in | 1pt |
in |
1 in | 72pt |
cm / mm |
metric | 28.35pt / 2.835pt |
pc |
1/6 in | 12pt |
Q |
1/40 cm | 0.709pt |
In particular, px is spec-correct CSS pixels: a 96px-wide element is exactly one inch wide in the output PDF, and a 96×96-pixel image renders at its true CSS size of one square inch — the same as every browser’s print output and other spec-conformant paged renderers. Relative units (em/rem/%) resolve against their usual per-property bases; the font-measured units (ex/ch/cap/ic/lh and their root-element rex/rch/rcap/ric/rlh forms) are read from the used font — see Font-relative units; viewport units (vw/vh/vmin/vmax, and their logical/small/large/dynamic variants) are supported against the PDF page box — see CSS Viewport Units.
Font-relative units
The units below are defined by the font, per CSS Values and Units 4 §6.1. Each is measured from the font the element actually uses — the first family in its font-family list that resolves — rather than being a fixed fraction of font-size, so 10ch in a monospace font and 10ch in a proportional font are different widths.
| Unit | Measures | When the font can’t say |
|---|---|---|
em |
the element’s own font-size |
— |
ex |
the font’s x-height (OS/2 sxHeight) |
0.5em |
ch |
the advance of the “0” (U+0030) glyph | 0.5em |
cap |
the font’s cap height (OS/2 sCapHeight) |
the font’s ascent |
ic |
the advance of the “水” (U+6C34) ideograph, in whichever family in the stack supplies it | 1em |
lh |
the element’s used line-height |
— |
rem, rex, rch, rcap, ric, rlh |
the same measurements, taken from the root element’s font (and, for rem/rlh, its font-size/line-height) instead of the element’s own |
as above |
The measured units are accepted everywhere a <length> is, including inside calc(), gradient stop positions and radii, and SVG and MathML lengths. In font-size itself, and in line-height for lh, they refer to the parent’s font and line-height (the element’s own is what is being computed), as the specification requires.
A context with no font of its own — a media query or an @page margin, size or margin-box length — has nothing to measure, so there each unit takes the fallback in the last column (and cap and lh use 0.7em and 1.2em).
Nesting depth
Layout, painting and the SVG renderer walk the document tree recursively, so how deep a document may nest depends on the stack of the thread that renders it. A document nested deeper than that stack allows (roughly 100 to 150 levels of <div> on a 1 MB stack, the default of a Windows thread and of a WebAssembly host, and a few dozen on the 512 KB stack of a macOS secondary thread) fails with an HtmlRenderException instead of ending the process with a stack overflow. Elements nested more than 400 levels deep are refused when the HTML is parsed, whatever the stack. color-mix() operands nested more than 32 levels deep make the colour invalid, like any other value that cannot be read. Run the render on a thread with a larger stack to allow deeper documents.
HTML Elements
Global attributes
| Attribute | MDN Reference | Notes |
|---|---|---|
dir |
dir | ltr, rtl, and auto are all supported on any element. auto (and <bdi>’s implicit default when it carries no dir of its own) resolves the element’s base direction from the first strong-directional character in its own text content, per the HTML Standard’s directionality algorithm — skipping into a descendant only if that descendant has no dir of its own, and never descending into a nested element that sets its own direction |
hidden |
hidden | Any element carrying it — valueless, empty or with any value other than until-found — computes to display: none, so it generates no box, takes no space and paints nothing, exactly as the HTML Standard’s hidden elements rendering rules specify. The rule is in the user-agent style sheet, so an author display declaration on the element overrides it (<div hidden style="display: block"> is shown), as in a browser. <embed hidden> is kept as a zero-sized inline box instead. hidden=until-found is not hidden: it maps to content-visibility: hidden, which PeachPDF does not support, so that content stays visible. The same style sheet hides area, base, basefont, datalist, head, link, meta, noembed, noframes, param, rp, script, style, template and title, and <input type=hidden> (in any letter case) unconditionally. |
Document Structure
| Element | MDN Reference | Notes |
|---|---|---|
html |
html | Full support |
head |
head | Processed for <style> and <link rel="stylesheet"> children; other children are ignored |
body |
body | Full support |
Metadata
| Element | MDN Reference | Notes |
|---|---|---|
style |
style | Inline stylesheets are applied |
link |
link | Only rel="stylesheet" is processed; other link types are ignored |
meta |
meta | name="author", name="subject", name="keywords", name="date", and name="generator" are extracted and written to the PDF info dictionary; all other meta names are ignored |
title |
title | Text content is written to the PDF document title in the info dictionary |
PDF metadata extraction
PeachPDF automatically extracts standard HTML metadata elements and writes them to the PDF info dictionary. No additional configuration is required — just include the elements in your HTML <head>.
| HTML source | PDF info field |
|---|---|
<title> inner text |
Title |
<meta name="author" content="..."> |
Author |
<meta name="subject" content="..."> |
Subject |
<meta name="keywords" content="..."> |
Keywords |
<meta name="date" content="..."> |
Creation date (parsed via DateTime.TryParse) |
<meta name="generator" content="..."> |
Creator |
The Producer and Creator fields both default to PeachPDF {version} when no <meta name="generator"> is present. The Producer field always identifies PeachPDF as the PDF converter regardless of any generator meta tag.
Example:
<!DOCTYPE html>
<html>
<head>
<title>Quarterly Report</title>
<meta name="author" content="Finance Team">
<meta name="subject" content="Q1 2025 Results">
<meta name="keywords" content="finance, quarterly, report">
<meta name="date" content="2025-04-01">
</head>
<body>
<!-- document content -->
</body>
</html>
Sections
| Element | MDN Reference | Notes |
|---|---|---|
article |
article | Rendered as a block |
aside |
aside | Rendered as a block |
details |
details | Rendered as a block. One without the open attribute shows only its <summary> and hides the rest of its content; with open it shows everything. A host that shows the document opens and closes it with HtmlDocument.SetOpenAsync (see PeachBrackets). The content is hidden by a block the engine puts around everything after the first <summary> (the standard’s ::details-content box), so no display value an author gives the content can show it while the element is closed; any further <summary> is content and is hidden with the rest. ::details-content itself cannot be styled yet, and content-visibility is not supported |
dialog |
dialog | Rendered as a block; open/close behavior is not supported |
figure |
figure | Rendered as a block |
figcaption |
figcaption | Rendered as a block |
footer |
footer | Rendered as a block |
header |
header | Rendered as a block |
hgroup |
hgroup | Rendered as a block |
main |
main | Rendered as a block |
nav |
nav | Rendered as a block |
search |
search | Rendered as a block |
section |
section | Rendered as a block |
summary |
summary | Rendered as inline text; the disclosure triangle is not rendered |
Content Grouping
| Element | MDN Reference | Notes |
|---|---|---|
address |
address | Rendered as a block |
blockquote |
blockquote | Rendered as a block with default margin |
center |
center | Deprecated element; rendered with text-align: center |
dd |
dd | Full support |
dir |
dir | Deprecated element; rendered as an unordered list |
div |
div | Full support |
dl |
dl | Full support |
dt |
dt | Full support |
fieldset |
fieldset | Rendered as a block with a border; no interactive behavior |
form |
form | Rendered as a block; form submission is not supported |
hr |
hr | The rule is the box’s border, painted by the same border code as every other block-level box, so every border-style works on it — inset/outset/groove/ridge shade their faces, dotted/dashed fit their pattern to the edge, double draws its two lines, and border-style: none paints nothing at all. noshade and color keep the rule flat rather than engraved, per the HTML Standard’s own UA rule for them, and a flat rule — whether flattened by one of those attributes or by an author’s own border-style — takes its colour from color: <hr color="red"> is red, and both <hr noshade> and hr { border-style: dashed } are the UA sheet’s own gray. The default engraved rule derives its two greys from the bevel instead, so neither color nor an inherited colour tints it — see the currentcolor note under border styles. It is positioned by the same block-flow code, so it follows CSS 2.1 §9.4.3 (a relatively-offset preceding sibling does not move it) and §8.3.1 (its margin collapses against the nearest in-flow predecessor, never a float). Its default margin is 0.5em above and below with auto on the left and right, as in the HTML Standard, so a rule narrower than its container centres, and an author margin overrides all of it. The legacy attributes map as the HTML Standard says: align (left, right or center, matched case-insensitively against the exact value) sets the horizontal margins rather than text-align; size is the rule’s total height, so a size above 1 is a height of size minus 2 pixels (the two 1px borders count towards it) and a size of 1 or less drops the bottom border and leaves a single 1px line; and color or noshade fills the rule’s content box in its own colour, so a thick coloured rule is a solid bar rather than two lines with a gap between them. A rule with no explicit height is exactly as tall as its own borders and padding, per §10.6.3’s used content height of 0, so an unpadded rule’s two borders meet with nothing between them; a declared height adds content between them, and max-height clamps it (§10.7). Whatever that resolves to is also what the next block is placed against and what a container holding only the rule takes as its own. Its width follows the ordinary block rules: a percentage resolves against the content width of its containing block — the content edge of the nearest block container ancestor, per §10.1 and §10.2 — with the rule’s own margins, borders and padding outside that basis, and auto fills what is left of that width once they are taken out (§10.3.3). So an unstyled rule’s border box spans its container exactly, and a declared width — percentage or length, with or without box-sizing: border-box — measures the same on a rule as on an equivalent empty <div>. min-width and max-width clamp it like any block (§10.4), and an auto-width rule shrinks to fit rather than filling when it is a float, an absolutely positioned box, a flex item or an inline-block. A percentage or calc() height against a containing block with no definite height of its own computes to auto, as in a browser, whichever form the percentage is written in. A rule with no border, padding or height is 0 tall, like an empty <div>, and paints nothing. |
li |
li | Full support |
menu |
menu | Deprecated element; rendered as an unordered list |
ol |
ol | Full support |
p |
p | Full support |
pre |
pre | Full support |
ul |
ul | Full support |
Headings
| Element | MDN Reference | Notes |
|---|---|---|
h1 |
h1 | Full support |
h2 |
h2 | Full support |
h3 |
h3 | Full support |
h4 |
h4 | Full support |
h5 |
h5 | Full support |
h6 |
h6 | Full support |
Inline Text
| Element | MDN Reference | Notes |
|---|---|---|
a |
a | href links are embedded as clickable PDF hyperlinks, regardless of whether the <a> also carries an id/name (both a link source and a fragment target, e.g. <a id="toc-1" href="#ch1">, is a common and fully-supported pattern). Anchor links (href="#id") for in-document navigation are also supported. Any element (not just <a>) with an id or name attribute can serve as a fragment-link target |
b |
b | Full support |
bdi |
bdi | Full support — isolates its content from the surrounding paragraph’s Unicode Bidi Algorithm resolution (unicode-bidi: isolate, per the UA stylesheet), and when it has no dir attribute of its own, its base direction is auto-detected from its content’s first strong character, same as an explicit dir="auto" |
bdo |
bdo | Full support |
big |
big | Deprecated element; rendered with a larger font size |
br |
br | Full support. A forced break ends the line it falls on, so one with nothing after it in its block leaves no line behind it: x<br> is one line tall, and so is x<br> followed by a block-level sibling. Only the last break in a run is taken back this way — x<br><br> is two lines and x<br><br>c is three |
cite |
cite | Rendered as italic |
code |
code | Rendered in a monospace font |
del |
del | Rendered with strikethrough |
em |
em | Rendered as italic |
i |
i | Rendered as italic |
ins |
ins | Rendered with underline |
kbd |
kbd | Rendered in a monospace font |
nobr |
nobr | Obsolete, non-standard element; not supported. Use a standard element with white-space: nowrap instead. For well-formed legacy input that cannot be changed, an opt-in stylesheet can reproduce its non-wrapping layout, but malformed/nested parser recovery remains unsupported; see Applying compatibility styles to legacy HTML |
s |
s | Rendered with strikethrough |
samp |
samp | Rendered in a monospace font |
small |
small | Rendered with a smaller font size |
span |
span | Full support |
strike |
strike | Deprecated element; rendered with strikethrough |
strong |
strong | Rendered as bold |
sub |
sub | Full support |
sup |
sup | Full support |
tt |
tt | Deprecated element; rendered in a monospace font |
u |
u | Rendered with underline |
var |
var | Rendered as italic |
Tables
| Element | MDN Reference | Notes |
|---|---|---|
table |
table | Full support. The legacy attributes follow the HTML Standard’s rendering rules: align places the table itself (left and right float it, center gives it auto side margins) and does not change the text alignment of its cells, and cellpadding and border are presentational hints of the cells, so a padding or border the author declares on a cell (inline or in a stylesheet) wins over them, the way an HTML email’s <table cellpadding="0"> with padded <td style="padding: …"> cells expects. align matches the exact value, ASCII case-insensitively; on a td, tr, div or p it still sets text-align |
caption |
caption | Full support |
col |
col | Width attribute is applied |
colgroup |
colgroup | Full support |
tbody |
tbody | Full support |
td |
td | colspan and rowspan are fully supported |
tfoot |
tfoot | Full support |
th |
th | colspan and rowspan are fully supported; rendered as bold and centered by default |
thead |
thead | Full support |
tr |
tr | Full support |
Embedded Content
| Element | MDN Reference | Notes |
|---|---|---|
img |
img | Full support; images are loaded via the configured network loader. An image that cannot be loaded (a missing file, a refused request, bytes no decoder reads) and has a non-empty alt is drawn as that text, in the image’s font and colour, clipped to its box: the box is the text’s size unless width/height or CSS size it, as in a browser; one with no alt stays an empty box. data: URIs and local file: URIs (including relative paths, which resolve against the document base — see Usage Examples) are always supported regardless of the configured loader. An SVG source (.svg file, data:image/svg+xml) renders as real vector PDF content — see Supported SVG Features. Raster decode support: JPEG, PNG, BMP, GIF, WebP (VP8/VP8L, including alpha), AVIF (baseline still images, including alpha), and TIFF (uncompressed/LZW/PackBits, 1/2/4/8/16-bit grayscale/RGB/palette/CMYK — decode only, no encode), and JPEG XL (lossy and lossless, alpha, 16-bit/HDR, ICC; an animation renders its first frame; a file made by recompressing a JPEG is embedded as that JPEG, so a CMYK or ICC-tagged one is byte-for-byte, and any other is decoded — decode only); TGA, PSD, and HDR are not supported. A CMYK or YCCK JPEG is embedded preserving its native CMYK data (with its embedded ICC profile carried through when present) rather than converted to RGB, always at its natural pixel size — see Color and ICC profiles. A CMYK TIFF is likewise preserved (decoded natively and embedded as a raw CMYK stream, with its embedded ICC profile carried through when present) rather than converted to RGB. An opaque PNG (no real per-pixel alpha channel, not interlaced) embeds losslessly and byte-for-byte via its own compressed data rather than being re-encoded as a lossy JPEG — including a tRNS chroma-key PNG, via a PDF color-key Mask array — and an opaque BMP/GIF embeds via a raw lossless stream at its own natural display size — see Lossless embedding for opaque PNG/BMP/GIF images for the ImageCompression option controlling this Responsive images: srcset (x and w descriptors) with sizes, and <picture>/<source> selection by media and type, are honored — see the picture row. A PDF has no resizable viewport, so for paged output the candidate with the highest pixel density wins (best print quality), where 100vw in sizes is the page content width; the chosen image’s natural size is divided by its density, so which candidate loads never changes the size of the box. A MediaType.Screen document instead takes the smallest candidate that is sharp enough for the display: the device pixel ratio its host reports times its zoom, with 10% of leeway. sizes="auto" is honored on an image with loading="lazy" (the used width is its definite width, then the width it was laid out at); without loading="lazy" it is ignored, as the HTML Standard says. A candidate that cannot be loaded is replaced by the next best (up to three), then by src. |
picture, source |
picture | <picture> is an inline wrapper; its <source> children (void elements, display: none) are consulted in order before the <img>: the first whose media query matches the page, whose type is a decodable image format, and whose srcset yields a candidate supplies the image (sizes and the x/w descriptors work as on img); otherwise the <img>’s own srcset/src applies. <source> inside <video>/<audio> is parsed as void but is not used for media selection. |
object |
object | Implements the HTML “replacement algorithm” for a static renderer: if data resolves to a supported image (including through the type attribute or a data: URI’s own MIME header — checked without a network fetch when the declared type is already known not to be an image), the element renders exactly like img. Otherwise it falls back to its DOM children, which may themselves be nested object/img elements and are resolved the same way recursively — matching how browsers fall through a chain of nested <object> fallbacks |
embed |
embed | A void element, rendered as an atomic inline replaced box. If src resolves to a supported image or SVG (a type attribute or data: URI MIME header that is not image/* is declined without a network fetch), it renders exactly like img, sized by the width/height attributes and honoring object-fit/object-position and transform. Any other resource (a plugin type, no src) is not rendered, but the element still reserves its width x height box |
iframe |
iframe | Rendered as a placeholder box with a gray border. For YouTube and Vimeo embed URLs, a video thumbnail image is displayed |
video |
video | Video playback isn’t possible in a static PDF, so the element renders its poster image (the frame browsers show before playback) as a replaced element — exactly like img, honoring object-fit/object-position. With no poster (or if it fails to load), the element falls back to being an ordinary container laying out any fallback DOM content |
svg |
svg | Inline SVG renders as real vector PDF content, not a rasterized bitmap — see Supported SVG Features for the full SVG compatibility matrix |
math |
math | Inline MathML renders as real vector PDF content (glyphs, fraction bars, radicals, stretchy operators), not a rasterized bitmap — see Supported MathML Features for the full MathML compatibility matrix. Tagged as a Formula structure element by default when tagged PDF output is enabled, with the original MathML source attached as a PDF 2.0 Associated File — see MathML Associated Files |
Forms
Form elements are rendered as static boxes by default. There is no interactive behavior unless interactive PDF forms output is explicitly enabled — inputs cannot be focused or edited, and forms cannot be submitted.
| Element | MDN Reference | Notes |
|---|---|---|
button |
button | Rendered as a static inline-block box; never becomes an interactive AcroForm field, even with interactive PDF forms enabled |
input |
input | Rendered as a static inline-block box with a browser-like default size. With interactive PDF forms enabled, becomes a real fillable text, checkbox, or radio field depending on its type |
select |
select | Rendered as a static inline-block box. With interactive PDF forms enabled, becomes a real fillable combo-box field populated from its option children |
textarea |
textarea | A multi-line text control: its text is its value, not flowed content, so it is drawn by the control - broken into lines for its width, clipped to the box, with the placeholder when empty - and sized from cols (20) and rows (2) unless CSS gives a size. Its default look is a bordered white box in a monospace font. With interactive PDF forms enabled, becomes a real fillable multi-line text field |
Scripting
| Element | MDN Reference | Notes |
|---|---|---|
script |
script | Completely ignored; JavaScript is not executed |
noscript |
noscript | Content is always rendered, because JavaScript is never executed |
Legacy Frames
| Element | MDN Reference | Notes |
|---|---|---|
frame |
frame | Deprecated element; no frame content is loaded |
frameset |
frameset | Deprecated element; rendered as a block |
noframes |
noframes | Deprecated element; hidden (display: none), as the HTML Standard’s rendering rules specify |
Malformed markup
The HTML Standard’s tree-construction
error-recovery rules change the element tree a stylesheet then matches against — most visibly
through the sibling combinators + and ~, and through :first-child/:nth-child(). The body of a
document follows them for formatting elements, paragraphs, lists, headings and buttons; tables, <select>,
<template> and the <html>/<head>/<body> skeleton follow the standard’s insertion modes.
- Misnested formatting elements (
a,b,big,code,em,font,i,nobr,s,small,strike,strong,tt,u) follow the adoption agency algorithm:<b>1<p>2</b>3</p>is a bold1, then a paragraph with a bold2and an unbold3. A formatting element that is still open when its container ends is reopened in the content that follows —<p><b>a</p>bis bold in both places — up to three identical copies at once. - A start tag closes the paragraph it cannot sit in, wherever that paragraph is open in
button scope:
<p><span><div>ends the<p>, so the<div>is its sibling. - An end tag only closes an element that is in scope.
</div>inside a table cell cannot reach a<div>outside the table, and a</span>with a<div>open inside the<span>is ignored. - An end tag whose element is not open is ignored, and the insertion point does not move. Content after it stays where it was rather than being reparented.
- Table parts are implied and kept in place.
<table><tr>gets a<tbody>, a<td>outside a row gets a<tr>, a<col>a<colgroup>, sotable > tbody > trmatches; a<td>,<tr>or<caption>outside a table is dropped. Text and elements a table cannot hold (<table>x<tr>…,<table><div>) are moved in front of it (foster parenting). In a<select>, options close each other and anything but options, option groups and text is dropped. - The document skeleton is supplied. The
<html>,<head>and<body>elements the source leaves out exist,<title>,<meta>,<link>,<style>and<script>before the body go into the head, a repeated<html>or<body>only adds its missing attributes, and text or elements after</body>or</html>still go into the body. <template>contents are parsed by their first tag (a<tr>as a row, a<td>as a cell) and are not rendered.<svg>and<math>end at an HTML element that cannot be inside them (<svg><p>ends the<svg>), and SVG element names are given their mixed case (<clippath>isclipPath).- A line feed that starts a
<pre>,<listing>or<textarea>is dropped, and<image>is an<img>. - A second
<form>inside a<form>is ignored, and</form>ends the form without closing what is open inside it. </br>is a<br>.</p>is the exception: when no<p>is open it generates an empty<p>, immediately closed. This is what the spec requires, and it is observable. An end tag written after a construct that already implied it —<p><table>…</table></p>, where<table>closes the paragraph itself — leaves a real, empty paragraph element behind, sop + table + pmatches that element rather than the next authored paragraph.
Not yet the spec’s: a <table> closes an open <p> whether or not the document is in quirks mode (which the
engine does not model), the legacy frameset modes, the mixed-case form of SVG and MathML attribute names, and
the tokenizer, which is MimeKit’s rather than the standard’s state machine.
CSS Properties
Box Model
| Property | MDN Reference | Notes | |
|---|---|---|---|
width |
width | Full support | The stretch keyword (and its legacy spellings -webkit-fill-available and -moz-available) sizes the margin box to fill the containing block, with auto margins counting as zero; where the axis has no definite size it behaves as auto (as 0 for a minimum, none for a maximum). |
height |
height | Full support. When content is taller than an explicit height, the box does not grow to fit it — content overflows past the box’s bottom edge (same behavior as max-height/overflow: hidden elsewhere in this engine). On a <table> or <tr> this instead follows CSS 2.1 §17.5.3: the table’s height becomes the maximum of the specified height and the rows’ natural total, never a clip, and any surplus is distributed proportionally across the rows (a <tr>’s own height is likewise a per-row minimum) |
The stretch keyword (and its legacy spellings -webkit-fill-available and -moz-available) sizes the margin box to fill the containing block, with auto margins counting as zero; where the axis has no definite size it behaves as auto (as 0 for a minimum, none for a maximum). |
max-width |
max-width | Full support, including replaced elements (images), absolutely/fixed positioned boxes, flex items, and table cells (a cell’s max-width caps its column) |
The stretch keyword (and its legacy spellings -webkit-fill-available and -moz-available) sizes the margin box to fill the containing block, with auto margins counting as zero; where the axis has no definite size it behaves as auto (as 0 for a minimum, none for a maximum). |
min-width |
min-width | Full support, including replaced elements (images), absolutely/fixed positioned boxes, flex items, and table cells (a cell’s min-width widens its column) |
The stretch keyword (and its legacy spellings -webkit-fill-available and -moz-available) sizes the margin box to fill the containing block, with auto margins counting as zero; where the axis has no definite size it behaves as auto (as 0 for a minimum, none for a maximum). The initial value is auto, which is 0 for every box but a flex item in a row: there it is the automatic minimum size, so an item does not shrink below its min-content width (an image keeps its natural width, a nowrap item its whole line, text its longest unbreakable run), or below its width when that is smaller. A scroll container (overflow other than visible/clip) and an item with min-width: 0 shrink freely. In a column flex container min-height: auto is the same floor on the main (height) axis: an item of an overflowing column keeps its content height, or its height when that is smaller (a replaced item shrinks as before). A grid item in a bare fr track (1fr is minmax(auto, 1fr)) keeps its min-content width, so the track does not shrink below it; minmax(0, 1fr) opts out. The floor also applies to an item a line grows: with flex: 1 (a zero basis) an item holding content wider or taller than its share keeps its minimum size and the other items share what is left. |
max-height |
max-height | Full support, including replaced elements (images), absolutely/fixed positioned boxes, and flex items. When content is taller than max-height, the box does not grow to fit it — content overflows past the box’s bottom edge (same behavior as overflow: hidden elsewhere in this engine) |
The stretch keyword (and its legacy spellings -webkit-fill-available and -moz-available) sizes the margin box to fill the containing block, with auto margins counting as zero; where the axis has no definite size it behaves as auto (as 0 for a minimum, none for a maximum). |
min-height |
min-height | Full support, including replaced elements (images), absolutely/fixed positioned boxes, and flex items. On a <table> or <tr>, same §17.5.3 minimum-and-redistribute behavior as height above |
The stretch keyword (and its legacy spellings -webkit-fill-available and -moz-available) sizes the margin box to fill the containing block, with auto margins counting as zero; where the axis has no definite size it behaves as auto (as 0 for a minimum, none for a maximum). |
margin |
margin | Shorthand and all four longhands (margin-top, margin-right, margin-bottom, margin-left) are supported. On an in-flow block-level box, auto follows CSS 2.1 §10.3.3: both horizontal margins auto centre the box in its containing block’s content width (whichever box-sizing that block uses), and exactly one auto margin takes all of the remaining space, so margin-left: auto pushes a fixed-width block to the end edge. An in-flow <table> follows the same rules: a single auto horizontal margin takes the remaining space, and a table with no auto margins in a direction: rtl container is anchored to the right-hand inline-start edge (an over-wide one overflows toward the left). |
|
padding |
padding | Shorthand and all four longhands (padding-top, padding-right, padding-bottom, padding-left) are supported |
|
box-sizing |
box-sizing | content-box and border-box are supported. Not inherited, per spec — a common pattern like html { box-sizing: border-box } needs *, *::before, *::after { box-sizing: inherit } to propagate it, the same as in browsers. The legacy -webkit-/-moz- spelling is accepted as the same property. |
|
aspect-ratio |
aspect-ratio | Supported in both directions (aspect-ratio: <number> [ / <number> ]?, auto, or auto <ratio>). A box with a definite width and an auto height takes its height from the width via the ratio; a definite height with an auto width derives the width from the height, where the width would otherwise be shrink-to-fit (an absolutely-positioned auto-width box, and replaced elements) — a normal-flow block’s auto width stays stretch-fit, which wins over the ratio, matching browsers. The ratio applies to the box-sizing box, and a ratio-derived height is definite, so a percentage-height descendant resolves against it (this is what makes a pure-CSS chart’s bars take their height from a ratio-sized container); an indefinite percentage height is treated as automatic, so the ratio sizes it too. The ratio-derived dimension is clamped by min-*/max-*. A definite length in the other axis overrides the ratio. On replaced elements (images, <svg>, <object>, <video> poster): a bare <ratio> overrides the element’s natural ratio, auto <ratio> prefers the natural ratio and falls back to the specified one, and auto/no value uses the natural ratio. Not yet applied: the CSS Sizing §5.1 transferred automatic-minimum-size for flex/grid items (the min-content-through-the-ratio min size), and height→width on non-replaced inline-block/float boxes. |
|
object-fit |
object-fit | Supported on every replaced element that renders content — <img> (raster and SVG source), <object> resolving to an image, <video> (its poster image, which PeachPDF renders since it can’t play video), and inline <svg>: fill (default — stretch to the content box), contain (scale to fit, preserving aspect ratio, letterboxing the remainder), cover (scale to cover, preserving aspect ratio, cropping the overflow to the content box), none (the content’s intrinsic size), and scale-down (the smaller of none and contain). |
|
object-position |
object-position | Supported on the same replaced elements as object-fit, sharing the background-position grammar (keywords, lengths, percentages, and the edge-offset form). Positions the object-fit-sized content within the content box (and selects which part is visible when cover/none overflows). Defaults to 50% 50% (center). |
|
image-orientation |
image-orientation | Inherited. from-image (the default) draws an image upright according to the orientation stored in the file (JPEG and TIFF Exif, PNG eXIf, WebP EXIF, AVIF irot/imir, the JPEG XL header), as browsers do, and an image’s intrinsic size is its upright size (a quarter turn swaps width and height). none draws the stored raster and ignores the tag. <angle> \|\| flip ignores the tag and applies that rotation (rounded to the nearest quarter turn) and an optional horizontal mirror, applied after the rotation. Applies to raster images in <img>, <object>, <video poster>, background-image, list-style-image, content: url() and border-image, not to SVG or gradients. The embedded image bytes are not re-encoded; the turn is a transform in the page content. calc() angles are not accepted. |
|
image-resolution |
image-resolution | [ from-image || <resolution> ] && snap?, inherited, 1dppx by default: how many pixels of a raster make a CSS pixel, which sets the natural size of an <img>, <object>, <video poster>, background-image, list-style-image and content: url() (it does not scale an image given an explicit width and height, and not the slices of a border-image, which count image pixels). By default every raster is 96 dpi, as in browsers. from-image uses the density the file records (JPEG JFIF or EXIF, PNG pHYs, TIFF XResolution, BMP, JPEG 2000 and, through EXIF, WebP, AVIF and JPEG XL), taking the horizontal and vertical density separately, and falls back to the <resolution> given beside it, else 1dppx. A <resolution> (300dpi, 118dpcm, 3dppx, 3x) alone ignores the file. snap rounds the resolution so one image pixel covers a whole number of device pixels (300dpi is 3dppx, a 72 dpi image 1dppx). Not applied to SVG. The embedded image bytes are unchanged. |
|
image-rendering |
image-rendering | Inherited. Chooses how a raster image is resampled when drawn at a size other than its own, on <img> (and other replaced images), background-image and border-image: auto (the default: smooth, except that a repeating background tile and a border-image are drawn with hard edges so neighbouring tiles meet without a seam), smooth (bilinear), high-quality (a sharper bicubic curve on the raster backend; a smooth resample in a PDF), crisp-edges (hard-edged pixels) and pixelated (hard-edged pixels when enlarging, filtered when shrinking). In a PDF the choice is the image’s smoothing flag, which a viewer may or may not honour. An explicit value overrides the defaults above. |
Logical box-model properties
The CSS logical box-model properties are supported and resolve to their physical equivalents, per each box’s own resolved direction and writing-mode — following the CSS Writing Modes Level 4 §7.1 abstract-to-physical mapping table. Under the default writing-mode: horizontal-tb / direction: ltr, block-start/-end are top/bottom and inline-start/-end are left/right; a vertical writing-mode rotates block-start/-end onto the left or right edge instead, and direction: rtl flips which physical edge is inline-start/-end within whichever axis is inline for that writing mode (sideways-lr reverses the inline mapping relative to vertical-rl/vertical-lr/sideways-rl).
| Logical property | Resolves to | Notes |
|---|---|---|
margin-block / margin-inline |
the two physical block-edge / inline-edge margins | 1–2 values (1 applies to both edges) |
margin-block-start / -end, margin-inline-start / -end |
one physical margin edge | single edge; WebKit’s older -webkit-margin-start / -webkit-margin-end are accepted as margin-inline-start / -end |
padding-block / padding-inline (+ -start/-end longhands) |
the physical padding-* edges |
same 1–2-value / single-edge model as margin; -webkit-padding-start / -webkit-padding-end are accepted as padding-inline-start / -end |
inset |
top+right+bottom+left |
1–4 values (the standard box-edge shorthand — always physical, not writing-mode-dependent, per spec) |
inset-block / inset-inline (+ -start/-end longhands) |
the physical top/right/bottom/left edges |
1–2-value / single-edge |
border-block-start / -end, border-inline-start / -end |
one physical border edge shorthand | <width> <style> <color> |
border-block / border-inline |
both block / both inline edges | one <width> <style> <color> applied to both edges |
border-block-width / -style / -color (and border-inline-*) |
the two physical edge width/style/color longhands | 1–2 values |
border-block-start-width / -style / -color (all four edges) |
the physical per-edge border-*-width/-style/-color longhands |
single edge |
Each logical longhand keeps its own identity through the cascade and is resolved to a physical edge once the box’s own direction/writing-mode are known, rather than being aliased directly onto a physical longhand at parse time. When a declaration block is serialized back to CSS, the logical longhands are used as declared — a logical shorthand (e.g. margin-block) is never reconstructed from them.
Borders
| Property | MDN Reference | Notes |
|---|---|---|
border |
border | Shorthand supported; also border-top, border-right, border-bottom, border-left |
border-width |
border-width | Shorthand and all four longhands supported |
border-style |
border-style | Shorthand and all four longhands supported; values: none, hidden, solid, dashed, dotted, double, inset, outset, groove, ridge. hidden behaves exactly like none — the used border-width of that side is 0, so the edge is neither painted nor given any space in the box model — except inside a border-collapse: collapse table, where it also suppresses the shared grid line outright and beats every competing declaration (CSS 2.1 §17.6.2, clause 1 — see the border-collapse row below). See How each border style is drawn below |
border-color |
border-color | Shorthand and all four longhands supported |
border-collapse |
border-collapse | collapse resolves borders per CSS 2.1 §17.6.2 (width, then style, then cell/row/row-group/column/column-group/table origin, then position), including on a repeated <thead>/<tfoot> across pages. The table’s own border box contains each outermost grid line whole rather than the half §17.6.2 describes, and where two grid lines cross, one of them paints the whole square they share — both matching browsers; see How each border style is drawn. border-radius is undefined by the spec on a collapsed table and is not drawn there, matching common browser behavior |
border-spacing |
border-spacing | Full support for tables |
Borders and backgrounds are painted on the box’s exact fractional position by default. PdfGenerateConfig.SnapBoxDecorationsToCssPixels opts in to rounding each edge of the box’s decorations to a whole CSS pixel, as a browser does, so a 1px border covers one device pixel at 100% zoom. Border widths are used as written by default (a 0.5px border is drawn 0.5px wide, where a browser draws 1px). PdfGenerateConfig.SnapBorderWidthsToCssPixels opts in to the snapping CSS Values 4 describes, rounded on the CSS pixel since a PDF has no device pixel.
How each border style is drawn
CSS 2.1 §8.5.3 defines what each keyword means but leaves the exact rendering to the renderer. PeachPDF draws them to match what a browser produces, so a document proofed in a browser prints the same way:
dottedpaints round dots whose diameter is the border width, anddashedpaints dashes twice the border width. In both cases the ideal gap is one border width, but the gap is then stretched or squeezed so that a whole number of dots/dashes spans the edge exactly — the pattern always begins and ends flush with the corner rather than being cut off part-way through. Because the fit depends on the edge, the spacing changes slightly as a box’s width or height changes, and an edge’s horizontal and vertical runs may use slightly different gaps. An edge too short to hold two dashes is drawn solid. For square-cornered outlines and borders with matching patterned sides, translucent dots and dashes are composited once across adjoining edges, so their corners have the same opacity as the straight runs.doublepaints two lines with a gap between them, each exactly one third of the border width.grooveandridgeeach paint two halves of the border:groovedraws its outer half asinsetand its inner half asoutset, andridgedoes the reverse.insetandoutsetshade one pair of sides darker and the other lighter.insetdarkens the top and left and lightens the bottom and right;outsetis the mirror image. Both ends of the lightness range are special-cased so the bevel never disappears into itself: a color too dark to darken visibly lightens both faces instead, by differing amounts, while a color too light to lighten visibly keeps the declared color on the lit pair rather than being clipped to white.
On a border-collapse: collapse
table inset and outset are drawn from the same two faces but pick between them differently,
because a collapsed grid line is not any one box’s edge: it straddles the boundary shared by whatever
lies on either side of it, so it shows both faces at once, one per half. The half at the smaller
coordinate (the left half of a column line, the upper half of a row line) takes the face a bottom or
right edge would, and the half at the larger coordinate the face a top or left edge would — on every
line alike, interior or outermost, whichever cell’s, row’s or table’s declaration won it. A collapsed
inset therefore draws the same two bands as a ridge, and a collapsed outset the same as a
groove, which is what a browser paints for the same table. groove and ridge themselves are
unchanged by this: they already draw two halves, and the rule above happens to pick the same two
faces in the same order a box edge does, so they look identical either side of border-collapse. A page box’s or page-margin box’s border
has four real sides and so follows the per-side rule above instead, and mitres at its corners the
same way an element’s does.
Where two collapsed grid lines cross, the square they share is painted whole by one of them, and never
split along a diagonal. The wider line takes it; at equal width, the one whose style ranks higher
in §17.6.2’s order
(hidden, double, solid, dashed, dotted, ridge, outset, groove, inset); and failing
both, the row line, except on the table’s first row line, where the column line takes it unless that
column line is the one at the table’s inline start (its leftmost, in a left-to-right table). The four
corners of the table are crossings like any other and are filled accordingly, rather than left blank.
This is what a browser paints for the same table; CSS 2.1 resolves a border per grid-line segment and
says nothing about the square two crossing segments share. The one case where a square is still shared
is a crossing where a line’s own resolved border changes from one side of it to the other — the two
runs meeting there split it between them rather than one taking it whole.
A collapsed table’s own border box contains each outermost grid line whole. §17.6.2 instead puts
only half of it inside (“the width of the table includes half the table border”), which paints the
other half outside the table — over whatever precedes it, or clipped away entirely at a page or
container edge, so an outer line comes out thinner than an interior one. PeachPDF follows browsers
here, so no part of a collapsed border is ever painted outside the table it belongs to: a 2×2 table of
60×40px cells whose every border is 20px occupies exactly 180×140px — 20 + 60 + 20 + 60 + 20 across
— rather than the 160×120px §17.6.2’s half-line box would give it.
A bevelled border side whose color is currentcolor
— which is border-color’s initial value, so this covers every border: 2px inset that names no
color — does not shade the element’s own color.
It shades a fixed light grey, rgb(238, 238, 238), whose two faces are #9a9a9a and #eeeeee. This
matches Chromium, and it is what makes an unstyled <hr> and a bare border: 2px inset look the way
they do in a browser rather than shading black text into a black-on-black frame. Consequences worth
knowing: color has no effect at all on such a border, a translucent or transparent color does not
make it translucent, and naming the color explicitly (border: 2px inset #808080) opts back into
shading that color — a different result (#2c2c2c over #d4d4d4) from the same grey left implicit.
Elements with a table display type
(table, inline-table, table-row, table-cell, and the rest) are exempt and do shade their own
currentcolor. outline-color and column-rule-color are never substituted this way, even when their
own style is bevelled — only the four border-*-color longhands are.
Because currentcolor is resolved per element rather than once during the cascade, a
border-color: inherit inherits the keyword itself, not the parent’s resolved colour — so the child
resolves it against its own color, and against its own bevel base if the child’s border style is
bevelled. A child with no color of its own inherits one, so it usually matches its parent anyway; a
child that sets color does not.
Every style mitres into its neighbours at the corners, including each individual line of a double
border and each half of a groove/ridge, so adjacent edges of differing width or color meet on the
corner’s diagonal — and the mitre follows the line from the border box’s outer corner to its inner
corner, so it stays correct when the two sides differ in width and the cut is not 45°. This is what
makes the classic zero-size “border triangle” work. outline-style uses all the same rendering,
banded outward from the border edge — an outline is drawn exactly as a border of the same width would
be on the rectangle that outline-offset + outline-width inflates the border box to, so a
dotted/dashed outline fits its pattern to that outer rectangle’s sides and a dot lands in each of
its corners. A large negative outline-offset shrinks the outline into the border box, but its outside
shape remains at least twice the outline width in each dimension so the outline stays visible.
With border-radius, a border whose four sides share a style, color and width follows the curve as
one continuous outline, which keeps the corners seamless and lets a dotted/dashed pattern be
fitted to the whole perimeter so it runs evenly all the way round. double is drawn as two such
outlines at its thirds; groove and ridge are drawn as two curved half-width bands, with their
per-side bevel colors meeting through each corner.
Non-uniform rounded borders share the same curved corner-transition geometry across every style.
The transition follows the ratio of the adjoining widths, as allowed by CSS Backgrounds and
Borders §4.4: solid and shaded sides
fill their part of the curve, each double line and groove/ridge half keeps its own band, and a
dotted/dashed centerline is clipped to its side’s part of the corner. On an unsliced box, every
patterned side is phased against the complete rounded centerline before clipping, like a browser. This
means a small size change can move a dot or dash across a corner transition: a dot may visually join
the adjacent side at one size and sit wholly within its own side at another. Sliced fragments instead
fit each remaining open side independently and leave a square end where a physical edge is omitted
rather than closing a false rounded corner.
Border Radius
| Property | MDN Reference | Notes |
|---|---|---|
border-radius |
border-radius | Shorthand; supports 1–4 values with optional / for elliptical radii (e.g. 10px / 20px) The legacy -webkit-/-moz- spelling is accepted as the same property. |
border-top-left-radius |
border-top-left-radius | Accepts <length> or <percentage>; optional second value sets the vertical radius independently The legacy -webkit-/-moz- spelling is accepted as the same property. |
border-top-right-radius |
border-top-right-radius | Same as above The legacy -webkit-/-moz- spelling is accepted as the same property. |
border-bottom-right-radius |
border-bottom-right-radius | Same as above The legacy -webkit-/-moz- spelling is accepted as the same property. |
border-bottom-left-radius |
border-bottom-left-radius | Same as above The legacy -webkit-/-moz- spelling is accepted as the same property. |
Percentages are relative to the border-box width (horizontal radius) and height (vertical radius). Overlapping adjacent radii are automatically reduced proportionally per the CSS spec.
Border Image
| Property | MDN Reference | Notes |
|---|---|---|
border-image |
border-image | Shorthand for the five longhands below The legacy -webkit-/-moz- spelling is accepted as the same property. |
border-image-source |
border-image-source | none, or any <image> — a url() (raster or SVG), or a linear-gradient()/radial-gradient()/conic-gradient() (including their repeating- forms) |
border-image-slice |
border-image-slice | 1–4 <number>/<percentage> values plus the optional fill keyword, in any order |
border-image-width |
border-image-width | 1–4 values, each <length>, <percentage>, <number>, or auto |
border-image-outset |
border-image-outset | 1–4 values, each <length> or <number> |
border-image-repeat |
border-image-repeat | 1–2 keywords: stretch (default), repeat, round, space |
The source image is sliced into nine regions per the standard algorithm: the four corners are placed unchanged (scaled independently on each axis to fit border-image-width, but never tiled); the four edges are stretched or tiled along their one free axis per border-image-repeat; the center is painted, tiled the same way on both axes, only when fill is declared. An SVG source is sliced at its own intrinsic size — its width/height, or the viewBox they default to — so the artwork keeps its own proportions, exactly as <img src="frame.svg"> would draw it. A gradient, or an SVG that declares no size at all, has no intrinsic size to slice against and is rendered at the size of the border-image area itself (the border box, extended by border-image-outset) instead; border-image-slice percentages on such a source are therefore relative to that area. On any vector or generated source, a bare <number> in border-image-slice counts CSS pixels of that source, not PDF points. Repainting the same SVG source at the same size reuses one document-local Form XObject, including across pages. Once border-image-source resolves to a real, loaded image, it replaces border-style’s own stroke entirely for that box; an unset or failed-to-load source falls back to painting the ordinary border as if border-image were never declared.
Known limitations:
- Slice regions are sampled without smoothing (nearest-neighbor). A region is cut out of the source with a clip, and a smoothing sampler bleeds the neighboring region’s pixels across that cut — at the scale factors a small texture stretched over a border reaches, a visible fraction of the whole frame. A raster source stretched far past its own resolution therefore shows its pixels rather than a smooth ramp; a vector source is unaffected.
border-image-outsetaccepts a<percentage>component (resolved against the same side’s own border width) — broader than the specification’s<length> | <number>grammar, a pre-existing behavior at the parsing layer.
Outline
| Property | MDN Reference | Notes |
|---|---|---|
outline |
outline | Shorthand for outline-color, outline-style, outline-width, in any order |
outline-color |
outline-color | Any <color>, currentcolor (the initial value), or the legacy invert keyword |
outline-style |
outline-style | none (initial), auto, solid, dashed, dotted, double, groove, ridge, inset, outset, each drawn as described under How each border style is drawn. hidden is not an outline-style value — CSS Basic User Interface 4 §3.3 excludes it, unlike every border-*-style — so a declaration using it is invalid and dropped, leaving whatever the cascade already resolved. Use none to suppress an outline |
outline-width |
outline-width | <length>, or thin/medium/thick. Ignored when outline-style is auto — see below |
outline-offset |
outline-offset | <length>, may be negative to pull the outline back over the border |
Unlike border, outline is layout-neutral: it never participates in box sizing, is drawn entirely outside the border edge (offset by outline-offset), and can visually overlap sibling content without shifting anything. It is also drawn on top of that sibling content, following CSS Positioned Layout 4’s painting order. An outline paints over the in-flow content, floats, and z-index: auto/0 positioned boxes of its stacking context. It paints under that stacking context’s positive-z-index content, so a raised overlay covers an outline beneath it, as in Chromium. Browsers differ here: Firefox draws outlines over positive-z-index content as well. There is one deliberate difference from Chromium: Chromium also draws outlines under z-index: auto/0 positioned boxes, which PeachPDF paints under the outline, so a following position: relative sibling cannot cover a ring. Replaced elements (<img>, inline <svg>, <object>, <video>, <iframe>, <math> and form controls) take an outline like any other box, drawn around the border box after the element’s own content. A positioned box, float, inline-block, or flex or grid item paints as one unit: it draws the outlines inside it before any later content paints. With border-radius, the outline follows the curve too: each corner radius expands outward by the same outline-offset + outline-width its rectangle itself expands by, matching Chromium’s rounded-outline geometry — CSS Basic User Interface 4 leaves the exact treatment as a UA choice, but rounding to match is the one most browsers make.
When an inline element wraps across multiple lines, its outline is drawn as one connected shape around all of them rather than leaving the two line-wrap edges open the way border/background do under box-decoration-break: slice (the default) — CSS Basic User Interface 4 recommends a fragmented outline be fully connected rather than open on some sides. The shape is the outline of the region the lines’ rectangles cover once outline-offset has been applied to each of them: where consecutive lines touch or overlap — which is what a border, extra padding, or a positive outline-offset on a wrapped inline typically causes — they merge into a single stepped contour, with the outline running around its concave corners rather than closing across each wrap. Lines that don’t touch stay separate pieces of the same shape, which is how an ordinary widely-spaced wrapped inline still reads as one ring per line. This matches Chromium, which builds the same region the same way.
Every outline style follows that shape, including the patterned and bevelled ones. On a square-cornered contour, dotted and dashed fit their pattern along each straight edge, so a dash lands on every corner it turns, including the concave ones a merge introduces; a rounded one has no corner for a dash to land on, so it is fitted to the closed contour as a whole, which spaces the pattern evenly all the way round rather than restarting it at each corner. groove, ridge, inset and outset pick each edge’s light or dark face from the direction that edge runs in rather than from which side of a box it is — the only rule that is still well defined once the lines have merged and a “side” no longer is. Both match what Chromium does for the same content.
For translucent square-cornered dotted and dashed outlines, each corner dot or dash is painted once, so the corners have the same opacity as the straight runs. The same applies to borders with matching patterned sides.
The border boxes of the non-inline boxes inside an inline (images, inline-blocks, floats) join the region too, so an outline around content taller than its own line box goes around it, as in Chromium. Out-of-flow boxes and list markers do not.
A page or column break is a different axis and is unaffected by any of this: the region is always built from one page’s own rectangles, so a box broken across pages gets its own shape on each, and the block-axis edge the break cuts through stays open on both sides of it, exactly as border’s own slice geometry already leaves it.
outline-style: auto is a UA-defined appearance, typically used for a browser’s own focus ring. CSS Basic User Interface 4 says the outline-width property is ignored when outline-style is auto, and separately allows a UA to treat auto as solid — the second is a choice about the style, not a licence to keep the author’s width. PeachPDF therefore draws auto as a solid ring at its own fixed width of 2px, whatever outline-width says (including 0), matching the ring Chrome draws. That ring is also centred on the edge outline-offset puts it at, rather than sitting wholly outside it the way every other style does, so with the default zero offset it straddles the border edge by 1px either side — again matching Chrome. If you want an outline of a width you control, use any other outline-style.
outline-color: invert is a legacy value: rather than approximating it with a fixed color, PeachPDF renders it as a true per-pixel color inversion of whatever is underneath the outline, using a PDF blend mode (/Difference composited with white, which is mathematically equivalent to inverting the backdrop). This is a genuine inversion, not a fixed guess at a contrasting color, and it composites correctly regardless of what’s behind the outline at render time.
Box Shadow
| Property | MDN Reference | Notes |
|---|---|---|
box-shadow |
box-shadow | Comma-separated list of shadow layers, each inset? <offset-x> <offset-y> <blur-radius>? <spread-radius>? <color>?. Supports outset (drop) and inset shadows, negative offsets, negative spread, multiple layers, and an omitted color (which uses the element’s own color, i.e. currentColor). Offsets, blur and spread may use any <length> unit (including em/rem) or a calc()/min()/max()/clamp() expression; a value with an unrecognised unit (2foo) makes the declaration invalid, and a percentage is not a valid length here. The first-listed layer paints on top. Honors border-radius. A blurred shadow is a real Gaussian blur — the blur radius is twice the standard deviation, as the specification defines it — rendered into a bitmap beneath the element, which itself stays vector; see Rasterized effects. An outset shadow is knocked out under the element’s own border box (following its rounded corners), so it never shows through a transparent element, and a rounded inset shadow is confined to the padding edge, whose corner radius is the border-radius minus the border width, with its lit inner edge keeping rounded corners too. A shadow with no blur is plain vector geometry. Where the output cannot carry a bitmap, the blur falls back to a stack of concentric fills that approximates it. The legacy -webkit-/-moz- spelling is accepted as the same property. |
The first two lengths are the shadow’s horizontal and vertical offset; the optional third is the blur radius (must be non-negative) and the optional fourth is the spread radius. Multiple shadows are layered, the first-listed drawn last (on top). An outset shadow paints behind the element’s background; an inset shadow paints over the background, clipped to the padding box and fading inward.
Blur rendering: PDF has no blur, so a blurred shadow is drawn into a bitmap at PdfGenerateConfig.RasterizationDpi, Gaussian-blurred, and placed beneath the element. Only the shadow is a bitmap — the element’s own background, border and text stay vector, selectable and tagged — and a shadow is knocked out under the box it belongs to. Under PDF/A-1 or PDF/X-1a/X-3, which forbid the soft mask a blurred bitmap needs, a document using a blurred shadow is rejected, as any semi-transparent shadow always was.
Transforms
| Property | MDN Reference | Notes |
|---|---|---|
transform |
transform | Supports matrix(), matrix3d(), translate()/translateX()/translateY()/translateZ()/translate3d(), scale()/scaleX()/scaleY()/scaleZ()/scale3d(), rotate()/rotateX()/rotateY()/rotateZ()/rotate3d(), and skew()/skewX()/skewY() (their angle arguments accept a unitless 0, e.g. rotate(0); skew()’s second argument is optional). The translation entries of matrix()/matrix3d() are CSS pixels, like translate()’s, and the perspective entries of matrix3d() (the 4th, 8th and 12th values) are per CSS pixel, so matrix3d(…, 0,0,-1/300,1) is perspective(300px). Multiple functions may be chained in one value; they compose per spec (the last-listed function is applied first, closest to the element). Not inherited. An element whose transform leaves it no visible area - a matrix that cannot be inverted (scale(0), an all-zero matrix(), or a 3D transform whose 4x4 matrix is singular such as scaleZ(0) or a matrix3d() with a zero z row), or a plane turned edge-on (rotateX(90deg), also under a perspective when the viewer’s eye is level with it, or inside a preserve-3d context) so that it is under about 0.017 px thick - is not painted, with its descendants (and a transform-style: preserve-3d element that collapses takes its whole 3D context with it); its layout box is unchanged. Nothing clickable is left where nothing is drawn: the link annotations and form fields inside such an element are not written (its bookmarks are kept, since they name a place in the document). A plane that is merely close to edge-on (rotateX(89.9deg)) still shows as a faint line, as in browsers. A link annotation or form widget over an element that is drawn transformed sits where the element is drawn: its /Rect is the bounding box of the drawn shape; a link also carries that shape as /QuadPoints (in a file that declares PDF 1.6 or later) when it is not an axis-aligned rectangle, so a rotated or skewed link does not answer clicks in the empty corners of its box; and a widget’s appearance is turned and scaled with the element for any affine transform (rotation, scale, skew), while under perspective or in a preserve-3d context it is the upright bounding box. A link over an element moved off the page, or wholly behind the viewer, is not written (nothing drawn there to click); a form field is kept, since it still holds a value and belongs to its form: one moved off the page keeps its widget there, and one that was not drawn at all (a perspective or preserve-3d plane wholly outside the clip, or behind the viewer) is an empty rectangle with nothing to see or click. transform applies only to a transformable element - a block-level or atomic inline-level box (inline-block, inline-flex, an <img> or other replaced element), or a table row, row group, cell or caption. A plain non-replaced display: inline box (a <span>, <a>, <em>, including one that wraps a block), and a table column or column group, ignore it, and neither paints transformed nor becomes a stacking context or a containing block for positioned descendants because of it; give the element display: inline-block (or block) to transform it. A floated or absolutely positioned element is blockified, so it is transformable. perspective, transform-style and backface-visibility have the same restriction. perspective() is supported: an element whose transform involves it, or a perspective on its parent, is drawn in perspective (see below). The legacy -webkit-/-moz- spelling is accepted as the same property. |
translate, rotate, scale |
translate | The individual transform properties: translate: <length-percentage> [<length-percentage> <length>?]?, rotate: <angle> or an axis (x, y, z or three numbers) with an angle in either order, and scale: <number>|<percentage> with one to three values. They compose in front of the transform list, about the same transform-origin, in the order translate, rotate, scale, transform (so the list is applied first), and any of them that is not none makes the box transformed for stacking contexts, containing blocks and the other rules above, whatever it works out to. Painted on HTML boxes and animated on their own (a none against a value mixes with the identity, a shorter list is filled in, and two rotations about different axes flip half way). An SVG element ignores them. offset-path and the other motion path properties are not supported. Not inherited. |
perspective |
perspective | none or a <length>: how far the viewer is from the plane, for this element’s direct children (as the perspective() function does for the one element it is in). A child’s 3D transform is then viewed through it, from the point perspective-origin names. A deeper descendant sees it too when the child is a transform-style: preserve-3d box that keeps it in the same 3D space. Not inherited. Applies to transformable elements only (see transform). See 3D transforms and perspective. The legacy -webkit-/-moz- spelling is accepted as the same property. |
perspective-origin |
perspective-origin | The vanishing point, a <position> (one or two <length>/<percentage>/keyword values) relative to the element’s border box; 50% 50% by default. The legacy -webkit-/-moz- spelling is accepted as the same property. |
backface-visibility |
backface-visibility | visible (default) or hidden. A hidden element is not painted while its back faces the viewer, as decided by the transformed normal: rotateY(180deg) shows the back, scaleX(-1) (a mirror in the plane) does not. Applies to transformable elements only (see transform). The legacy -webkit-/-moz- spelling is accepted as the same property. |
transform-style |
transform-style | flat (default) or preserve-3d, which puts an element’s children in the same 3D space as the element: their transforms accumulate down the tree, and the planes are depth-sorted and, where they intersect, resolved per pixel (a cube of six faces, a carousel, a flip card with a hidden back). Not inherited. Applies to transformable elements only (see transform). A grouping property (overflow other than visible, opacity below 1, filter, backdrop-filter, clip-path, a mix-blend-mode other than normal) forces the used value back to flat. preserve-3d establishes a stacking context. See 3D transforms and perspective. The legacy -webkit-/-moz- spelling is accepted as the same property. |
transform-origin |
transform-origin | 1–3 values (<length>/<percentage>/keyword for X and Y, plain <length> for Z). X/Y percentages resolve against the border-box. Defaults to 50% 50% 0. Not inherited. The legacy -webkit-/-moz- spelling is accepted as the same property. |
3D transforms and perspective
3D transform functions are composed as a genuine 4×4 matrix and projected onto the element’s own flat plane. Without perspective that projection is affine and mathematically exact — translate3d()/scale3d()/rotateX()/rotateY()/rotate3d()/matrix3d() all render as true, lossless 2D transforms of the flattened element, in vector form.
Perspective is not affine, so a PDF transformation matrix cannot carry it: parallel edges stop being parallel and a receding plane foreshortens. An element whose transform involves perspective(), or that is a direct child of a perspective container and has a 3D transform that moves it out of its plane (rotateX(), rotateY(), rotate3d()), is therefore painted into a bitmap in its own untransformed space and warped through the projective map at PdfGenerateConfig.RasterizationDpi (see Rasterized effects); a plane brought closer than its flat size is rendered with proportionally more pixels so it does not blur. A plane parallel to the view plane (translateZ() alone) is only scaled, and stays vector, and so does a perspective so faint that the warp differs from a plain matrix by less than a quarter of a device pixel over the element (a small card on a distant viewer, say). The part of an element that passes behind the viewer is not drawn. A perspective (or perspective()) distance under one CSS pixel is treated as one pixel; zero is none.
- Text in a warped element stays selectable: it is supplied again as invisible text positioned by the transform’s linearisation at the element’s centre, which is exact there and approximate towards the edges under strong perspective.
- Filters, opacity,
clip-pathand blend modes on a warped element apply to it before the warp, in its own space, as they do without perspective. - Not modelled. A warped element inside another transformed ancestor is warped in its parent’s untransformed space. Under PDF/A-1 and PDF/X-1a/X-3 the warped bitmap has soft edges and is rejected unless the document asks for flattening.
Shared 3D space (transform-style: preserve-3d). An element whose used transform-style is preserve-3d starts a 3D rendering context: it and every descendant reached through a chain of preserve-3d elements are planes in one space. Each plane’s transform is accumulated down from the context’s root (its own transform about its transform-origin, then the perspective its parent gives its children, then its parent’s whole transform), so a child of a rotated parent rotates with it and a perspective set above the context reaches every plane in it, however deep. Whatever is not itself in the chain (an element with transform-style: flat, or one a grouping property forces flat) is flattened into its plane.
- Otherwise the planes are painted into bitmaps and composed into one picture through a per-pixel depth test at
PdfGenerateConfig.RasterizationDpi: a nearer plane covers a farther one whatever the document order, and planes that intersect are resolved exactly where they cross rather than by sorting. Translucent planes blend in back-to-front order of their centres. Each plane honours its ownbackface-visibility. - A context whose planes all stay parallel to the view plane at one depth has nothing to resolve, and is painted exactly as it would be without
preserve-3d, in vector form. So is one whose planes are only tilted (no perspective) and where, over each overlap, one plane is in front of the other throughout (none pierces another): they are drawn back to front, each under its own matrix, with no bitmap. - Text stays selectable, as for a single warped element.
- Approximations. A translucent pixel does not hide what is behind it from a plane painted after it (the planes are ordered back to front by their centres, which is exact unless translucent planes intersect each other).
Opacity
| Property | MDN Reference | Notes |
|---|---|---|
opacity |
opacity | Full support; not inherited (it composites the element and its whole subtree as a group, per spec). Rendered as a genuine, isolated PDF transparency group — the element’s subtree is painted into an offscreen Form XObject and flattened, then that single flattened result is composited onto the page at the given alpha, so overlapping content within the element (e.g. two overlapping semi-transparent children) doesn’t double-darken where it overlaps. The legacy -webkit-/-moz- spelling is accepted as the same property. |
mask-image |
mask-image | Applied to HTML boxes: none, <image> layers (linear/radial/conic gradients and url() images) in a comma list. The element and everything inside it is drawn through the mask as a PDF soft mask (/Alpha for alpha, /Luminosity for luminance), so the box fades or is cut out where the mask is transparent or dark; opacity and mix-blend-mode apply to the masked result. The mask painting area is the border box. -webkit-mask-image is accepted. Not supported: url(#id) referencing an SVG <mask> from an HTML box, mask-border-*, and a mask on an element that also has a filter (the mask is skipped) |
mask-mode, mask-repeat, mask-position, mask-size, mask-origin |
mask-mode | Applied per layer like their background-* counterparts (mask-mode is alpha, luminance or match-source, which is alpha for an image; one mode is used for the whole mask). mask-origin takes border-box, padding-box or content-box |
mask-clip, mask-composite |
mask-clip | Accepted but not applied: layers are clipped to the border box and always composite with add. The mask shorthand is not supported on HTML boxes |
clip-path |
clip-path | Basic shapes are supported: polygon() (with an optional nonzero/evenodd fill rule), inset() (1–4 edge offsets, with an optional round <border-radius>), circle() and ellipse() (with a <length-percentage> or closest-side/farthest-side radius and an optional at <position>), path(), url(#id) (referencing an SVG <clipPath> element), and none. A shape (or a bare <geometry-box> with no shape) is resolved against the element’s border-box by default, or against another box selected by a trailing or leading <geometry-box> keyword (content-box, padding-box, margin-box; fill-box/stroke-box/view-box compute to border-box for a plain HTML element per spec) — circle(50%) padding-box and padding-box circle(50%) are equivalent. A bare <geometry-box> (no shape function at all) clips to that box’s own shape, including the element’s declared border-radius for border-box/padding-box/content-box (reduced by the border/padding width for the latter two, the same as background-clip); margin-box has no CSS-defined outward radius growth and always clips to a sharp rectangle. The result clips the whole element (background, border, content, and descendants) and is transformed together with any transform on the element. A calc() expression is accepted as a polygon()/inset() coordinate or radius (resolved against the reference box at layout time). Invalid values are dropped at parse time. inset(... round <border-radius>) accepts the same 1–4-value (optionally /-split horizontal/vertical) grammar as the border-radius property; an invalid or negative-literal radius invalidates the whole declaration, and radii that would overlap are proportionally reduced per CSS Backgrounds and Borders Level 3 §4, the same algorithm the border-radius property itself uses. Offsets that overconstrain inset()’s width or height (their sum exceeds the reference box’s own extent) are proportionally reduced rather than producing an inverted rectangle. path() takes an optional leading nonzero/evenodd fill rule and a string of SVG path data (the same grammar <path d="..."> uses); its numbers are unitless CSS pixels, with the path’s own (0, 0) origin placed at the resolved reference box’s top-left corner — there is no percentage resolution against the box’s size, unlike the other shapes. Per spec, a path() string that isn’t well-formed SVG 1.1 path data, or that parses to no segments, is itself invalid and drops the whole clip-path declaration, the same as any other invalid value. url(#id) references a <clipPath> element defined anywhere in the document — including inside an <svg> that is never itself rendered, such as the common <svg style="display:none"> defs-only pattern — and honors that <clipPath>’s own clipPathUnits (userSpaceOnUse, the default, with the same unitless-pixel/origin convention as path(); or objectBoundingBox, mapping its 0..1 child geometry onto the resolved reference box); an unresolvable id drops the clip (no clipping applied) rather than the whole declaration. The legacy -webkit-/-moz- spelling is accepted as the same property. |
clip |
clip | Legacy CSS 2.1 rectangular clipping (superseded by clip-path above, kept for compatibility). Per CSS 2.1 §11.1.2, applies only to absolutely/fixed-positioned elements — it has no effect on a statically or relatively positioned box. rect(<top>, <right>, <bottom>, <left>) (comma- or space-separated) and auto are supported; top/bottom are offsets from the top border edge and right/left are offsets from the left border edge — not a width/height pair — and auto on an individual edge means “use the box’s own edge there” (don’t clip that side). Resolved against the element’s border-box, like clip-path. |
Animations
A PDF has no timeline, so in PDF output CSS animations are rendered as a single frame: set PdfGenerateConfig.AnimationProgress (or --animation-progress on the command line) and every animation is sampled at that point, from 0 (its start) to 1 (its end), of its own run. Without it animations are not applied.
A document shown on a screen can play them instead: with HtmlDocumentOptions.AnimationsEnabled a clock runs the animations and transitions over time (animation-delay, animation-fill-mode and animation-play-state apply, and transition runs when a style change restyles a box: a state rule, or a change of class, attribute or preference through the document’s API). The HtmlView control for WinUI does it with AnimationsEnabled. Properties that only paint (transform, translate, rotate, scale, opacity, filter, visibility, colours, shadows, outlines and the like) play with no layout; properties that move or resize boxes (width, margin, top, font-size, flex and grid sizing) play when the host lays the document out for each frame, and so do registered custom properties (@property), which a var() reads. The rest of this section describes how a value is sampled and mixed, which is the same for both.
| Property | MDN Reference | Notes |
|---|---|---|
animation |
animation | Shorthand for the longhands below, including a comma-separated list of animations. A later animation in the list wins for a property both animate, and sees the earlier result as the value an omitted from/to keyframe stands for. |
animation-name |
animation-name | Names an @keyframes rule, as an identifier or a string; an unknown name or none does nothing. |
animation-duration |
animation-duration | How long one iteration lasts, which sets the run a fraction is taken of. An animation with a zero duration has no run. |
animation-timing-function |
animation-timing-function | linear, ease, ease-in, ease-out, ease-in-out, step-start, step-end, cubic-bezier() (an overshooting curve is followed past the end value, within what the property allows: a width stops at 0) and steps() (with start, end, jump-start, jump-end, jump-none or jump-both; a step count below 1, steps(1, jump-none) and a cubic-bezier() whose x values leave 0 to 1 are invalid and ignored), applied to each keyframe interval; the first step is taken at the very start of the run, and the last interval’s easing still applies at the very end (so a linear() curve that does not finish at 1 ends where it says); a keyframe’s own animation-timing-function overrides it for the interval that starts there. linear() follows its stops: a piecewise-linear curve through them, with unplaced stops spread evenly and the line continued past the first and last stop (so a curve may leave 0 to 1, within what the property allows). |
animation-iteration-count |
animation-iteration-count | A number (fractions included) or infinite, whose run is one iteration. |
animation-direction |
animation-direction | normal, reverse, alternate and alternate-reverse. |
animation-delay |
animation-delay | Applies when the animation plays on a screen (a negative delay starts it part-way through). In a PDF it is accepted and ignored: the frame is taken inside the run, whether or not a browser would have started it yet. |
animation-fill-mode |
animation-fill-mode | Applies when the animation plays on a screen: backwards shows the first keyframe during the delay, forwards keeps the last after the end. In a PDF it only matters for an animation with no run (a zero duration or iteration count), which applies in its final state for forwards and both. |
animation-composition |
animation-composition | replace (the default), add or accumulate, one per animation, and a keyframe may give its own. With add or accumulate the value of each keyframe is first combined with the value underneath the animation (the box’s own, or the result of an earlier animation in the list), and the mix runs between the combined values: numbers and lengths are summed (a scale factor multiplies), colours are added premultiplied, a transform list is followed by the keyframe’s (add) or has the functions of the same kind in the same places summed (accumulate). Values that cannot be combined (keywords, lists of another shape) replace. Not part of the animation shorthand. |
animation-timeline |
animation-timeline | One per animation: auto (time, the default), none (the animation has no effect), a dashed name from scroll-timeline-name or view-timeline-name, scroll([root \| nearest \| self] [block \| inline \| x \| y]) or view([block \| inline \| x \| y] [<inset>]). On a scroll or view timeline progress follows how the document is scrolled (a host that scrolls tells the document with SetViewport), animation-duration and animation-delay are ignored, animation-fill-mode and animation-direction apply, and the iterations share the range; nothing ever finishes, since it can be scrolled back. Only the document scrolls: see the limits below. Not reset by the animation shorthand. |
animation-range, animation-range-start, animation-range-end |
animation-range | The part of the timeline the animation plays over: normal (the whole of a scroll timeline, or cover of a view timeline), a <length-percentage> offset (of the scrollable distance, or of the cover range), or a named range of a view timeline (cover, contain, entry, exit, entry-crossing, exit-crossing) with an optional offset into it. |
scroll-timeline, scroll-timeline-name, scroll-timeline-axis |
scroll-timeline | Names the scroll position of the document (declared on html or body) so other elements can use it as animation-timeline. |
view-timeline, view-timeline-name, view-timeline-axis, view-timeline-inset |
view-timeline | Names how much of an element is in view, with an optional inset of the scrollport at each end. |
timeline-scope |
timeline-scope | Lets the elements under this one (and the siblings of the element that declares it) use a timeline declared below it: none, all or dashed names. |
animation-play-state |
animation-play-state | paused holds an animation where it is when it plays on a screen (a rule on :hover can pause it). In a PDF it is accepted and ignored: a frame has no playback to pause. |
The values of an animation replace the ordinary ones before layout, in the cascade’s animation origin: above every normal declaration and below every !important one. An !important declaration of a custom property is seen by a var() in an animated property or in a keyframe, and an !important color is the color currentcolor stands for in a mix (an animation of color itself then does not apply, as the declaration outranks it); a color that another animation in the list sets is seen by every animation’s currentcolor. An !important animation-* declaration (animation: none !important, for instance) still decides which animations run. Between two keyframes a value is mixed the way a browser mixes it:
- Mixed: numbers (
opacity,flex-grow,z-indexas a whole number), lengths and percentages, angles (deg,grad,rad,turn), colors (the next point), and any list or function made of them: the arguments oftransformandfilterfunctions,box-shadow,text-shadow,background-position. Lengths of different units (10pxto50%) are written as acalc()and resolved at layout, and so is acalc()against a length or against acalc()of another shape. A bare0takes the other value’s unit (against acalc()it does not inline-height,tab-size,border-image-widthandborder-image-outset, where it is a number, so that pair flips), andnormalis the zero length forletter-spacingandword-spacing.currentcoloragainst a color mixes as the box’s owncolorin any color property (background-color,border-color,outline-color,column-rule-color,text-decoration-color, …), in a shadow’s colour and in a gradient stop (acoloranimated in the same keyframes counts, socurrentcoloris the text color of the same moment); two values that both saycurrentcolorkeep the keyword, and at the very start and end it stays the keyword.noneagainst atransformorfilterlist mixes with the identity of each function in the other list. Twotransformlists are paired function by function, the shorter list continuing with identity functions: functions that share a primitive pair up whatever they are called (translateXwithtranslate,rotatewithrotateZ,scale(1)withscale(2, .5)) and are mixed on their own, so a rotation keeps the angle as written (rotate(0deg)againstrotate(360deg) scale(1.2)turns a full time). At the first position where the two share no primitive, what is left of each list is composed to one matrix and the two matrices are mixed through their decompositions into translation, scale, skew and rotation, where a rotation takes the short way round (a full turn is lost).matrix()andmatrix3d()are always mixed as matrices, never entry by entry (a pair that cannot be decomposed - a collapsed scale, or perspective terms in amatrix3d()- flips half way; a list with acalc()argument is not paired, but is mixed as two whole matrices). Twoperspective()functions pair and mix their lengths.visibilityisvisiblefor the whole interval betweenvisibleand anything else, anddisplaybetweennoneand anything else shows the other value for the whole interval (so a box fading out stays in the layout until the end). A custom property in a keyframe is written onto the element for avar()to read: one that is not registered with@propertyflips at the middle of the interval, and a registered one is mixed by itssyntax(<length>,<number>,<color>…). An!importantcustom property outranks the animation. - Colors: hex, named colors,
transparent,rgb(),hsl()andhwb()are mixed premultiplied in sRGB, sohsl(0, 100%, 50%)tohsl(120, 100%, 50%)passes through olive, not through the hue wheel. As soon as one of the two is alab(),lch(),oklab(),oklch(),color()orcolor-mix()color,, a relative color (rgb(from ...)), or has a component written asnone(a missing component, which takes the other color’s value in the mix), the pair is mixed premultiplied in Oklab, as a browser does, solab(50 40 20)tolab(70 -30 30)and#f00tooklch(0.5 0.15 260)do not pass through the muddier sRGB middle. A color outside the sRGB gamut (a vividoklch(0.7 0.3 150), say) is mixed from the values it was written with and clipped to sRGB once, at the end, as a browser does.currentcolortakes part as a legacy color whatever the box’scoloris. - Flipped half way: a value with no way to be mixed (keyword values,
autoagainst a length, twotransformlists that cannot be mixed through matrices (see the limitations below),calc()against a value that is not a length) shows the first keyframe’s value until half way through the interval and the second’s after.
Gradients are mixed stop by stop where a browser would flip half way. The -webkit- spellings of the animation and transition properties and @-webkit-keyframes are accepted as the standard ones.
PeachPDF reports prefers-reduced-motion: reduce, because a static PDF has no motion. A stylesheet with the common reset @media (prefers-reduced-motion: reduce) { * { animation: none !important } } therefore switches off every animation, even when AnimationProgress is set.
transition and its longhands (transition-property, transition-duration, transition-delay, transition-timing-function, transition-behavior) run on a screen whenever a style change restyles a box (a change of :hover, :active or :focus state, or of a class, an attribute or the preferences through the document’s API), from the value the box had to the value it now has, and are never run in a PDF: a transition needs a style change to start from, and a document that has just loaded has none (@starting-style gives it one, see below). A transition-property entry may be all, a property or a shorthand that sets it (margin). A property whose values do not mix (display, a keyword) only transitions when its entry says allow-discrete in transition-behavior; display then keeps the box in the layout, with the other value, for the whole interval. A transition that is interrupted starts again from where it had got to, except that a change back to where it started reverses it over the part of the duration already travelled, and a restyle that leaves the transition’s target as it is lets it run on. @starting-style (top level, or nested in a rule) gives an element the style a transition runs from when the document first appears or when the element is shown again after display: none; its rules take no part in the ordinary cascade. Not run: a transition of a pseudo-element (::before), of an inherited property that moves boxes (font-size), or of a property a running animation animates.
Known limitations: the matrix part of a transform mix needs both sides to be a matrix before layout, so a pair with a percentage argument in that part (translateX(50%)), a perspective() against something else, or a collapsed scale (scale(0)) still flips half way; scroll and view timelines follow only the document’s own scrolling (a box with overflow: auto or scroll has no scroll position, so a timeline on it does not exist and its animations have no effect); a registered custom property is mixed only when both keyframe values are of one single interpolable type (<length>, <number>, <integer>, <percentage>, <length-percentage>, <color>, <angle>, a transform), and flips half way otherwise, lists included; the declarative document-building API does not apply animations; CSS animation of the elements of a standalone SVG (<img src="x.svg">) is not applied (those of an inline <svg> are: see CSS animations in SVG); and SVG’s own animation elements (SMIL) are sampled at one point of their span for an inline <svg> (see SMIL animation).
Filters and Blend Modes
| Property | MDN Reference | Notes |
|---|---|---|
mix-blend-mode |
mix-blend-mode | All 16 standard values (normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity), rendered as a genuine PDF blend mode (ExtGState /BM, ISO 32000-1 §11.3.5) — not an approximation. Per CSS Compositing and Blending 1 §3.1, any value other than normal establishes a stacking context (see Stacking Context above). |
filter |
filter | Every filter function is supported. opacity(), brightness(), contrast(), and invert() are genuinely native, real PDF color math rather than an approximation (see below). blur(), grayscale(), sepia(), saturate(), and hue-rotate() have no PDF equivalent, so an element carrying one is rendered into a bitmap, filtered, and embedded — see Rasterized effects below. drop-shadow() follows the element’s real alpha shape and is rendered the same way (see below). backdrop-filter has its own row. Per Filter Effects 1 §3, any value other than none establishes a stacking context (see Stacking Context above). The legacy -webkit-/-moz- spelling is accepted as the same property. |
backdrop-filter |
backdrop-filter | The same function list as filter (blur(), brightness(), contrast(), grayscale(), hue-rotate(), invert(), opacity(), saturate(), sepia(); drop-shadow() has no meaning on a backdrop and is ignored), applied to whatever was painted behind the element, inside its border box, and drawn under the element’s own background, clipped to its border-radius. -webkit-backdrop-filter is the same property. The filtered backdrop is a bitmap at PdfGenerateConfig.RasterizationDpi (see Rasterized effects); the edges of the box are extended by mirroring for the blur, so it does not darken at the edges. The element creates a stacking context, and is a backdrop root for its own descendants. Only what is inside the element’s nearest backdrop root is the backdrop: an ancestor with opacity below 1, a filter, a mix-blend-mode, a clip-path or its own backdrop-filter isolates its content from what lies outside it; with none, the backdrop is everything painted before it on the page, over white paper. The filter is not applied to an element with a transform, or under a transformed ancestor that is inside its backdrop root, because its backdrop is then in another coordinate space (the element paints without it). A nested backdrop-filter sees the earlier one’s result, to a depth of three. Under PDF/A-1 or PDF/X-1a/X-3 it is rejected, like any effect that needs a bitmap with soft edges |
opacity() composes into the same isolated-transparency-group alpha the opacity property itself uses (see Opacity above). brightness(), contrast(), and invert() are each an affine, per-color-channel transform — CSS Filter Effects 1 defines every one of them as an equivalent feColorMatrix/feComponentTransfer — so they map directly onto a PDF ExtGState /TR transfer function (ISO 32000-1 §8.6.5.3): real, spec-correct color math evaluated by the PDF reader itself, not a rasterized approximation. Multiple native functions chained in one filter: value still compose into a single transfer function, so a whole native-only filter chain costs one PDF object, the same as one function alone.
Not every PDF reader honours a transfer function: MuPDF, and the viewers and libraries built on it, ignore it and draw the element unfiltered, and PDFium applies it to solid fills and text but not to a gradient or other shading. Set PdfGenerateConfig.PortableColorFilters to write the same three functions as blend-mode layers (Multiply, ColorDodge, Screen, ColorBurn, Difference) that every reader composites itself. The result is still vector content and matches the CSS formulas to within a few of 255 levels on opaque content; where the content is partly transparent (an rgba() colour, an antialiased edge) the colour is slightly off, which is why it is opt-in.
drop-shadow(<offset-x> <offset-y> <std-deviation>? <color>?) draws a blurred, offset copy of the element’s real alpha shape — its glyphs, an image’s transparent corners, an SVG’s outline, a clip-path — in the shadow colour, beneath the element. Unlike box-shadow, the third length is the standard deviation itself, not twice it. It is rendered in the same bitmap as any other raster filter function and applied in list order, so drop-shadow(…) drop-shadow(…) shadows the first shadow too, as the specification describes. A graphics that cannot rasterize falls back to shadowing the element’s border-box rectangle.
Rasterized effects
A few effects cannot be expressed as vector PDF content. A blur needs per-pixel convolution (which also means a blurred box-shadow or text-shadow, and a drop-shadow() that follows an element’s real shape), and grayscale(), sepia(), saturate() and hue-rotate() each mix color channels together, while a PDF can only recolor each channel on its own. For these, PeachPDF renders the element into a bitmap, applies the whole filter list to it in the order it was written, and embeds the result. backdrop-filter is the same idea applied to the content painted behind an element, and an SVG <filter> with a blur, lighting or other pixel primitive is evaluated the same way (see SVG filters). Everything else on the page stays vector.
- Which elements. Any element whose
filterlist containsblur(),grayscale(),sepia(),saturate(),hue-rotate()ordrop-shadow()with an actual effect (blur(0),grayscale(0),saturate(1)andhue-rotate(0deg)change nothing and stay vector). The bitmap covers the element, all of its descendants and its ownbox-shadow, grown by three standard deviations of everyblur()so the blur has room to spread. Abrightness(),contrast(),invert()oropacity()written next to one of these functions is applied in the same bitmap pass, in order; on their own they stay native vector math. The element’s ownopacityandmix-blend-modeare applied after the filters, as the specifications order them. - Blur radius.
blur(<length>)is the standard deviation of the Gaussian, in the length’s own units. Up to a deviation of 2 device pixels the exact kernel is used; beyond that, the three-pass box approximation Filter Effects 1 specifies forfeGaussianBlur. - Resolution. The bitmap is rendered at
PdfGenerateConfig.RasterizationDpipixels per inch of paper (default300;72to1200;--raster-dpion the command line), so it stays sharp when zoomed or printed, up to that resolution. The value is a physical resolution, independent ofPixelsPerInch: the bitmap is always placed at exactly the size of the content it replaces, so an inch stays an inch and only the number of pixels behind it changes — withPixelsPerInch = 96and a value of288, each CSS pixel is backed by 3 × 3 = 9 bitmap pixels. A region that would exceedPdfGenerateConfig.MaxRasterPixels(64 million pixels by default) is rendered at a lower resolution just large enough to fit; its placed size never changes. Bitmaps are exempt fromDownscaleImages. See Rasterized effects and resolution. - Text. The text of a rasterized HTML element is drawn into the bitmap, and PeachPDF also places the same text over it as invisible, positioned text, so it can still be selected, searched and copied, and tagged-PDF structure still points at it. Text drawn by an inline
<svg>or<math>inside the element is part of the bitmap only, andtext-decorationlines are not part of the selectable overlay. Small text in a bitmap can be made sharper by the font’s own hinting (TrueType instructions, or the hints of CFF outlines) withPdfGenerateConfig.TextHinting(off by default; it never touches the PDF’s vector text or layout), and CFF fonts can also have their stems thickened withTextStemDarkening, see Sharper small text in bitmaps. - Anti-aliasing. Every pixel of the bitmap is anti-aliased by default — shape fills, images and text alike.
PdfGenerateConfig.RasterAntiAliasing(--no-raster-antialiasingon the command line) turns this off, thresholding every pixel to fully transparent or fully opaque instead; it has no effect on the PDF’s own vector content, which a viewer anti-aliases on its own. See Turning off smoothing. - Limits.
text-decoration-skip-inkis not applied, a CMYK image is drawn through a colour-unmanaged RGB conversion (the PDF keeps its CMYK data everywhere else), and a stroke thinner than one device pixel is widened to one pixel. A document that requests PDF/A-1 or PDF/X-1a/X-3 conformance is rejected if it uses one of these effects, because a bitmap with soft edges needs a transparency soft mask, which those levels forbid.
Backgrounds
| Property | MDN Reference | Notes |
|---|---|---|
background |
background | Shorthand supported; all longhand components are parsed and applied |
background-color |
background-color | Full support |
background-image |
background-image | URL, data URI, and all CSS gradient functions: linear-gradient(), radial-gradient(), conic-gradient(), repeating-linear-gradient(), repeating-radial-gradient(), and repeating-conic-gradient(); all accept multi-stop gradients with stop positions in any <length-percentage> unit (absolute, percentage, em/rem/ex/ch, or a viewport/container-relative unit), two-position hard-stop shorthand, color hints, rgba()/alpha transparency, and currentcolor as a stop color (the box’s own color; a page or page-margin box’s color: its own, else the @page rule’s, else the root element’s; a gradient on <body> that propagates to the page canvas takes the root element’s color; a currentcolor inside a color function in a stop, such as color-mix(in srgb, currentcolor, red), mixes with that same color); a gradient with a single color stop is that color, solid; radial gradients support circle/ellipse shape, at <position> centering, an explicit radius in any of those same length units, and all four size keywords; conic gradients support from <angle> and at <position>, and a calc() expression is accepted as an angular color-stop position (e.g. calc(1turn * 0.35)); all gradient functions support CSS Color Level 4 color-space interpolation (in srgb, in srgb-linear, in display-p3, in lab, in oklab, in lch, in oklch, in hsl, in hwb, in xyz/in xyz-d65, in xyz-d50) with polar hue-interpolation methods (shorter hue, longer hue, increasing hue, decreasing hue); the in <color-interpolation-method> prelude is validated against CSS Images 4 §3.1 and an invalid one is dropped at parse time (an unknown color space, a hue method on a rectangular space, or a malformed direction). The wide-gamut RGB interpolation spaces a98-rgb, prophoto-rgb, and rec2020 are valid CSS but not supported, and a gradient that requests one is dropped rather than approximated. A url() source that is an SVG (.svg file, data:image/svg+xml) renders as real vector content — a reusable PDF Form XObject positioned/sized/repeated via background-position/background-size/background-repeat exactly like a raster image — not rasterized; see Supported SVG Features The legacy -webkit-/-moz- gradient functions (-webkit-linear-gradient(), -moz-radial-gradient(), and their repeating- forms) are accepted anywhere a gradient is, translated from their pre-standard syntax (angles measured counter-clockwise from east, a top/left keyword naming the start rather than the end, a leading radial position, cover/contain sizes); -webkit-gradient() is not. |
background-position |
background-position | Full support: keywords, lengths, percentages, calc(), and the 4-value edge-offset syntax (e.g. right 10px bottom 20px); applies to url() images and gradients alike. Comma-separated multi-layer values cycle against the number of background-image layers (a single value applies to every layer) |
background-size |
background-size | Full support: auto, cover, contain, lengths, percentages, and calc(), for both url() images and gradients — a gradient with an explicit size smaller/larger than the box is rendered once and then positioned/repeated exactly like an image. Comma-separated multi-layer values cycle against the number of background-image layers The legacy -webkit-/-moz- spelling is accepted as the same property. |
background-repeat |
background-repeat | Full support: all keywords (repeat, no-repeat, repeat-x, repeat-y, and the 1/2-value forms). Comma-separated multi-layer values cycle against the number of background-image layers |
background-origin |
background-origin | Full support; border-box, padding-box, content-box. Comma-separated multi-layer values cycle against the number of background-image layers The legacy -webkit-/-moz- spelling is accepted as the same property. |
background-attachment |
background-attachment | scroll (default) and fixed are supported. On a continuous MediaType.Screen surface fixed positions the image against the viewport the host reports, and it stays put as the host scrolls. Since a PDF page has no scrolling viewport, fixed is given the paginated-media meaning of CSS Paged Media: the background positioning area is the page box rather than the element’s own box, and it repeats identically (page-anchored) on every page — the same page-anchored idea position: fixed uses, though that one’s containing block is the page area (see position). background-clip is unaffected, so the image is still only ever visible within the element’s own box. Comma-separated multi-layer values cycle against the number of background-image layers |
background-clip |
background-clip | Full support; border-box, padding-box, content-box, text. Comma-separated multi-layer values cycle against the number of background-image layers; when there are multiple values, background-color uses the last (bottom-most) one, per spec. For padding-box/content-box on a box with border-radius, the inner curve’s radius is the border-box radius reduced by the border width (and, for content-box, the padding too), clamped to zero, per the spec’s corner-clipping rule. text clips every background layer to the union of the element’s own laid-out glyph outlines, including nested inline descendants — the standard gradient-text idiom (background: linear-gradient(...); background-clip: text; color: transparent;) renders as intended. Unlike this library’s SVG gradient-text support, the glyphs stay ordinary selectable/extractable PDF text (color: transparent only makes them invisible, it does not convert them to vector art). A box under a true vertical writing-mode (vertical-rl/vertical-lr) clips to the same real glyph-outline union: an upright run builds it character by character — each character’s outline additionally clipped to its own reserved cell when its font carries real OpenType vertical metrics (vhea/vmtx/VORG), matching the identical per-cell clip painting itself already applies — and a rotated (sideways) run builds one whole-run outline and carries it into its physical footprint with the same rotation used to paint it. A box falls back to border-box (rounded corners preserved) when its font has no decodable outline, or the box uses writing-mode: sideways-rl/sideways-lr (a different value, laid out horizontally with only its glyphs rotated) The legacy -webkit-/-moz- spelling is accepted as the same property. |
Canvas background (<html>/<body>)
Per CSS Backgrounds 3 §2.11.2, the root element’s background doesn’t just paint its own box — it fills the whole canvas (here: every page). PeachPDF resolves this once per document: <html>’s own background (background-color and/or background-image layers) is used if it declares one; otherwise <body>’s background is propagated to the canvas instead; otherwise no canvas fill happens at all. When <html> has a background of its own, <body>’s background is not propagated and paints as an ordinary box background over <body>’s own box. Whichever element was used for the canvas fill isn’t separately re-painted at its own (possibly much smaller than a page) laid-out rect — the canvas fill already covers it. A non-<body>/<html> element’s own background (e.g. a <div>) is unaffected and continues to paint normally. The fill repeats identically on every page the document spans. The @page box’s own background — a further tier below this canvas fill — is documented in @page rule below.
Color & Typography
| Property | MDN Reference | Notes |
|---|---|---|
color |
color | Full support for named colors, hex, rgb()/rgba() in both the legacy comma form (rgb(r, g, b), rgba(r, g, b, a)) and the CSS Color Level 4 space-separated form with an optional slash alpha (rgb(r g b), rgb(r g b / a), rgba(r g b / a%)), hsl()/hsla(), hwb(), the CSS Color Level 4 wide-gamut functions oklch(), oklab(), lab(), and lch() (number or percentage components, an angle hue with any unit for the polar forms, and an optional / <alpha>), and color() in every predefined space (srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz, xyz-d50, xyz-d65), all resolved to sRGB. A component, or the alpha, may be written as none in the space-separated syntax (not in the legacy comma one): it reads as 0 (an alpha of none is transparent), and in a color-mix() or an animation it is a missing component that takes the other color’s value. color-mix() is supported (color-mix(in <space>, <color> <p>?, <color> <p>?)) — premultiplied-alpha mixing in any of the interpolation spaces above, so the common opacity-modifier shape color-mix(in oklab, <color> 50%, transparent) yields that color at half alpha, including a hex operand of either form (#e11d48 or #2563eb). An operand outside the sRGB gamut (a vivid oklch(0.7 0.3 150)) is mixed before the result is clipped to sRGB, as a browser does; a lab(), lch(), oklab() or oklch() lightness out of its range is clamped and a negative chroma is 0; and a gray operand of an lch or oklch mix takes the other operand’s hue, except a gray written in that same space (or its rectangular form: lab() in an lch mix, oklab() in an oklch mix), which keeps the hue it was written with. These color functions work anywhere a <color> is accepted (color, background-color, the background/border shorthands, gradient color stops). currentcolor is accepted as an operand of a color function in background-color, the border, outline and column-rule colors and gradient color stops, and stands for the element’s own color (color-mix(in srgb, currentcolor, red) is a tint of the text color); it is not yet read inside a color function in the other color properties (shadows, text-decoration-color). device-cmyk() (device-cmyk(c m y k)/device-cmyk(c m y k / a), number or percentage components, /-only alpha — no legacy comma-alpha form) is also supported, and unlike every color function above, it is not approximated to sRGB: a device-cmyk()-authored color/background-color/border color is carried through to the PDF as a real DeviceCMYK fill or stroke operator, exactly as authored — PDF’s own support for a document mixing DeviceRGB and DeviceCMYK content means an RGB-authored color elsewhere on the same page is completely unaffected. Relative color syntax is supported in rgb(), hsl(), hwb(), lab(), lch(), oklab(), oklch() and color(): rgb(from #336699 calc(r + 20) g b / 50%) converts the origin color (any color, itself possibly relative) to the function’s space, binds its components to the channel keywords (r g b, h s l, h w b, l a b, l c h, x y z, and alpha), and each component of the result is a number, a percentage, none, a keyword, or a calc()/min()/max()/clamp() over them; an omitted alpha is the origin’s. An origin outside the sRGB gamut keeps its coordinates for lab(), lch(), oklab(), oklch() and color(). A color-mix() between two device-cmyk() operands mixes directly in C/M/Y/K space (ignoring the declared in <space> keyword, which has no CMYK equivalent); a gradient whose stops are all device-cmyk() likewise interpolates directly in C/M/Y/K space, the same way an all-RGB gradient interpolates in RGB space. A color-mix() or gradient mixing a device-cmyk() operand with an RGB-authored one has no defined conversion and is rejected (invalid declaration / thrown error respectively) — see PDF/X-conformant output for how an achromatic (gray/black) RGB color is still handled under strict CMYK-only PDF/X-1a output |
font |
font | Shorthand supported; all components are parsed |
font-family |
font-family | Full support. Generic families (serif, sans-serif, monospace, cursive, fantasy, math, and the ui-serif, ui-sans-serif, ui-monospace and ui-rounded families), system-ui and its aliases -apple-system and BlinkMacSystemFont resolve to a real installed substitute matching actual browser behavior per platform (hardcoded specific families on Windows/Android, WebKit’s choices and the system’s own San Francisco, New York, SF Mono and SF Rounded on macOS and iOS, delegated to the OS’s own fontconfig on Linux; math picks the first installed math-capable family of a per-platform list) — see Fonts for the full per-platform table. Every mapping is verified against the system’s actually-installed fonts before use, falling back to the platform default font otherwise, so a requested substitute that isn’t present never silently resolves to an arbitrary, unrelated font |
font-size |
font-size | Full support including absolute sizes (medium, large, etc.), relative sizes (smaller, larger), lengths, and percentages. A document laid out for the screen medium starts from the size browsers use (medium is 16px, with the keywords at 9, 10, 13, 16, 18, 24 and 32px, and so 1rem is 16px, and smaller/larger scale the parent by 1.2); a paged (print) document keeps a medium of 11pt and steps smaller/larger by 2pt |
font-size-adjust |
font-size-adjust | none (the initial value), or an optional metric — ex-height (the default), cap-height, ch-width, ic-width or ic-height — followed by from-font or a non-negative <number>. Inherited. The glyph font is created at font-size × the number ÷ that face’s own measurement of the metric, so each face in a font-family list, fallback faces included, lands on the same aspect value and a small-x-height font stops looking small next to its fallback. Only the glyph font changes size: font-size itself, and so em and every length derived from it, keeps the unadjusted size (an ex is measured on the used, adjusted face). from-font takes the first available font’s own measurement, which leaves that font at its authored size and sizes fallback faces to match it. ic-height is measured as ic-width, since the font layer exposes no vertical advance for the ideograph. Applies to SVG text too |
font-stretch |
font-stretch | The 9 keywords (ultra-condensed … normal … ultra-expanded) or a non-negative <percentage> of the normal width (87.5%); inherited and cascaded, and consulted for real face selection when a family has multiple registered faces at different stretch values (e.g. two @font-face rules with different font-stretch descriptors) — nearest-width matching per CSS Fonts Level 4 §5.2. A keyword stands for a percentage (ultra-condensed is 50%, condensed 75%, semi-condensed 87.5%, normal 100%, semi-expanded 112.5%, expanded 125%, extra-expanded 150%, ultra-expanded 200%), and a variable font’s wdth axis is set to it, kept inside the range the face declares. The font shorthand takes only the keywords, as in the specification |
font-style |
font-style | normal, italic, oblique, and CSS Fonts Level 4’s oblique <angle> (e.g. oblique 10deg) — when no real oblique/italic face is available and the renderer has to synthesize one, an explicitly declared angle drives the exact synthesized shear amount instead of a fixed default |
font-variant |
font-variant | Real shorthand over font-variant-caps, font-variant-ligatures, font-variant-numeric, font-variant-east-asian, font-variant-position, font-variant-emoji, font-variant-alternates, and font-feature-settings, combinable in one declaration (e.g. font-variant: small-caps common-ligatures oldstyle-nums;) — setting it resets every covered longhand it doesn’t mention back to its initial value, per CSS Cascading. none sets font-variant-ligatures to none and every other covered longhand to normal |
font-variant-caps |
font-variant-caps | All 7 keywords (normal, small-caps, all-small-caps, petite-caps, all-petite-caps, unicase, titling-caps) parse and cascade. When the resolved font actually has the corresponding OpenType GSUB feature (smcp/c2sc/pcap/c2pc/unic/titl respectively, implemented via either Single or Alternate Substitution — see Text shaping), real glyph substitution is used. When the font lacks the feature, small-caps/all-small-caps fall back to synthesis (originally-lowercase letters upper-cased and drawn at a reduced size; under all-small-caps, already-uppercase letters are shrunk too, approximating c2sc); the other four keywords never synthesize — an unsupported font renders them as normal with no visual effect |
font-variant-ligatures |
font-variant-ligatures | normal, none, common-ligatures/no-common-ligatures, discretionary-ligatures/no-discretionary-ligatures/historical-ligatures/no-historical-ligatures, and contextual/no-contextual all actually control GSUB substitution (see Text shaping) — contextual drives a font’s calt feature via GSUB contextual/chaining-context lookups |
font-kerning |
font-kerning | auto, normal, and none are all supported: auto/normal apply a font’s GPOS kern feature (pair/class kerning) when the font defines one; none disables it. Mark-to-base/mark-to-mark positioning (diacritic placement) is unaffected by this property — it’s always applied when a font defines it, matching real browser behavior The legacy -webkit-/-moz- spelling is accepted as the same property. |
font-variant-numeric |
font-variant-numeric | All 8 keywords (lining-nums, oldstyle-nums, proportional-nums, tabular-nums, diagonal-fractions, stacked-fractions, ordinal, slashed-zero) parse, cascade, and activate the corresponding OpenType GSUB feature (lnum/onum/pnum/tnum/frac/afrc/ordn/zero) on a font that has it; a keyword the resolved font doesn’t support is a silent no-op (no synthesis for this property) |
font-variant-east-asian |
font-variant-east-asian | All 9 keywords (jis78-forms, jis83-forms, jis90-forms, jis04-forms, simplified, traditional, full-width, proportional-width, ruby) parse, cascade, and activate the corresponding OpenType GSUB feature (jp78/jp83/jp90/jp04/smpl/trad/fwid/pwid/ruby) on a font that has it; a keyword the resolved font doesn’t support is a silent no-op |
font-variant-position |
font-variant-position | normal, sub and super parse, cascade, and activate the corresponding OpenType GSUB feature (subs/sups) on a font that has it. When the resolved font lacks the feature, the run is synthesized instead, as the spec requires: the text is drawn with a reduced-size face and its baseline shifted, using the scale and offset the font’s own OS/2 table recommends (ySuperscriptYSize/ySuperscriptYOffset and the subscript pair), falling back to representative ratios only for a font that leaves those fields at zero. Real substitution and synthesis are never combined. The decision is per element, matching the spec’s rule that a run whose glyphs aren’t all available is synthesized as a whole. Synthesized sub/superscripts don’t affect line height or any other box dimension — unlike vertical-align’s sub/super, which shift the whole inline box within its line |
font-variant-emoji |
font-variant-emoji | normal, text, emoji and unicode parse, cascade and select between the text (outline) and emoji (colour) form of a character that has both. Only emoji presentation participating code points — the characters Unicode lists in emoji-variation-sequences.txt, such as ❤ ☺ © ⌚ 👍 and, by that list, the digits, # and * — are affected; the property has no effect on any other character. text/emoji act as if U+FE0E/U+FE0F were appended to every participating character; unicode follows each character’s Emoji_Presentation property (emoji-default → emoji, text-default → text); normal makes no choice, so font-family order decides as it always has. An explicit U+FE0E or U+FE0F in the content overrides the property. The presentation steers font selection, following the cluster-matching order: the first font-family font that supports the requested form wins (a font supports it when its cmap format-14 table lists the variation sequence, otherwise when it is a colour font for emoji / an outline font for text); if none does, system fallback is searched for one that does; and if no font anywhere supports the form, the selector is ignored and the first font that merely covers the character is used — so an emoji-only font still draws its colour glyph for a text request. See Per-character font matching |
font-variant-alternates |
font-variant-alternates | normal, historical-forms (activates a font’s hist feature), and the six functional clauses stylistic(<name>)/styleset(<name>#)/character-variant(<name>#)/swash(<name>)/ornaments(<name>)/annotation(<name>), combinable in one declaration (each may appear at most once). Each <name> is looked up in a matching @font-feature-values rule for the element’s used font family: stylistic()/swash()/ornaments()/annotation() activate salt/swsh/ornm/nalt respectively, using the looked-up integer the same way font-feature-settings uses one (1 the first alternate, 2 the second, and so on); styleset() turns on one or more numbered ss01–ss20 features at once (one per integer the name’s @styleset block declares); character-variant()’s name maps to a cv01–cv99 feature (its first integer) with an optional second integer selecting the value passed to it (defaulting to 1). A name absent from the registry (or a @font-feature-values rule that doesn’t cover the used family) contributes nothing, the same “unknown resolves to no-op” behavior font-palette uses for an unmatched name. Takes precedence over a font-feature-settings entry for the same tag |
font-feature-settings |
font-feature-settings | normal and a comma-separated list of <string> [<integer> \| on \| off]? feature tags (e.g. "smcp" 1, "ss01" on) activate the named OpenType GSUB features directly on a font that has them — including a feature implemented via Single, Multiple, Alternate, Ligature, or Contextual/Chaining Context substitution. For a feature a font implements via Alternate Substitution (e.g. a numbered stylistic set), the integer selects which glyph alternate to use (1 the first, 2 the second, and so on) rather than just turning the feature on or off. A tag already controlled by one of the font-variant-* longhands above is governed by that longhand instead, per spec precedence — font-feature-settings never overrides it The legacy -webkit-/-moz- spelling is accepted as the same property. |
font-weight |
font-weight | Keyword (bold, normal) and numeric (1–1000) values, fractions included (350.5; a calc() that is a plain number works too, and the font shorthand takes one). bolder/lighter step relative to the parent’s own resolved weight (fractions included) per the CSS Fonts Level 4 §2.2.1 table: bolder steps a weight under 350 up to 400, one from 350 up to (not including) 550 up to 700, one from 550 up to (not including) 900 up to 900, and leaves 900 or over unchanged; lighter leaves a weight under 100 unchanged, steps one from 100 up to (not including) 550 down to 100, one from 550 up to (not including) 750 down to 400, and one of 750 or over down to 700 — a fixed always-bold/always-normal result is never produced. Face selection uses real CSS Fonts Level 4 §5.2 nearest-weight matching (not just an exact Regular/Bold pick) among every face registered for a family, after the faces have been narrowed by font-stretch and then font-style; a face whose weight range holds a fractional weight is preferred to one that only holds the whole weight next to it, and a variable font’s wght axis is set to the fraction; when no face close enough to the request exists, a faux-bold is synthesized (fill+stroke render mode) rather than rendering with no visual distinction |
font-palette |
font-palette | Selects which CPAL palette a COLR/CPAL color font paints with (see Per-character font matching). Inherited. normal uses palette 0; light/dark select the first palette the font flags usable with a light/dark background (via the CPAL v1 palette-type flags, falling back to normal when the font has none); a <dashed-ident> names a custom palette defined with @font-palette-values; and palette-mix() blends two palettes per CPAL entry in a given color space. The palette is applied to the element’s used font family; a non-color font (or one with a single palette) is unaffected. Affects COLR/CPAL color fonts (over glyf or CFF outlines), and the palette entries an OpenType SVG glyph refers to. Animation/interpolation of font-palette is not supported (a static PDF has no timeline) |
font-variation-settings |
font-variation-settings | normal or a comma-separated list of "tag" <number> pairs (a tag is four characters). Inherited, and reset by the font shorthand. Sets the named axes of a variable font (a font with an fvar table) to the given values, clamped to each axis’s range; it wins over what font-weight and font-stretch select on the same axis. A tag the font has no axis for is ignored, and a font that is not variable is unaffected. The glyph outlines (TrueType outlines through gvar, CFF2 outlines through their blend operators), advance widths (HVAR, or gvar), vertical advances and origins (VVAR, or gvar), the mapping of the axes (avar, both versions) and font-wide metrics (MVAR, and the font bounding box worked out from the glyphs) follow the location, as do the paints and clip boxes of COLR version 1 colour glyphs (PaintVar*), the value deltas of GPOS and the FeatureVariations of GSUB and GPOS; the PDF embeds each distinct location as its own static font instance (a font with CFF2 outlines as a static font with CFF outlines) |
font-synthesis |
font-synthesis | Real shorthand for font-synthesis-weight, font-synthesis-style, font-synthesis-small-caps and font-synthesis-position: none, or any of weight, style, small-caps and position once each. A longhand the value names is auto; every one it does not is none. Inherited |
font-synthesis-weight, font-synthesis-style, font-synthesis-small-caps, font-synthesis-position |
font-synthesis-weight, font-synthesis-style, font-synthesis-small-caps, font-synthesis-position | auto (the initial value) or none; inherited. none forbids the corresponding synthesis: -weight stops the faux bold (fill-and-stroke) a face lighter than the requested weight otherwise gets, -style stops the faux italic shear on an upright face, -small-caps leaves font-variant-caps text on a font without real small-caps glyphs as written (no upper-casing, no reduced size), and -position leaves font-variant-position text on a font without real sub/superscript glyphs at its normal size and baseline. A switch only matters when the matching style was asked for. The face is chosen as before; only the synthesis is dropped, so font-synthesis: none text in a regular-only family renders regular. Applies to SVG text for weight and style (SVG has no small-caps or sub/superscript synthesis) |
font-language-override |
font-language-override | normal (the initial value) or a string of one to four printable ASCII characters naming an OpenType language system ("SRB", "ROM"; padded with spaces to four, case-sensitive). Inherited. The tag replaces the one derived from lang when the font’s substitution (GSUB) features are selected, so a font’s language-specific alternates (a Romanian cedilla, a Serbian Cyrillic form) can be asked for regardless of the document language; a font that does not define the language system uses its default one. A string that is empty, longer than four characters, or not ASCII is invalid and ignored. GPOS kerning always uses the font’s default language system. Applies to SVG text too |
forced-color-adjust |
forced-color-adjust | auto (the initial value), none and preserve-parent-color, inherited. Only has an effect when the host turns forced colors on (UserPreferences.ForcedColors): none keeps the element’s own colours, preserve-parent-color keeps its text colour, auto lets the system palette replace them. See Forced colors |
text-rendering |
text-rendering | auto (the initial value), optimizeSpeed, optimizeLegibility and geometricPrecision are accepted and inherited. optimizeSpeed turns off kerning and the optional ligatures (common, contextual) of text whose font-kerning is auto and font-variant-ligatures is normal; a value the author set explicitly wins, and the required ligatures stay. The other keywords change nothing: PDF output is unhinted vector content, and kerning and ligatures are already on. Applies to SVG text too |
-webkit-text-stroke, -webkit-text-stroke-width, -webkit-text-stroke-color |
-webkit-text-stroke, -webkit-text-stroke-width, -webkit-text-stroke-color | The shorthand takes a width (a length, or thin/medium/thick) and a colour in either order; the width is 0 and the colour currentcolor (the text colour) initially. Inherited. A stroke of that width, centred on the glyph outlines, is drawn over each word after it is filled (horizontal, sideways and upright text alike), so the text itself stays selectable and the stroke is vector artwork. paint-order does not apply to HTML text, so the stroke is always on top. A font with no decodable outlines (a bitmap font) is drawn unstroked. Applies to SVG text, where it replaces stroke and stroke-width for that text |
font-optical-sizing |
font-optical-sizing | auto (the initial value) sets a variable font’s opsz axis to the font size in CSS pixels; none leaves it at the font’s default. Inherited, and reset by the font shorthand. A font-variation-settings value for opsz wins over auto. Only a font with an opsz axis is affected |
line-height |
line-height | Full support. A line box is built the way CSS 2.1 §10.8.1 describes: each inline box on the line contributes its own font ascent and descent plus half the leading (its line-height less its font’s own height) on each side, the line reaches as far above and below its baseline as the furthest of them — including the strut, an imaginary inline box carrying the block’s own font and line-height — and every box on the line hangs its content from that one shared baseline. So a line-height larger than the font centres the text in the line rather than hanging it from the top, and text of two different sizes on one line sits on a common baseline rather than sharing a top edge. An empty inline element, such as an icon wrapper with no text or a <span> holding only an absolutely positioned badge, is an inline box on the line too, so a larger font on it makes the line taller. An empty inline right after a space goes to the next line along with the word that follows it, if that word wraps. A line holding nothing but empty inlines stays zero-height (CSS 2.1 §9.4.2). In a vertical writing mode an empty inline makes its column thicker the same way. Because the two sides are maximised independently, a line can be taller than the largest single line-height on it. Where a line-height is shorter than the font, the leading is negative and the glyphs overflow the line box on both sides, matching the spec exactly — fragmentainer (page/column) membership is decided per line box rather than per word, so ink escaping above a line box does not risk escaping the fragmentainer the line was placed in |
vertical-align |
vertical-align | baseline, sub, super, top, middle, bottom, text-top, text-bottom, and <length>/<percentage> (offsetting the box from its own baseline; a percentage resolves against the box’s own line-height) for any inline-level box relative to its line box. sub lowers and super raises a box by a fifth and a third of the parent’s font size plus a pixel (the offsets Chromium and WebKit use), middle centres the box on the parent’s baseline plus half the parent’s x-height, and a box raised or lowered by any of them (or by a length or percentage) makes its line box taller to hold it, nested offsets adding, not just table cells (which use a separate, table-specific alignment algorithm accepting only the keyword subset — see Tables). An atomic inline — an <img>, an inline <svg>, MathML, a form control, an inline-block — is baseline-aligned from its own box rather than from font metrics (CSS 2.1 §10.8.1): its bottom margin edge rests on the line’s baseline, so text <img> text sits the image’s bottom on the text’s baseline and the line grows above the baseline to hold it, rather than the image hanging from the line’s top edge. An inline-block whose overflow is visible and which has in-flow line boxes of its own instead uses the baseline of its last such line, so its text and the text beside it line up; one that is empty, or whose overflow is not visible, falls back to the bottom margin edge. An inline box wrapping an atomic inline moves with it, so its background and border stay attached. One known limitation: the text inside an inline-block with vertical-align: top, text-top or text-bottom and top padding is drawn over that padding, at the box’s top border edge, instead of below it (with middle, partway into it). Give the padding to a block inside the inline-block to keep the text below it. An inline-flex, inline-grid or inline-table box moves as a unit — its items and any absolutely positioned child with it — whether or not it holds text. |
Per-character font matching and coverage fallback
Font matching is per-codepoint, following the CSS Fonts 4 matching algorithm: each character is resolved to the first family in the font-family list whose face both covers that character (via its @font-face unicode-range, or, for a system/rangeless font, its actual cmap coverage) and contains a glyph for it. A run whose primary font lacks a glyph for some character therefore falls back to the next declared family that covers it, and multiple @font-face rules sharing one font-family but declaring different unicode-range subsets each supply their own characters.
Supplementary-plane (astral) characters above U+FFFF — where nearly all emoji live — are supported: they resolve through the font’s format-12 cmap subtable, both for glyph lookup and for coverage-based fallback, and render the font’s monochrome glyf/CFF outline. A font whose only Unicode subtable is format 12 works too; its groups then answer for the Basic Multilingual Plane as well.
Color emoji is supported for COLR/CPAL fonts (both COLR version 0 and version 1), including the COLR v1 build of Noto Color Emoji. PeachPDF decodes each color glyph and draws its visible artwork as native PDF vector content: COLR v0 layers are filled with their CPAL palette colors bottom-to-top, and COLR v1 paint graphs are composed — solid fills, linear/radial/conic (sweep) gradients, per-paint and per-gradient-stop alpha, affine transforms, glyph clips, and separable/HSL blend-mode compositing. Color glyphs are also searchable and selectable: a subset of the color font is embedded solely for invisible PDF text, with a /ToUnicode map and per-occurrence /ActualText preserving the exact source sequence copied from each glyph (including variation selectors, ZWJ, modifiers, regional indicators, and tag characters). The embedded subset increases file size compared with vector-only color-glyph output; it never supplies visible ink. A color glyph’s artwork itself is stored only once per document however often it is used — including across pages and across font sizes — so a document repeating an emoji pays for the artwork once and for a short placement instruction at each occurrence.
Multi-codepoint emoji sequences resolve to their composed glyph when the font defines one, via the same GSUB path as any other ligature (see Text shaping) — ZWJ sequences (👩💻, 🏳️🌈), regional-indicator pairs (🇺🇸, 🇯🇵), tag sequences (🏴), and skin tone modifiers (👍🏻 👍🏽 👍🏿, U+1F3FB–U+1F3FF), including a modifier and a ZWJ in the same sequence (👩🏽💻). Emoji assigned an ordinary ideographic, emoji-base, or paired regional-indicator line-break opportunity wrap between complete grapheme clusters without requiring overflow-wrap; the sequences listed above remain intact. Other sequences, including keycaps, retain their own Unicode line-break behavior rather than gaining a break merely because they are emoji. A trailing U+FE0F (VARIATION SELECTOR-16) in such a sequence neither draws nor contributes an independent advance, and does not prevent the ligature from forming.
Bitmap color emoji is supported too: the CBDT/CBLC tables (the classic bitmap build of Noto Color Emoji; PNG image formats 17, 18 and 19, every index-subtable format) and sbix (Apple Color Emoji; png and jpg pictures, and dupe redirects). Each glyph is drawn as the font’s own picture, scaled by font-size over the strike’s pixels-per-em and placed by the picture’s bearings; the smallest strike at least as large as the text is used, so a picture is scaled down rather than up. A picture used many times is stored once per document. Text stays searchable and selectable in the same way as for COLR glyphs, and the pictures are drawn by the raster backend too, so an emoji inside a filtered or flattened element is not lost. The pictures have an alpha channel, so a document targeting PDF/A-1 or PDF/X-1a/X-3 needs transparency flattening to use them. tiff and mask sbix pictures, and bitmap formats other than PNG in CBDT (formats 1, 2, 5, 6, 7, 8, 9), are not drawn.
SVG-in-OpenType (the SVG table) is supported: each glyph’s SVG document, plain or gzip-compressed, is rendered through the SVG engine as vector content and shared between occurrences as one form. The glyph is the element with the id glyph<N> (a document that covers a single glyph with no such element is drawn whole); the document’s var(--color0), var(--color1) and so on are the font’s CPAL palette entries (so font-palette applies), currentColor and context-fill are the text colour, and context-stroke is no paint (the text has no stroke), except inside a <use> or a marker, where context paint refers to that element’s own fill and stroke. A font that also has bitmaps or COLR paints uses those first. The document comes from the font file and is untrusted: it is parsed without a DTD, an inflated document is capped at 4 MiB, and only data: images resolve. A document that cannot be used draws the glyph’s outline. Inside a rasterized element (filters, shadows, PDF/A flattening) the glyph is drawn from its document too, at the raster resolution, so the effect sees the real glyph. A glyph is not clipped to the em box: it is drawn on a canvas of at least one em to its left, an em and a half above, two ems to its right and half an em below, grown to hold the shapes it draws (and their strokes) up to eight ems from its origin in any direction. The painted region of a filter, and text and marker content, do not count towards that size, so they are clipped at the canvas. Animation is ignored, since a PDF has no timeline.
COLR glyphs in CFF-flavored (OpenType OTTO) fonts paint exactly like those over TrueType outlines. The Porter-Duff composite modes of a COLR v1 PaintComposite are drawn as vector content from paint order and clipping: CLEAR, SRC, DEST, DEST_OVER, SRC_IN, DEST_IN, SRC_ATOP and DEST_ATOP clip one operand to the other’s glyph outlines, and SRC_OUT, DEST_OUT and XOR clip to the area outside them, along with the separable and HSL blend modes. A radial gradient between two circles honors its inner radius, with different centers too, and concentric circles support repeat/reflect as vectors. The two things a PDF cannot express, a PLUS composite (an additive blend) and a repeat/reflect radial gradient between circles with different centers (a PDF shading can only pad), are drawn by the raster backend: such a glyph is painted once on a pixel canvas, at a 300-pixel em, and embedded as a picture shared between occurrences (the pictures have an alpha channel, so a document targeting PDF/A-1 or PDF/X-1a/X-3 needs transparency flattening to use them), while every other glyph stays vector. Still approximate: the shape an IN/OUT/ATOP/XOR composite clips to is the outline of the other operand’s glyphs, so where those overlap each other and carry partial transparency the overlap is painted twice, and a glyph whose own contours overlap is treated as even-odd when clipping to its outside.
When no family in the declared font-family stack (plus the default font) covers a character, PeachPDF performs a last-resort system-fallback search across every OTHER font registered with the PdfGenerator instance — system-discovered fonts plus anything added via AddFontFromStream — the same final step the CSS Fonts 4 font matching algorithm leaves to the user agent. When more than one registered font covers the character, the one whose own coverage best overlaps the character’s Unicode script wins (so a font built for that script is preferred over one that merely happens to include an incidental glyph); a character with no script of its own (punctuation, digits) or a tie between equally-good candidates falls back to alphabetical family-name order for a reproducible result. A character nothing registered covers still shows a missing-glyph box.
Variation sequences. A variation selector stays in the same run, and the same font, as the character before it. A font’s cmap format 14 (Unicode Variation Sequences) table is read: a sequence the font gives its own glyph (a non-default record — e.g. a slashed zero for 0 + U+FE00, or an ideographic variant) draws that glyph, and for U+FE0E/U+FE0F a listed sequence also counts as the font supporting that presentation (see font-variant-emoji). No variation selector other than U+FE0E/U+FE0F influences which font is chosen.
Text Layout
| Property | MDN Reference | Notes |
|---|---|---|
direction |
direction | ltr and rtl. Drives a real Unicode Bidirectional Algorithm (UAX #9) implementation: mixed-direction text within a line is resolved per-character (not per whole word), digits and other neutral/weak runs are correctly embedded inside surrounding RTL text, and mirrored characters (parentheses, brackets, etc.) are substituted for their mirror glyph in an RTL run. See Text shaping. A block-level box narrower than its rtl containing block sits against the right edge, and an over-constrained one overflows toward the left, per CSS 2.1 §10.3.3 (horizontal writing modes only; an in-flow <table> is placed the same way) |
unicode-bidi |
unicode-bidi | normal, embed, bidi-override, isolate, isolate-override each push the corresponding explicit directional embedding/override/isolate onto the Unicode Bidi Algorithm’s level stack (the same mechanism a raw Unicode LRE/RLE/LRO/RLO/LRI/RLI/FSI control character would), scoped to the element it’s set on. plaintext re-derives its own base direction from the first strong character in its content (UAX #9 P2/P3), regardless of the computed direction property, the same first-strong-character detection HTML’s dir="auto" performs |
writing-mode |
writing-mode | All five values parse, cascade, and inherit correctly, and correctly drive logical box-model property resolution. vertical-rl/vertical-lr additionally get real vertical line flow for a block box holding plain inline content (text and simple nested inline elements): lines (“columns”) stack along the block axis (right-to-left for vertical-rl, left-to-right for vertical-lr), text runs top-to-bottom within each column, glyphs orient per text-orientation (see below), and auto width/height both shrink to the content’s own extent. A vertical box holding block-level children instead stacks each child along the same block axis, treating each already-laid-out child as one atomic unit — this is also what makes an orthogonal-flow child (one whose own writing-mode is perpendicular to its containing block’s, per CSS Writing Modes 4 §4.3) work: it lays its own content out in its own writing mode, and the parent places the whole resulting box as one atomic unit along its own block axis. An auto-width, non-replaced orthogonal child is sized via real shrink-to-fit (min(max-content, max(min-content, constraint)), per §4.3) rather than stretching to fill the parent’s available block-axis extent — a definite width, or content wide enough to need the full available extent anyway, is unaffected. A direction: rtl vertical box’s block children correctly anchor to the physical bottom edge (its own inline-start) rather than the physical top ltr uses, with a shorter child’s own unused space appearing as a gap at the top. Margins between block-axis-stacked children — and between a vertical box’s own block-start/block-end edge and its first/last stacked child — really collapse per CSS2.1 §8.3.1 rather than being summed, stopping at a boundary where adjacent boxes’ writing modes differ. Flexbox row/column axis selection, sizing, and page fragmentation are also writing-mode-aware. display: table is too, including <thead>/<tfoot>, <caption>, border-collapse: collapse, rowspan/colspan, and vertical-align: rows stack along the block axis and columns run top-to-bottom along the inline axis, using each cell’s own height as its column-sizing hint; a <caption> stacks along the row axis and sizes across the full column axis; <thead>/<tfoot> are placed along the row axis, including a header/footer group with more than one row, which reverses its own rows’ relative order under vertical-rl the same way <tbody> rows do; collapsed borders resolve on the correct logical edges and paint with the correct physical orientation; a rowspan cell’s own row-axis extent grows to the combined extent of every row it spans (colspan’s own column-axis extent already worked, since a column’s own width is an axis-agnostic concept); and vertical-align repositions a cell’s content along the row axis rather than the column axis. A vertical box holding inline-only or block-level content is moved whole to the next page rather than fragmented mid-content, since it does not yet paginate its own content across pages under a vertical writing mode. A vertical display: table is different: a plain grid (no rowspan, colspan, <caption>, <thead>/<tfoot>, or border-collapse: collapse) whose own column-axis extent exceeds one page relocates the columns that don’t fit onto later pages as whole units — real per-column pagination, reusing the page’s own physical-Y fragmentation axis directly, since a vertical table’s column axis genuinely is that axis. A table combining that with any of the five listed features is still moved whole, like any other vertical box. sideways-rl/sideways-lr are vertical writing modes too and lay out like vertical-rl/vertical-lr, with every glyph rotated sideways whatever text-orientation says; sideways-lr turns the glyphs counter-clockwise, so the text reads bottom to top, a line starts at the bottom edge (text-align: end is the top) and the columns stack left to right. SVG <text> supports them too (see the SVG guide). A multi-column container has no real column arrangement under a vertical writing mode either — column-count/column-width are inert there, and it falls back to ordinary single-column block flow (which still paginates normally) rather than laying out columns along the wrong axis. A vertical box’s own inline-only content now also supports text-align (left/right are always physical per CSS Writing Modes’ line-left/line-right — direction-independent — while start/end resolve against direction; natural, pre-alignment placement already flushes to the physical bottom edge under direction: rtl rather than the physical top ltr uses, so which keyword is a no-op versus an active shift flips between the two), Unicode Bidi Algorithm reordering (mirroring the horizontal engine’s own per-line reordering and mirroring, along the column’s own inline axis instead of physical X), hyphens: auto/manual soft-hyphen breaks (splitting a word across a column boundary the same way horizontal flow splits across a line boundary), and position: absolute/fixed nested inside such content (already blockified per CSS2.1 §9.7 before it ever reaches this content, so it resolves fully via the same physical-offset mechanism a block-level positioned descendant already uses, reserving no column space). A float (float: left/right, or inline-start/inline-end) works in a vertical box as it does in a horizontal one, with the axes swapped: left and right are line-relative (CSS Writing Modes 4 §7.5), so in vertical-rl and vertical-lr float: left is pinned to the physical top and float: right to the physical bottom, whatever the direction. The float sits at the current block-axis position and slides along the inline axis, beside the floats already on its side while there is room and dropping toward block-end when there is not. Columns beside it start below a top float and stop above a bottom one, and clear moves a block past the block-end edge of the floats it clears. Not yet supported under a vertical writing mode: box-decoration-break: clone, real multi-column arrangement, a float’s own starting position when interleaved between vertical text runs (it resolves via the ordinary top-down block-flow mechanism, which is not physically meaningful for a box stacked along the block axis instead), clear on an ordinary (non-floated) block-level child of a vertical box’s own block content, and (for a table combining real per-column pagination with any of the five features above) real pagination together with that feature The legacy -webkit-/-moz- spelling is accepted as the same property. |
text-orientation |
text-orientation | mixed, upright, and sideways all parse, cascade, inherit, and are consumed by layout and painting under a vertical writing mode (under sideways-rl/sideways-lr the glyphs are always sideways, so the property has no effect). upright forces every character upright; sideways rotates every character 90°; mixed (the default) classifies each character by Unicode’s Vertical_Orientation property (UAX #50) and splits a run at each upright/rotated boundary, painting CJK ideographs, kana, and other U/Tu-classified characters upright while rotating everything else (Latin text, digits, etc.) — matching real vertical Japanese/Chinese/Korean typesetting, where CJK characters read upright down the column and embedded Latin/numeral runs rotate sideways. An upright character’s down-the-column advance consults the font’s own real OpenType vertical metrics (vhea/vmtx) when it carries them (mainly professional CJK vertical fonts), clipped to its own reserved cell so a glyph never bleeds into its neighbor’s; a font without real vertical metrics — the common case — falls back to approximating the advance as the font’s own line height. An upright character’s anchor position additionally consults the font’s real VORG vertical-origin table when it has one — CFF/CFF2 fonts only, per the OpenType spec’s own restriction; a TrueType-outline font’s VORG table (rare in practice) is never consulted, and such a font falls back to the plain top-of-cell anchor exactly as a font with no VORG at all does. A rotated (sideways) character always uses its natural horizontal advance rotated 90°, matching real browser behavior for sideways-oriented text The legacy -webkit-/-moz- spelling is accepted as the same property. |
hyphens |
hyphens | none, manual, auto are parsed, cascaded, and inherited. manual and auto both honor an explicit soft hyphen (­/U+00AD) as a line-break opportunity, rendering a literal - glyph only when that break is actually used. auto additionally performs real pattern-based automatic hyphenation (Liang’s algorithm) for ~73 languages — see the note below the table for language coverage and exclusions The legacy -webkit-/-moz- spelling is accepted as the same property. |
hyphenate-character |
hyphenate-character (CSS Text 4) | auto and an explicit <string> are both supported, for both automatic (hyphens: auto) and soft-hyphen breaks. auto always resolves to - (U+002D HYPHEN-MINUS) — this engine’s pre-existing hyphen glyph — rather than a per-content-language typographic convention (the spec only recommends, not requires, the latter). An explicit empty string (hyphenate-character: "") is honored: the line still breaks at the hyphenation point, with no visible glyph inserted |
hyphenate-limit-chars |
hyphenate-limit-chars (CSS Text 4) | The full [ auto \| <integer> ]{1,3} grammar (word / before / after minimums, with the 2nd/3rd defaulting as spec’d) is supported and enforced on every candidate break, automatic or soft-hyphen. An auto component imposes no additional constraint of its own beyond hyphens: auto’s underlying pattern data, which already carries its own per-language minimums (see the note below the table). Minimums are counted in UTF-16 code units, not typographic character units — the spec’s carve-out for nonspacing combining marks and intra-word punctuation not counting toward the minimum is not implemented |
hyphenate-limit-lines |
hyphenate-limit-lines (CSS Text 4) | no-limit and a non-negative <integer> are both supported: once a block has produced that many consecutive lines ending in a hyphen, the next line is never hyphenated (the word wraps whole instead), resetting the count as soon as a line closes without one. The count carries across a page/column break, so a run of consecutive hyphenated lines that straddles the boundary is still capped rather than restarting |
hyphenate-limit-last |
hyphenate-limit-last (CSS Text 4) | none, always, column, page, and spread are all supported: when the value forbids it, a hyphen that would otherwise end the last line before that kind of break is undone and the whole word moves to the fragmentainer the break resumes in instead — unless the hyphenated word was already alone on its line (a cheap proxy for “no narrower elsewhere would help”), in which case the hyphen is kept rather than deferred indefinitely with no benefit. This engine has no facing-page “spread” concept (no two-page layout), so spread behaves the same as page |
hyphenate-limit-zone |
hyphenate-limit-zone (CSS Text 4) | <length-percentage> (percentages resolve against the line box’s own length) is supported: a hyphenated break is only attempted when skipping it would leave more than this much space unfilled at the end of the line. The initial value 0 preserves this engine’s pre-existing behavior of always preferring a hyphenated break over any unfilled space at all |
letter-spacing |
letter-spacing | Full support, including negative values and <percentage> (resolved against the element’s own font size, since CSS Text 4 leaves the basis to the user agent); spacing is added after every character including the last (realized via the PDF Tc character-spacing operator, which applies to every glyph shown), and one letter-spacing unit is folded into the following inter-word gap so adjacent words never collapse together. Per CSS Text Level 3 §7.2, spacing is not suppressed at the start/end of a word — only at the start/end of a line, which this engine does not special-case, leaving each line’s own leading/trailing edge with a sub-pixel, visually negligible extra inset |
block-ellipsis |
block-ellipsis (CSS Overflow 4) | auto (the default …), none (the block still stops at line-clamp’s own line limit, but with no marker appended at all), and an explicit <string> (including an empty one, "" — same “no visible marker, still stops there” effect as none) are all supported, customizing the ellipsis line-clamp (below) generates. Meaningless without a line-clamp limit also set on the same box; does not honor a ::first-line font override for the marker itself, same as line-clamp’s own generated ellipsis. Inherited, per spec — unlike line-clamp itself (below), setting a custom marker once on an ancestor applies it to every clamped descendant that doesn’t redeclare it. This engine models it as its own standalone longhand rather than only as line-clamp’s two-value shorthand form (line-clamp: 3 "…more"), which is not accepted — set both properties separately instead |
line-clamp |
line-clamp | The single-value form, none or a positive <integer> limiting a block to that many visible lines. Unlike text-overflow below, this is a layout-time cutoff, independent of overflow: once a clamped block has produced that many lines, layout stops there — the block’s own height reflects only the visible lines, and everything after them is never laid out at all, not merely hidden. A generated ellipsis is appended to the end of the last visible line whenever content was actually truncated (never when the block’s content already fit within the limit), replacing only as many trailing words as it takes to make room for itself — the specified per-soft-wrap-opportunity granularity, not a character-level truncation the way text-overflow: ellipsis uses. Correctly limits line count and shrinks height under bidi/RTL horizontal text (the ellipsis’s own placement within a mixed-direction line is not yet bidi-aware) and counts line boxes belonging directly to the clamped block’s own formatting context; it does not clamp at all under a vertical writing-mode, does not accept the two-value <integer> <block-ellipsis> form (custom marker text/auto/none), and does not honor a ::first-line font override for the ellipsis itself. The legacy display: -webkit-box; -webkit-line-clamp: N; -webkit-box-orient: vertical idiom is not recognized as an alias for this property -webkit-line-clamp is accepted as the same property, so the legacy display: -webkit-box; -webkit-line-clamp: N; -webkit-box-orient: vertical idiom clamps to N lines (unlike a browser, -webkit-line-clamp also takes effect without display: -webkit-box). |
tab-size |
tab-size (CSS Text 4) | Both grammar forms are supported: a bare <number> (the default is 8) is a multiple of the space character’s own measured advance width, and a <length> is an absolute tab-stop width. Only meaningful where a tab character survives as literal content — white-space: pre and pre-wrap (normal/nowrap/pre-line already collapse a tab to an ordinary space before this property ever applies). Tab stops are measured from the true start of the tab’s own rendered line, correctly accounting for a pre-wrap soft wrap and for content contributed by an earlier sibling inline element on the same line. Negative numbers and lengths, and percentages (not part of the grammar), are all rejected (the property keeps its previous value) The legacy -webkit-/-moz- spelling is accepted as the same property. |
text-align |
text-align | left, right, center, justify, start, end. Under justify, a line that ends a paragraph is aligned by text-align-last (below) instead of being stretched, per CSS Text §6.1 — that is the block’s own last line and the last line before a forced break (<br>, or a preserved newline under white-space: pre/pre-wrap/pre-line), so a <br>-separated address block or verse stays ragged rather than being stretched line by line. A line that merely ends at a page or column boundary is neither, so it is justified like any other and the block resumes justified on the next page. A justified line’s leftover width is distributed over its justification opportunities only (CSS Text §6.4.5): word separators, plus the boundary between a CJK character and whatever sits next to it. Two adjacent inline boxes with no white space between them (A<span>B</span>, text beside an <img> or inline <svg>) stay contiguous, and a hyphen or other soft wrap opportunity inside a word is not an opportunity either. A line with no opportunity at all is unexpandable text (§6.4.3) and is aligned by text-align-last too (see that property’s own row for the one case where PeachPDF follows the spec’s letter rather than any browser); one whose content is already too long for its measure is start-aligned and spills past the end edge (§6.1) — the physical right edge under direction: ltr, the left under rtl. The spec’s third kind of opportunity — between the letters of a clustered (South-East Asian) script — is not offered, since this engine does not break those scripts into clusters in the first place. text-align is a real shorthand over text-align-all and text-align-last (below), per CSS Text §6.1: a plain value (any of the six listed here) sets text-align-all and resets text-align-last to auto; justify-all sets both to justify; match-parent sets both to match-parent. An inline-flex, inline-grid or inline-table box counts at its margin-box width towards its line under center, right and end, and moves with its items; under justify it is neither moved nor resized by the expanded spaces, and words are not stretched into it. |
text-align-all |
CSS Text §6.2 | The longhand text-align’s shorthand actually sets: left, right, center, justify, start, end, match-parent. match-parent behaves as inherit, except that an inherited start/end is interpreted against the parent’s own direction rather than this element’s, recursing through a chain of match-parent ancestors; on the root element it computes to start |
text-align-last |
text-align-last | auto, start, end, left, right, center, justify, match-parent (same parent-direction resolution as text-align-all above, recursing through a chain of match-parent ancestors — degrading to auto’s own behavior below when the nearest ancestor that actually declared text-align-last left it auto), inherited. Governs every line that ends a paragraph — the block’s last line and the last line before a forced break — plus any justified line with no justification opportunity to expand at (CSS Text §6.3 and §6.4.3). The initial auto defers to text-align-all, except under text-align-all: justify, where it is start instead: that is why an ordinary justified paragraph’s closing line is ragged. text-align-last: justify stretches those lines like any other, though a line with no justification opportunity to expand at cannot be stretched: per §6.4.3’s parenthetical (“If text-align-last is justify, then they must be aligned as for center”), PeachPDF centers it — a deliberate spec-literal choice that no browser engine (Chromium, Firefox, or Safari, all of which start-align here instead) follows. Applies to a vertical writing mode’s columns along their own inline axis as well |
text-decoration |
text-decoration | Shorthand supported The legacy -webkit-/-moz- spelling is accepted as the same property. |
text-decoration-color |
text-decoration-color | Full support The legacy -webkit-/-moz- spelling is accepted as the same property. |
text-decoration-line |
text-decoration-line | none, underline, overline, line-through, and any space-separated combination of them (e.g. underline overline draws both lines). A decoration declared on a block-level box decorates that box’s inline content — one line per line box, as wide as that line’s content — rather than the box’s full width, and it reaches the inline content of in-flow block-level descendants too; floated and absolutely positioned descendants are not decorated, and the decorating box’s own padding and border are skipped while a descendant inline box’s are not. A replaced element (an <img>, an inline <svg>) is never decorated, even when it is blockified as a flex or grid item of the decorated box. An atomic inline — an image, an inline-block, an inline-table — inside a decorated box is not decorated by it: the line breaks around its margin box and resumes after it (which box edge bounds that gap is left to the user agent by the spec; this engine uses the margin box, so an atomic inline’s own margins are undecorated too). An inline-block, inline-table, inline-flex or inline-grid box that itself declares a decoration — a { display: inline-block; text-decoration: underline } — is decorated like any other block container: only its own inline content is underlined, so an icon-only one with no text has no line at all. An <input> or <select> that declares a decoration gets no line: a control’s value is not drawn as page text, so there is nothing for the line to sit under (a <textarea>’s value is drawn by the control too, so it gets none either). A decorated inline-block or block that clips its overflow (overflow: hidden or clip, on either axis) has its line clipped with its text. Under a vertical writing-mode (vertical-rl/vertical-lr), the line runs the correct physical axis — down the column, on the correct side of the glyphs — rather than across it; the atomic-inline break-around above is not yet extended to that axis, an inline-block’s own decoration is only a short line at the box’s left edge rather than an underline down each column of text, and neither is text-decoration-skip-ink below for an upright run (a rotated run’s skip-ink is, per that property’s own row) The legacy -webkit-/-moz- spelling is accepted as the same property. |
text-decoration-skip-ink |
text-decoration-skip-ink | auto, none, all. auto — the initial value — interrupts an underline or overline wherever it would cross a glyph’s ink, leaving a gap either side that grows with the decoration thickness (up to a cap); none draws the line straight through. A line-through is never interrupted, per spec. The line breaks once per glyph, across everything that glyph puts in the line’s path — so a letter whose ink the line meets in several places, such as the two sides of an o or the bowl of a g, gets one gap spanning the whole letter rather than one per stroke, and no fragment of line is left stranded inside it. The spec leaves this choice of shape to the user agent; this matches what browsers do. Under auto, CJK-script text (Han, Hiragana/Katakana, Hangul) is never interrupted regardless of where its ink falls — the spec calls this out explicitly as UA discretion a conforming engine should exercise; all has no such exception and always interrupts. Under a vertical writing-mode, skipping applies to a rotated (sideways) run — one ordinary horizontal glyph run reoriented as a whole, so its ink reduces to something this engine’s existing band-sweep math can measure once mapped into that run’s own pre-rotation orientation — but not yet to an upright run (stacked character-by-character down the column, with no such single horizontal layout to reduce to; see the vertical-writing-mode note on text-decoration-line above) The legacy -webkit-/-moz- spelling is accepted as the same property. |
text-decoration-style |
text-decoration-style | solid, dashed, dotted, double, wavy. double draws two strokes; each is a third of the resolved text-decoration-thickness, with a gap the same size between them, so the pair’s total span matches the declared thickness exactly — the same “sum of the two lines and the space between them” rule the equivalent border-style value follows. Below roughly one CSS pixel per third, each stroke is held at that minimum instead (so a very thin double rule reads as two clearly visible hairlines rather than fading into illegibility), and the pair’s total then exceeds the declared thickness. The pair keeps the first stroke where a single one would sit and grows the second away from the text: downward for an underline and a line-through, upward for an overline, matching browsers. A double overline’s upward growth is accounted for during layout — the line reserves the extra headroom it needs, so it renders correctly even flush against the top of a page, with no author-side margin required. wavy strokes a curved path rather than a dash pattern — its centerline is offset from a single stroke’s position the same direction double’s second stroke grows, by about one resolved thickness; the exact wave shape (amplitude, wavelength) is left to the user agent by the CSS Text Decoration Module Level 3 itself, not merely by this engine’s own choice — browsers do not render an identical wave either. Not yet extended to a true vertical writing-mode: a wavy decoration there falls back to a plain solid line rather than a mispositioned wave. Decoration styles on SVG text follow the same rules as HTML except double, which SVG still paints as a single stroke The legacy -webkit-/-moz- spelling is accepted as the same property. |
text-decoration-thickness |
text-decoration-thickness | auto, from-font, and <length>/<percentage> (percentages resolve against the element’s own font size). auto — the initial value — uses this engine’s own fixed decoration-line thickness rather than deriving one from the font; from-font reads the resolved font’s real OpenType underlineThickness metric (falling back to the same fixed thickness auto uses for a font that reports none). An automatically positioned underline keeps its top edge below the alphabetic baseline; the gap grows with thick strokes, so increasing text-decoration-thickness does not make the line expand upward through ordinary glyphs. The baseline it hangs from is the one the decorated line’s own text is set on, so a line mixing font sizes — a small label beside a large figure, say — is underlined under all of it rather than at the height one of the sizes would give; text raised or lowered by vertical-align, such as a superscript, does not move the line |
text-underline-offset |
text-underline-offset | auto and <length>/<percentage> (percentages resolve against the element’s own font size — “1em”). auto — the initial value — is a zero offset: the underline sits exactly where text-underline-position places it. A positive value moves the underline further from the text (down, for the auto/from-font/under positions this engine implements); a negative value moves it closer. Inherited, like text-underline-position. Only affects underline, never overline/line-through, per spec. Unlike a double overline’s upward growth (see text-decoration-style below), an unusually large offset (or under on deep-descender text) is not yet reserved space by layout, so it can clip against a page’s own bottom edge on a line flush against one |
text-underline-position |
text-underline-position | Full grammar support: auto \| [ from-font \| under ] \|\| [ left \| right ]. auto — the initial value — is this engine’s own existing automatic clearance below the alphabetic baseline. from-font reads the resolved font’s real OpenType underlinePosition metric instead, used as-is even when it is exactly 0 (at the baseline) rather than falling back to auto’s clearance — 0 is itself a plausible authored metric, not a clear “no data” signal. under positions the underline below the line’s own lowest descender rather than at a fixed baseline-relative clearance, so it does not cross descenders. left/right pin the underline to that literal physical edge instead of the writing mode’s own default under/over mapping; if that pins the underline where an overline would otherwise draw, the overline moves to the opposite physical edge instead of overlapping it. left/right, and the from-font/under-vs-baseline distinction, are meaningful only under a vertical writing-mode — under horizontal-tb a left/right value parses but has no visible effect (there is no physical left/right edge distinct from the line’s own inline extent there) |
hanging-punctuation |
hanging-punctuation | first is applied: an opening bracket or quote (Unicode categories Ps, Pi, Pf, plus ' and ") at the start of a block’s first line hangs outside the start edge, is not measured (the line gains its width), and works for both LTR and RTL. A start border or padding on an inline that opens the line prevents hanging. force-end and allow-end hang a stop or comma (U+002C, U+002E, U+060C, U+06D4, U+3001, U+3002, U+FF0C, U+FF0E, U+FE50-FE52, U+FF61, U+FF64) that ends a line outside the end edge: it is left out of the fit test (a word that overflows only by that character stays on the line; force-end also hangs it when the word fits) and out of right/centre alignment and justification. last hangs a closing bracket or quote (Pe, Pi, Pf, ', ") at the end of the block’s last line the same way, though it does not change where the last line breaks. A border or padding closing the inline after the character prevents hanging. The end-edge values apply to left-to-right horizontal lines only |
text-indent |
text-indent | Full support, including the hanging and each-line keywords (CSS Text 3) |
text-overflow |
text-overflow | clip, ellipsis. Applies per line box that overflows its container’s content edge, in any writing mode/direction — not only a forced single-line (white-space: nowrap) box, and only when that container’s own overflow is hidden (see the overflow row in Display & Layout below — auto/scroll don’t establish a real clip in this non-interactive renderer either). The two-value edge syntax, a custom <string> replacement, and fade() are non-standard/experimental (see MDN) and not implemented. A text decoration (underline, overline, line-through) on a truncated line covers the kept text only and ends where the ellipsis begins; in a right-to-left block whose line starts with a plain (undecorated) box, a decoration on a later box can run past the ellipsis. See line-clamp above for the related but distinct multi-line, layout-time truncation feature |
text-shadow |
text-shadow | Comma-separated list of shadows, each <color>? <offset-x> <offset-y> <blur-radius>?; the first-listed shadow paints on top, an omitted colour is the text’s own color, and the property is inherited. Offsets and blur may use any <length> unit or a calc() expression; an unrecognised unit makes the declaration invalid. A shadow with no blur is the text drawn again at the offset, in vector. A blurred shadow is a real Gaussian blur (the blur radius is twice the standard deviation) rendered into a bitmap beneath the text, so the text itself stays vector and selectable; see Rasterized effects. Shadows are painted for horizontal text only, and not for text decorations (underline, line-through). |
text-transform |
text-transform | none, uppercase, lowercase, capitalize. full-width converts ASCII characters, space, Latin-1 currency/symbol characters, and the halfwidth katakana, Hangul jamo and symbol forms to their fullwidth (normal-width) characters; a halfwidth voiced-sound mark becomes the combining mark rather than composing into the preceding kana. full-size-kana is not supported |
overflow-wrap / word-wrap |
overflow-wrap | normal, break-word, anywhere; inherited. word-wrap is the legacy name alias and shares the same computed value. break-word and anywhere break an otherwise-unbreakable token at extended grapheme-cluster boundaries only when no ordinary wrapping or hyphenation opportunity fits the line, without inserting a hyphen. An ordinary opportunity before a token therefore takes priority over an emergency break inside it. They work in horizontal and vertical writing modes and have no effect when white-space forbids wrapping. As specified, anywhere’s emergency opportunities participate in min-content sizing (including table, flex, grid, and shrink-to-fit sizing), while break-word’s do not |
white-space |
white-space | normal, nowrap, pre, pre-wrap, pre-line |
line-break |
line-break | auto, loose, normal, strict and anywhere; inherited. Sets how strictly characters that should not start a line are kept off it, as the Unicode line breaking algorithm is tailored for it: strict also keeps a small kana, an iteration mark and a prolonged sound mark off the start of a line, normal (which auto is) allows the wave dash and the katakana double hyphen there, loose also allows small kana, iteration marks and a hyphen after an ideograph, and anywhere lets a line end after every character (grapheme cluster), which also makes a word’s min-content width a single character. Where the writing system is Chinese or Japanese, as the lang attribute of the element or an ancestor says (ja, zh and their variants; PdfGenerateConfig.DefaultLanguage for a document that declares none), the wave dash and the katakana double hyphen may start a line in normal and loose, and loose also lets a line start with the middle dot, the colon and semicolon of CJK text and the fullwidth and double exclamation and question marks, end before a suffix such as % or ℃, and end after a prefix such as ¥ or № (the degree, plus-minus and currency signs of Latin-1, which browsers leave out too, stay with their number). In any other language, or with none, those breaks stay forbidden, so loose does not break before a fullwidth exclamation mark after Latin text. loose never breaks between a word and the ellipsis after it, only between two ellipses |
word-break |
word-break | normal, break-all, keep-all. Where a line may end inside a run of text is decided by the Unicode line breaking algorithm: between ideographs and kana, after hyphens and spaces, and nowhere the algorithm forbids (before closing punctuation, after opening punctuation, inside numbers). Thai, Lao, Khmer and Burmese, which write no spaces between words, also break between the words a word list finds (the script decides, not lang), never inside a syllable, and those breaks stay under keep-all, line-break and overflow-wrap as any other; a compound the list has as a word is not split, and a stretch of text no word of the list matches stays whole. break-all also allows a break between letters and digits, and keep-all forbids breaks between ideographs, Hangul and letters. Beyond the algorithm, no break is offered between !, / or \| and a following letter, so !important and and/or stay whole. Text in separate inline elements is analysed as if it were one run: the last word of one element and the first of the next are decided by the same algorithm, with any white space the markup collapsed between them, so an element boundary alone neither creates nor removes a break (foo<b>bar</b> stays whole, <b>well-</b><i>known</i> breaks after the hyphen) |
word-spacing |
word-spacing | Full support. The space between two words is written to the PDF as a real space character, drawn without ink in the gap the layout reserved - in horizontal text normally right after the word before it, as a browser writes it (the gap itself, including word-spacing, letter-spacing and justification, is unchanged, and so is where every word is drawn). Text copied or extracted from the PDF therefore keeps its word breaks, also for readers that take the text in drawing order rather than rebuilding gaps from glyph positions. Where the font draws a no-break space with its space glyph, as Arial does, a no-break space extracts as an ordinary space once that font has also drawn one. No space is written at the start of a line, where the space was removed, in front of an image or other replaced element, nor in a font that has no space glyph |
Text shaping
PeachPDF applies essentially all of OpenType Layout a font can exercise, including Arabic-family complex-script joining, the Universal Shaping Engine (USE) syllable reordering used by Devanagari, Bengali, Gujarati, and Tamil, and Khmer’s own separate coeng/subjoined-consonant shaping: GSUB single, multiple, alternate, ligature, contextual/chaining-context, and reverse-chaining single substitution (including a contextual/chaining-context rule whose own target is another contextual/chaining-context lookup, nested to an arbitrary depth); GPOS kerning, cursive attachment, and mark positioning; a real Unicode Bidirectional Algorithm (UAX #9); Arabic/Syriac-family joining-form resolution and shaping; conjunct/reph formation with syllable-level glyph reordering for the four USE-shaped scripts above; and Khmer’s own coeng-driven subjoined-consonant stacking. Indic script reordering beyond those four USE scripts (other USE-driven Brahmic scripts, e.g. Gurmukhi, Kannada, Malayalam, Oriya, Telugu) is not implemented. In practice:
- Ligatures are formed automatically from a font’s
GSUBliga/clig(“common ligatures”) andrlig(“required ligatures”) features — e.g.f+fbecomes a font’sffligature glyph when the font defines one, the same way a browser renders it, not only when the source text already contains a precomposed ligature codepoint (e.g.fi, U+FB01).font-variant-ligaturescontrols this:normal(the initial value) andcommon-ligaturesenableliga/clig,noneandno-common-ligaturesdisable them,discretionary-ligatures/historical-ligatures(and theirno-*forms) enable/disable a font’sdlig/hligfeatures the same way, andcontextual/no-contextualenable/disable a font’scaltfeature — but per specrlig(“required ligatures”) is never affected by this property, not even bynone, since a font’s required ligatures aren’t a stylistic choice. - Glyph composition/decomposition and localized forms (
ccmp,locl) are applied to all text, for every script, without being requested — they are default-on features in the OpenType feature registry, not stylistic opt-ins, so a font that decomposes a precomposed letter into base + mark, or that keeps its emoji-sequence ligatures inccmprather thanliga, is honored. This matters most for modern color-emoji fonts: Noto Color Emoji’s entireGSUBfeature list is a singleccmprecord, and that is where its regional-indicator (country flag), tag-sequence, and ZWJ-sequence ligatures live. - Caps, numeric, East Asian, positional variants, and multiple substitution (
font-variant-caps,font-variant-numeric,font-variant-east-asian,font-variant-position, and arbitrary tags viafont-feature-settings) apply a font’sGSUBSingle, Multiple, or Alternate Substitution features the same way — see the property table above for each one’s specific OpenType tags and fallback behavior. - Contextual/chaining-context substitution (GSUB lookup types 5/6,
caltand anyfont-feature-settingstag a font routes through one) supports all three OpenType subtable formats (glyph-sequence, class-based, and coverage-based), including a font’slookupFlag/GDEFmark filtering — an intervening non-participating glyph (e.g. a diacritic) inside the matched backtrack/input/lookahead window is correctly skipped rather than breaking the match, the same way ligature substitution’s own component matching already did. - Reverse-chaining context single substitution (GSUB lookup type 8) is applied end-to-start, per spec, for the rare font that defines it.
- Default-ignorable characters — variation selectors (
U+FE00–U+FE0F,U+E0100–U+E01EF),ZWJ/ZWNJ, the bidi controls, word joiner, the Hangul fillers, and the language tag characters (U+E0000–U+E007F) — draw nothing when the font has no glyph for them, rather than falling through to the font’s missing-glyph box. That is what Unicode’sDefault_Ignorable_Code_Pointproperty means, and a font is expected not to cover them. They are still fully visible to shaping first, so a font that does map one (and ligates through it, as emoji ZWJ sequences do) behaves exactly as authored, and a ligature whose component list skips over one — e.g.U+1F3F3 U+FE0F U+200D U+1F308against a flag + ZWJ + rainbow rule — still forms. U+FE0E/U+FE0F additionally steer font selection between a text and an emoji form — seefont-variant-emoji. - Kerning, cursive attachment, and mark positioning (
GPOS) apply a font’s pair/class kerning (kern, gated byfont-kerning), cursive joining (curs, requested for Arabic-family joining text — see below), and mark-to-base/mark-to-ligature/mark-to-mark diacritic attachment (mark/mkmk, always applied when the font defines it — not a stylistic opt-out), including contextual/chained-contextual positioning (GPOS lookup types 7/8) nesting any of these. - Per-language feature selection: GSUB feature selection uses the script’s language-specific
LangSyswhen the element’s resolved language (its ownlangattribute, or its nearest ancestor’s, per the HTML “language of a node” algorithm) maps to a recognized OpenType language tag, falling back to the script’s default language system otherwise. - Arabic-family complex-script shaping (Arabic, and other joining scripts sharing its state machine, e.g. Syriac) resolves each character’s initial/medial/final/isolated joining form from its Unicode
Joining_Type/Joining_Group(per UAX #53 semantics) and requests a font’sinit/medi/fina/fin2/fin3/med2/isolGSUB features accordingly, ahead of the general ligature/contextual pass (so a font’s ownrligligatures — e.g. Arabic’s lam-alef — see the already-joining-form-selected glyphs, matching real shaping engines’ own staging) and a font’scursGPOS feature (cursive baseline attachment, for fonts whose joining relies on it rather than purely on positional substitution). Shaping itself always runs in true logical (source) order — matching real shaping engines — with only the resulting glyph list reversed for right-to-left display, since Arabic is a strong-RTL script and reads right-to-left regardless of the surroundingdirection. - Universal Shaping Engine (USE) syllable reordering (Devanagari, Bengali, Gujarati, Tamil) classifies each character’s Unicode
Indic_Syllabic_Category/Indic_Positional_Categoryinto a syllable structure (consonant, conjunct-forming halant sequence, dependent vowel sign, vowel modifier — plus, for Bengali specifically, its own Consonant Placeholder and Syllable Modifier categories, neither reachable from any Devanagari/Gujarati/Tamil codepoint), applies a font’s default glyph pre-processing (locl/ccmp/nukt/akhn), forms reph from a word-initial RA+virama sequence via the font’s ownrphffeature, applies the font’s conjunct/half-form/subjoined-form features (rkrf/abvf/blwf/half/pstf/vatu/cjct), then reorders the resulting glyphs — repositioning a formed reph forward past the base consonant, and moving a pre-base dependent vowel sign (e.g. a vowel sign I) backward to immediately follow the base — before a final pass applies the font’s own presentation features (abvs/blws/haln/pres/psts). Gujarati and Tamil need no script-specific classifier/grammar code beyond what Devanagari already exercises; other USE-driven Brahmic scripts (Gurmukhi, Kannada, Malayalam, Oriya, Telugu, and the rest of the family) are not yet supported. - Khmer’s own coeng/subjoined-consonant shaping uses a separate, older shaping model from the Universal Shaping Engine above (the two share only their raw
Indic_Syllabic_Category/Indic_Positional_Categoryinput data), since Khmer’s own syllable grammar and reorder rules are script-specific rather than a member of the Devanagari-family USE grammar. A coeng (U+17D2) marks the consonant after it as subjoined: a coeng+RO pair is repositioned before the syllable’s base consonant and takes the font’spref(pre-base forms) feature, while a coeng+any-other-consonant pair stays in logical order and takesblwf(below-base forms); a pre-base dependent vowel sign is likewise moved to the syllable’s own start. The font’s default glyph pre-processing (locl/ccmp) and remaining basic features (abvf/pstf) apply globally, followed by its presentation features (pres/abvs/blws/psts). A handful of Khmer vowel signs that HarfBuzz’s own reference shaper decomposes into two glyphs at normalization time (U+17BE,U+17BF,U+17C0,U+17C4,U+17C5) are not decomposed here — a font that expects the decomposed pair rather than its own precomposed glyph for one of these five signs renders it as that glyph directly instead. - Arabic-family joining and Devanagari USE shaping (not yet Bengali/Gujarati/Tamil/Khmer) also apply to SVG
<text>/<tspan>content, resolved over each<text>element’s own flattened character stream the same way — a maximal run of mutually-joining or same-syllable-participating characters (sharing one element, zeroletter-spacing, and no explicit per-characterx/y/dx/dy/rotateon any but the run’s own first character — see Text) shapes and, for a right-to-left run, display-reverses as one atomic unit, matching HTML’s own per-word treatment. Not supported for<textPath>(glyphs there are still isolated nominal forms, positioned individually along the path) or under a verticalwriting-mode(Arabic and Devanagari are never vertically typeset in practice). - Bidirectional text implements the full Unicode Bidirectional Algorithm: paragraph/embedding level resolution (including the
dir="auto"first-strong-character heuristic and<bdi>’s isolation), per-character reordering of mixed-direction runs within a line (not just whole-word reordering), correct placement of neutral/weak runs (digits, punctuation) against their strong-direction neighbors, and mirroring of paired characters (parentheses, brackets, etc.) in a right-to-left run. This applies uniformly to HTML text layout, inline and standalone SVG<text>/<tspan>, and@pagemargin-box content (headers/footers, counters, named strings).
SVG <text>/<tspan>/<textPath> request the same GSUB/GPOS features HTML text does — font-variant-ligatures/-caps/-numeric/-east-asian, font-feature-settings, font-kerning-gated GPOS kerning, and per-language LangSys selection (the bullet above, resolved from an element’s own lang/xml:lang attribute or its nearest ancestor’s — including an inline <svg>’s surrounding HTML document, when nothing in the SVG’s own tree sets one) are all supported per-element (see Text for the full property list and this feature’s own known simplifications, e.g. no small-caps synthesis fallback). <text>/<tspan> additionally get real Arabic-family joining and Devanagari USE reordering, per the bullets above.
hyphens: auto language coverage
auto performs real pattern-based automatic hyphenation only when the text’s language is known: PeachPDF resolves each element’s own language per the HTML “language of a node” algorithm — its own lang attribute if set, else its nearest ancestor’s, else <html lang="...">, else a calling application’s PdfGenerateConfig.DefaultLanguage fallback for documents that declare no language at all. With no language available from any source, auto behaves like manual (no algorithmic hyphenation) rather than guessing. A declared language resolves to the closest available pattern set — the tag itself, then progressively shorter subtag prefixes (e.g. de-AT falls back to de’s default variant, de-1996) — and a language with no match anywhere in that chain silently falls back to the same no-op rather than erroring.
auto is also a no-op on a host with no working Brotli decoder, because the pattern data is Brotli-compressed — text lays out unhyphenated rather than the render failing. This affects only a host where System.IO.Compression.BrotliStream itself doesn’t work (WebAssembly in a browser, at the time of writing) and that hasn’t registered a decoder of its own with PeachDrawing.Text.Compression.BrotliDecompression.SetDecompressor; see PeachDrawing.Text for that seam and the ready-made PeachDrawing.Text.Brotli decoder for it.
Each language’s pattern data carries its own minimum characters before/after a break (2/3 by default, TeX’s own values, when a pattern file doesn’t state otherwise); hyphenate-limit-chars layers on top of — not instead of — those, and an auto component in hyphenate-limit-chars leaves the pattern’s own minimum as the only constraint on that side.
Pattern data is sourced from CTAN’s hyph-utf8 package (see tools/Update-HyphenationPatterns.ps1 for the reproducible download/build pipeline). PeachPDF ships only permissively licensed pattern sets (MIT/LPPL/BSD-style/public-domain), consistent with the library’s own license — 73 languages/scripts are supported:
Afrikaans (af), Albanian (sq), Ancient Greek (grc), Assamese (as), Basque (eu), Belarusian (be), Bengali (bn), British English (en-GB), Bulgarian (bg), Catalan (ca), Chinese pinyin/Mandarin romanization (zh-Latn-pinyin), Church Slavonic (cu), Coptic (cop), Croatian (hr, via Serbo-Croatian Latin patterns), Danish (da), Dutch (nl), Esperanto (eo), Estonian (et), Finnish (fi, plus a fi-x-school school-method variant), French (fr), Friulan (fur), Galician (gl), Georgian (ka), German — traditional (de-1901), reformed/modern (de-1996, the default for bare de), and Swiss traditional (de-ch-1901, the default for de-CH), Gujarati (gu), Hindi (hi), American English (en-US, the default for bare en), Icelandic (is), Interlingua (ia), Irish (ga), Italian (it), Kannada (kn), Kazakh (kk), Kurmanji/Northern Kurdish (kmr, also the default for bare ku), Latin — modern/medieval (la), classical (la-x-classic), and liturgical (la-x-liturgic) variants, Lithuanian (lt), Malayalam (ml), Marathi (mr), Modern Greek — monotonic (el-monoton, the default for bare el) and polytonic (el-polyton), Mongolian, Cyrillic script (mn-Cyrl, the default for bare mn), Norwegian Bokmål (nb, also the default for bare no), Norwegian Nynorsk (nn), Occitan (oc), Oriya (or), Panjabi (pa), Pāli (pi), Piedmontese (pms), Polish (pl), Portuguese (pt, shared for pt-BR/pt-PT), Romansh (rm), Russian (ru), Sanskrit and Prakrit, Latin transliteration (sa), Serbo-Croatian — Cyrillic (sh-Cyrl, also the default for bare sr) and Latin (sh-Latn, also the default for bare bs) scripts, Slovak (sk), Slovenian (sl), Spanish (es), Swedish (sv), Tamil (ta), Telugu (te), Thai (th), Turkish (tr), Turkmen (tk), Ukrainian (uk), Upper Sorbian (hsb), Welsh (cy), and languages written in the Ethiopic script (mul-Ethi, covering Amharic am and Tigrinya ti).
Explicitly excluded — these languages have hyphenation patterns in the upstream hyph-utf8 package, but PeachPDF does not ship them because the pattern file itself is GPL/LGPL-licensed (copyleft, stronger obligations than PeachPDF’s own license) or states no license at all. hyphens: auto is a silent no-op for these languages exactly as if no pattern data existed for them at all:
| Language | Tag | Reason |
|---|---|---|
| Armenian | hy |
LGPL 3.0 |
| Czech | cs |
GPL 2+ |
| Hungarian | hu |
LGPL 2.1 |
| Indonesian | id |
GPL 2 |
| Latvian | lv |
GPL 2+ |
| Macedonian | mk |
GPL |
| Romanian | ro |
No license stated in the source file |
| Serbian, Cyrillic script | sr-Cyrl |
GPL |
Regenerating the pattern set (tools/Update-HyphenationPatterns.ps1) re-checks each language’s license against the same permissive-only rule on every run, so a language is only ever added back automatically if upstream re-licenses it.
Display & Layout
| Property | MDN Reference | Notes |
|---|---|---|
display |
display | block, inline, inline-block, none, flex, inline-flex, grid, inline-grid, table, table-row, table-cell, table-header-group, table-footer-group, table-row-group, table-column, table-column-group, table-caption, list-item, flow-root (a block that establishes its own formatting context, so it contains its floats and its margins do not collapse with its children’s), contents (see display: contents below) -webkit-flex and -webkit-inline-flex are accepted as flex and inline-flex. The pre-standard -webkit-box / -webkit-inline-box (and -moz-box / -moz-inline-box) are accepted too: a box is a flex container (inline-flex), configured by the legacy -webkit-box-orient, -webkit-box-direction, -webkit-box-pack and -webkit-box-align (translated onto flex-direction, justify-content and align-items) and by its items’ -webkit-box-flex and -webkit-box-ordinal-group (aliases of flex-grow and order) — except that a vertical box (-webkit-box-orient: vertical / block-axis) is a plain block (inline-block), which is how it stacks its children and what lets the -webkit-line-clamp idiom clamp its text. A vertical box’s -webkit-box-flex children do not stretch to fill its height, and -webkit-box-direction: reverse is ignored on it. |
position |
position | static, relative, absolute, fixed. A fixed box’s containing block is the page area (CSS 2.1 §10.1: the viewport in continuous media, the page area in paged media), so top/left are measured from the page’s content corner — top: 0; left: 0 sits at the top-left of the content area, inside the @page margins, and left: 0; right: 0 spans the full measure. Percentages resolve against that same area, per page: on a document whose pages have different margins or sizes, a fixed box follows each page’s own area rather than holding one absolute position on the sheet. sticky (CSS Positioned Layout 3 §3.4) is treated as relative with a zero offset in paged media, since there is no scroll to ever cross a sticky threshold against — it participates in normal flow and in stacking/z-index like a positioned box, but its top/right/bottom/left values never shift it. On a continuous MediaType.Screen surface it works: it follows the viewport the host reports with HtmlDocument.SetViewport, held by its non-auto insets and never leaving its containing block (a box inside an overflow container does not stick, as nothing scrolls there), and a fixed box stays where it was put in that viewport. running(<custom-ident>) (css-gcpm-3) removes the element from normal flow entirely, making it available to a page margin box via content: element(<custom-ident>) — see Running elements -webkit-sticky is accepted as sticky. |
float |
float | left, right, none, the flow-relative inline-start/inline-end (CSS Logical Properties §2.2, which float to the line-left or line-right side of the containing block according to its direction — ltr puts inline-start on the left, rtl on the right — and stay the computed value rather than being rewritten to left/right), footnote (css-gcpm-3), top, bottom, top-bottom, snap, inside, outside (css-page-floats) — see Page floats for the last six. footnote removes an inline-level element from normal flow entirely and routes its content to the page’s - or, with float-reference: column, its own column’s - footnote area, the same “remove from flow” idea position: running() uses for margin boxes; see Footnotes. A left/right/inside/outside float shares one inline formatting context with the surrounding inline content of the block it is in regardless of source order — placed beside that content’s line boxes, with text wrapping around it, whether the float precedes or follows the content on the same line (CSS 2.1 §9.5, §9.5.1 rule 6). A shrink-to-fit box holding both (a float, an auto-width position: absolute box, an auto table column) is sized for the text and the float side by side, as are two adjacent floats — which are themselves placed side by side correctly. Floats never overlap (§9.5.1 rules 1-3): a float that does not fit beside an earlier one of either side goes below it, and a float is never placed higher than an earlier float (rule 5), within one formatting context (what a float does inside a box that is a formatting context of its own does not affect floats outside it). Two floats whose widths add up to the container only to within rounding (such as 66.6667% and 33.3333%) stay on one row. A float that clears earlier floats goes below them by its own outer edge, so its margin-top sits between them and it. Relative positioning is visual only: a float is placed against where earlier floats are in the flow, not where an offset draws them (text beside a float in an offset box, and a float that is itself position: relative, are known differences). A right float that does not fit beside an inline-block (or inline-flex, inline-grid, inline-table) already on its line goes below the line. Known differences from a browser: a float that has to move down goes below the lowest float it met on the way rather than the first bottom edge that makes room; a left float after such an inline-level box is placed at the start of the line over it, where a browser moves the box aside; and inside a multi-column container a float can still be placed above an earlier float’s top. A box that establishes its own formatting context and takes its height from content — a float, an overflow other than visible, an inline-block, an absolutely/fixed-positioned box, a table cell or caption, a flex or grid item, a multi-column container — contains its floats: its height grows to cover any floating descendant hanging below its content (CSS 2.1 §10.6.7), which is what makes the overflow: hidden containment idiom work. An ordinary block correctly does not, so a float still overhangs it. display: flow-root, css-display-3’s dedicated way to ask for containment, does the same without clipping or positioning anything. A block-level left/right/inside/outside float whose own content is taller than fits the remaining page continues onto the following pages: it breaks between its own lines and blocks like any other block, and the text that flows beside it carries on beside the continuation on every page the float reaches, with content that clears the float following its last line. A break-inside: avoid box that holds such a float is not moved whole to the next page, since that would drop what the float already placed on the earlier pages; the avoid is relaxed for it. A float met inside a line of inline content, a scroll container’s and an inline-block’s tall content still lay out unbroken, each page showing the slice that falls in it. Documents that combine a tall float with several other features can still lose text. Not yet handled: text beside a float is measured against the float’s border box rather than its margin box, so a line beside a float with a margin can start inside that margin |
shape-outside |
shape-outside | Text beside a float follows the float’s shape instead of its rectangle. none (default), circle(), ellipse(), inset() (with round), polygon() and path(), with an optional <shape-box> (margin-box by default, unlike clip-path; also border-box, padding-box, content-box), a bare <shape-box>, or a url() image whose alpha channel is the shape. An image is stretched over the float’s content box and a pixel belongs to the shape when its alpha is above shape-image-threshold; it must be a raster image (an SVG or gradient is not read, and the float keeps its rectangle). path() coordinates are CSS pixels from the reference box’s top-left corner. A line is narrowed to the widest reach the shape has anywhere in the line’s own height, matching browsers, and the shape is clipped to the float’s margin box. Not inherited. The shape applies to lines laid out in the ordinary flow beside the float; a multi-column container placed beside a floated shape and vertical writing modes still use the float’s rectangle. |
shape-image-threshold |
shape-image-threshold | A <number> from 0 to 1 (clamped), 0 by default: the alpha above which a pixel of a shape-outside image is part of the shape. Not inherited. |
shape-margin |
shape-margin | Grows the shape-outside shape outward by this <length-percentage> (a percentage is of the containing block’s width) before text is kept clear of it. 0 by default. Not inherited. Has no effect without a shape-outside shape. |
float-reference |
css-page-floats | inline (initial), column, region, page - which fragmentation context a float is positioned relative to. Consumed by a float: footnote source and by a top/bottom/top-bottom/snap page float. column routes a footnote into the note area of the column its reference landed in (see Footnotes), and pins a page float to the block-start or block-end edge of that column, reserving room for it there and only there (see Page floats); outside a multi-column container it falls back to the page. page is the page-level behavior, and inline (the initial value) and region resolve against the page too - a footnote has no inline note area to fall back to, a page float keeps the page-level default documents written for other page-float implementations expect, and PeachPDF implements no part of CSS Regions. MDN has no page for this property, so the specification is linked directly |
footnote-display |
css-gcpm-3 | block, inline, compact — how a float: footnote body stacks in the note area; see Footnotes |
footnote-policy |
css-gcpm-3 | auto, line, block — forces a page break when a footnote’s own note area can’t be placed on the page its call landed on; see Footnotes |
clear |
clear | left, right, both, none, and the flow-relative inline-start/inline-end (CSS Logical Properties §2.2), which clear the floats on that side of the containing block according to its direction, as float: inline-start/inline-end place them |
overflow |
overflow | Affects clipping regions; there is no interactive scrolling in PDF output. On a screen (MediaType.Screen) auto, scroll and hidden make a scroll container: the box clips to its padding edge and its content scrolls, and auto and scroll draw thin scrollbars. In a paged document auto and scroll show the whole of their content, as a printed page does. hidden clips descendant content to the padding edge, following any border-radius on the clipping box (rather than clipping to a rectangle regardless of rounding) — the curve’s own radius is reduced by the border width the same way background-clip: padding-box is, above. As in CSS Overflow 3, an out-of-flow positioned descendant is clipped only by overflow: hidden boxes on its containing block chain: an absolutely positioned descendant whose containing block (its nearest positioned ancestor) lies outside the overflow: hidden box escapes its clip, and a position: fixed descendant is not clipped by any overflow box unless one with a transform other than none (an identity one such as translateZ(0) or scale(1) included), perspective, filter or backdrop-filter forms its containing block. Give the overflow: hidden box position: relative to make it clip its absolutely positioned descendants. overflow is the shorthand of overflow-x and overflow-y, and takes one value for both axes or two (x then y). clip clips to the same edge as hidden but makes no scroll container, so the box still breaks across pages like any other block. An axis that is hidden, scroll or auto makes the box clip on both axes, a visible partner computing to auto as the specification says. A clip axis beside a visible one clips on its own axis only, so the other direction’s overflow still shows. A box that clips one axis has square corners there: border-radius does not round that clip. |
visibility |
visibility | visible, hidden, collapse — on a table row, row group, column, or column group, collapse removes it from the table’s geometry entirely (the rows/columns after it shift in to fill the gap), distinct from hidden, which reserves the element’s layout space and only omits painting it |
z-index |
z-index | Full support for positioned elements |
display: contents
An element with display: contents generates no box of its own: for box generation and layout it is
treated as if it had been replaced by its children and its ::before/::after
(CSS Display 3 §2.5). A flex or grid
container’s items are therefore the children of a contents wrapper, a <tr style="display: contents">
hands its cells to the table around it, and a <span style="display: contents"> inside a paragraph
disappears from its lines. Only the box tree is affected: selectors, inheritance and the element itself
still exist, so its children still inherit color, font-size, direction and the rest from it.
What follows from having no box:
- The element’s own box-model and painting properties —
margin,padding,border,background,opacity,transform,position,float,overflow,width/height— have nothing to apply to. - A
::first-letteror::first-lineon the element has no effect, andtext-decorationon it does not reach its content (decorations propagate through the box tree). A::first-letteron an ancestor block still reaches into its text. - A
counter-reset,counter-incrementorcounter-seton it has no effect (CSS Lists 3 §4.5), including the implicitlist-itemcounters of a<ol style="display: contents">; its<li>children still count and get their markers, and they are numbered by the list element that owns them, so a secondcontentslist starts again at 1. A marker hangs outside the content edge of its item, so with the list’s own indent gone it is drawn in the page margin. unicode-bidion it (so a<bdi>,<bdo>or[dir]element withdisplay: contents) does not isolate or override its content, which stays in the surrounding paragraph —unicode-bidiacts on an inline box (CSS Writing Modes 4). Itsdirectionis still inherited by its children.- In a table, cells whose rows are
contentsare wrapped in anonymous rows by the ordinary table fixup, so the cells of two consecutive<tr style="display: contents">share one row.
What it does not lose: the element is still in the document. Its id still resolves for href="#id"
links, named destinations, bookmarks and target-counter()/target-text() (at the place its content
begins); a link or bookmark-level on it works; its string-set is assigned where its content begins
(CSS Generated Content for Paged Media §1.1); and with
tagged PDF its structure element is kept around its children’s (<ul style="display:
contents"> is still an /L, a <section> a /Sect). <body style="display: contents"> still propagates
its background to the page canvas, and display: contents on the root element computes to block.
Elements whose rendering CSS does not fully control compute to display: none instead
(Appendix B): <br>, <wbr>, <img>, <video>, <audio>,
<canvas>, <iframe>, <embed>, <object>, <meter>, <progress>, <input>, <select>,
<textarea>, and inline <svg> and <math> themselves. <button>, <details>, <fieldset> and
<legend> just lose their box. A ::before/::after with display: contents is laid out as inline.
<progress> and <meter>
A <progress> bar and a <meter>
gauge are drawn as a rounded track with a fill, at the size browsers give them - ten ems wide and one em tall, sitting slightly below the baseline - in a PDF as on a
screen. They are inline-blocks, so width, height, border, background and vertical-align style them like any other box. A progress bar fills to
value out of max (default 1) in its accent-color (a blue for auto); one with no value is indeterminate and shows only the track. A meter fills to
value between min and max and takes the usual traffic-light color: green when the value is in the region the optimum is in (below low, between low and
high, or above high), yellow in the next region and red in the farthest. The element’s content is fallback text for a browser that cannot draw it, so it is not drawn.
Known gaps: display: contents on an element inside an inline <svg> or <math> (<g>, <tspan>,
<use>, <mrow>, …) has no effect, because those renderers do not read the CSS display property.
Atomic inline-level layout is approximated, not fully atomic
An inline-block whose content fits on one line has that content flowed through the
surrounding inline formatting context rather than being laid out as one opaque unit. The content
is correctly inset by its own border+padding (its label sits inside the padding box, and the line
reserves the full padding box height), and a non-auto width sizes it as declared
(CSS 2.1 §10.3.9 uses shrink-to-fit
only for width: auto): the box’s background, border and overflow clip are painted at that width
whether or not its content fills it — an empty one included, which is what makes the fixed-width
label column and the empty-bordered-box checkbox glyph work — and the line reserves the same
width, honoring box-sizing. Percentage widths resolve against the containing block’s content
width, just as they do on other boxes.
A non-auto height/min-height — including a percentage, resolved against the containing
block’s own height when that is itself definite
(CSS 2.1 §10.5) — sizes the box’s
painted background, border and overflow clip the same way
(CSS 2.1 §10.6.3): the box paints at the
declared (or floored-by-min-height) height whether or not its content fills it, and the line
itself reserves that same height
(CSS 2.1 §10.8). The extra height grows
from whichever edge the box’s vertical-align anchors — the box’s own top for top and the
default baseline on a box with real content, its own bottom for bottom/text-bottom, split
evenly around its own center for middle — except the one case noted in the gap below. A declared
height smaller than the content’s own natural height is not shrunk to — the painted box always
keeps at least its natural height, the same “never pulled back” rule a wider-than-declared width
already gets.
A box whose content does not fit on one line inside it — because it declares a width
narrower than its content, or because its content is wider than the containing block — is instead
laid out as a genuine atomic box: it establishes its own formatting context, breaks its lines at
its own measure, and takes one place on the line that holds it, with its last line’s baseline on
that line’s baseline. So a narrow inline-block holding a paragraph comes out as a narrow column
of wrapped text, the way a browser lays it out, rather than as text broken at the width of the
block around it. Content that genuinely cannot be broken — a single long word — still overflows
the box rather than widening it. A box holding block-level content (a <div> inside an
inline-block) has always been laid out this way.
Two knock-on gaps remain:
- An
inline-blocklaid out as an independent formatting context (because it holds block-level content or wraps its own inline content) moves whole to the next line when its margin box does not fit, and a declared content-boxwidthcorrectly grows by its padding and border.inline-table,inline-grid, andinline-flexget the same treatment: each preflights an estimated used width (a declared, non-percentagewidthas-is, or otherwise its own max-content width bounded by the containing block) against what is left of the line and, if it does not fit, closes the line for it, the same way an ordinary word wraps (CSS 2.1 §9.4.2). The approximated one-line path described above is the one shape this does not yet cover: it can still discover that the box’s declared width does not fit only after flowing some of its content, rather than moving the opaque box as a unit.
Pointer Interaction
Two properties describe how a document is meant to be pointed at. A PDF page has no pointer, so what each does depends on where the document is shown:
| Property | MDN | Screen hosts (PeachBrackets with a Skia, Win2D or SVG canvas) | |
|---|---|---|---|
cursor |
cursor | Parsed and inherited, otherwise ignored: a PDF has nowhere to put a pointer. | HtmlDocument.GetCursorAt(page, x, y) gives the pointer at a point: the cursor of the innermost element that receives pointer events, with any url() images (and their hotspots) ahead of the keyword. auto is the link pointer over a link, the text pointer over text and the default pointer elsewhere. HtmlDocument.PaintCursors puts the areas that ask for a pointer on a canvas (Canvas.AddCursor): the Skia and Win2D link maps answer CursorAt, and the SVG canvas writes a cursor style on an unpainted rectangle under the links. |
pointer-events |
pointer-events | none takes a link out of what can be clicked: no link annotation is written for it. Inside an inline <svg>, the property (as an attribute, in style or from a stylesheet, inherited from the <svg> and its host box) decides whether an <a> writes its link: none, or an SVG keyword the shapes under the <a> cannot satisfy (visiblePainted/painted with no fill or stroke, visible* on a visibility: hidden shape), leaves no link, while one shape that can be hit keeps it. The link rectangle stays the whole <a>’s bounds. |
none lets a point fall through to what is beneath: GetElementAt and GetCursorAt skip the element (a child that sets auto again still receives events), and it is left out of the cursor areas and the links. |
Three more properties from the same module serve a screen host and have no effect on a plain PDF page, except as noted:
| Property | MDN | Behavior |
|---|---|---|
user-select |
user-select | none keeps a selection from starting on the text: HtmlDocument.HitTest and GetCaretPosition return null over it, GetText leaves it out, and cursor: auto is not the text pointer there. auto is none or all when the parent’s is, and text otherwise; contain is text. all is read as text (the whole element is not selected as one unit). The vendor spellings are not accepted. |
caret-color |
caret-color | The colour of the text caret, which HtmlDocument.GetCaretColor(position) gives a host that draws one; auto is the text colour. |
accent-color |
accent-color | Colours the check mark of a checked check box and the dot of a checked radio button, in the painted page and in the PDF form widget appearances alike; auto keeps the text colour. Range and progress controls are not drawn by PeachPDF, so they are unaffected. |
Two more serve touch hosts, and are read through HtmlDocument.GetInteractionAt(page, x, y):
| Property | MDN | Behavior |
|---|---|---|
touch-action |
touch-action | auto, none, manipulation, and the pan-x/pan-y/pan-left/pan-right/pan-up/pan-down and pinch-zoom combinations. The result is TouchActions: what the element allows, intersected with every ancestor (the document is the only scroller), so a host knows at touch-down whether to pan or zoom or leave the gesture to the page. auto also allows double-tap zoom; manipulation does not. |
scroll-margin-top, scroll-padding-top |
scroll-margin-top, scroll-padding-top | Read by HtmlDocument.GetScrollOffset, which a host uses to scroll an in-page link’s target into view: the element’s top less its scroll-margin-top and the root element’s scroll-padding-top, so a bar stuck to the top of the viewport does not cover it. They do nothing to layout or painting, and the other sides and the shorthands are not supported. |
interactivity and the inert attribute |
inert | interactivity: inert (inherited) or inert on an element or an ancestor takes the subtree out of interaction: pointer hits fall through to what is beneath, its links write no annotation, its text cannot be selected, and the element cannot take focus. It is still painted. |
The keywords that only mean something for the painted parts of an SVG shape (visiblePainted, visibleFill, visibleStroke, visible, painted, fill, stroke, all) are accepted but read as auto: a shape inside an inline <svg> is not a separate hit target.
Stacking Context
Paint order follows the CSS stacking context model. A new stacking context is established by:
- the document root
position: relativeorabsolutewith az-indexother thanautoposition: fixedorsticky(unconditionally, regardless ofz-index)- a flex item (a direct child of a
display: flex/inline-flexcontainer) with az-indexother thanauto opacityless than 1- a
transformother thannoneon a transformable element (not a plain non-replaceddisplay: inlinebox) — including one whose matrix is the identity, such asrotate(0deg),scale(1)ortranslateZ(0) - a
perspectiveother thannone, on a transformable element mix-blend-modeother thannormalfilterother thannone
Elements are painted as one self-contained, atomic unit once they establish a stacking context — for example, everything inside an opacity: 0.5 box (including any absolutely-positioned descendants) fades together as a single composited group, and a z-index-ordered element’s own descendants are ordered independently of the rest of the page.
Within one stacking context, normal-flow content paints in CSS2.1 Appendix E order: in-flow block-level descendants first, then non-positioned floats, then in-flow inline-level descendants (text and inline replaced content, e.g. an inline <img>/<object>), then positioned descendants. A plain block-level box whose entire content is inline (a wrapper <div> around nothing but an inline image, for example) is treated as belonging to the inline pass itself, since painting it is what paints that inline content. This local ordering is preserved when a float is hoisted past its immediate container as long as that container is itself positioned (position: relative/absolute/fixed/sticky), even without an explicit z-index of its own. The one remaining gap: a float whose immediate container is a plain, non-positioned wrapper (no position at all) still hoists all the way to the nearest true stacking-context ancestor instead of preserving local order, so it may not paint correctly relative to non-hoisted block/inline siblings at its original nesting level.
The following triggers from the CSS specification are not supported, since the underlying properties themselves have no effect in PeachPDF (see Unsupported CSS Features): isolation, will-change, contain.
An element that needs to escape a plain (non-stacking-context) ancestor to compete for z-order at its true enclosing stacking context — for example, a z-index-ordered element nested inside a plain position: absolute wrapper that has no z-index of its own — is still correctly clipped by every overflow: hidden ancestor on its containing block chain that it passes through along the way, including when multiple such ancestors are nested.
Flexbox
CSS Flexbox Level 1 (display: flex / inline-flex) is supported, including multi-line wrapping, all alignment properties, and auto margins on both axes. Replaced elements (<img>, inline <svg>) work as flex items, including when mixed with block-level siblings. An inline-flex container is an atomic inline: its items belong to the flex formatting context inside it and stay there, whether they are inline-level or block-level, and the box as a whole takes its place on the line it sits on.
Which children become items follows CSS Flexbox §4: every in-flow child is an item in its own right, whatever its display — an inline element, a floated child (whose float is ignored in flex/grid layout), a ::before/::after pseudo-element (including one whose content is the empty string, a common spacer idiom sized purely by its own width/height), and a table-internal display such as table-row, which is blockified rather than treated as part of a table. Only a contiguous run of child text is wrapped in one anonymous item. CSS 2.1 §9.2.1.1’s anonymous block box is a block-container rule and is not applied inside a flex container, so a flex container mixing inline-level and block-level children keeps each of them as its own item. The same applies to grid containers.
Every in-flow item is blockified, per display: inline and inline-block compute to block, inline-flex to flex, inline-grid to grid and inline-table to table, so an item’s own width/height apply whatever it was declared as. A replaced item (<img>, inline <svg>) stays the item itself and keeps its intrinsic size: in a flex row, and in a grid under the default justify-self/align-self and under start, center or end, on either axis and whether or not the row has an explicit height. It stretches across its grid area under an explicit justify-self: stretch; an explicit align-self: stretch fills the row’s height and takes its width from that height through the image’s aspect ratio, which can overflow its area unless the item has a max-width. An item with no intrinsic width (an <svg> or SVG image with a viewBox but no width/height, which has an aspect ratio only) fills its area’s width under any alignment, as browsers do. An <svg> with only one of width/height and no viewBox takes the default object size, 300x150px, in the missing axis. <iframe> and <video> (without a poster) grid items keep that default size too, and stretch only under an explicit stretch; the iframe’s 2px default border that browsers add to it is not drawn. A percentage width, min-width or max-width on a grid item resolves against its grid area, not the grid container. A display: grid / inline-grid container that is itself a flex item with width: auto is as wide as its tracks plus gaps (its max-content width, with fr tracks sized from their content and percentage tracks treated as auto), then shrinks like any flex item but never below its min-content width. A grid using repeat(auto-fill/auto-fit, …), grid-template-areas, a column grid-auto-flow or subgrid tracks still takes the flex container’s available width.
Flexbox is writing-mode-aware: flex-direction: row/row-reverse always mean “main axis = the container’s own inline axis” and column/column-reverse mean “main axis = the block axis” (CSS Flexbox 1 §3), so under vertical-rl/vertical-lr a row container’s items stack top-to-bottom (physical Y) and a column container’s items stack along the block axis (right-to-left for vertical-rl, left-to-right for vertical-lr) — the same reversal logical box-model properties already apply. justify-content: left/right fall back to start when the main axis isn’t parallel to the physical left/right axis, per spec. row-gap/column-gap selection is unaffected by writing-mode (they map to flex-direction’s own row/column identity, not the physical axis it lands on). A container’s own definite-main-size resolution stays scoped to the physical Y dimension’s aspect-ratio fallback (no equivalent width-driven fallback for a vertical-writing-mode column container, where main lands on physical X) — a narrow, deliberate boundary, not an oversight.
| Property | MDN Reference | Notes |
|---|---|---|
flex-direction |
flex-direction | row, row-reverse, column, column-reverse The legacy -webkit-/-moz- spelling is accepted as the same property. |
flex-wrap |
flex-wrap | nowrap, wrap, wrap-reverse. wrap-reverse swaps the cross-start and cross-end edges, so the lines are stacked in the opposite direction — each still occupying its own cross size, in sequence, with whatever align-content puts between them. Lines of different cross size therefore stack without overlapping, and align-content reads in the reversed direction too: flex-end on a row container packs the lines against its top edge, and lines that do not fit overflow that edge The legacy -webkit-/-moz- spelling is accepted as the same property. |
flex-flow |
flex-flow | Shorthand for flex-direction + flex-wrap, in either order (wrap row as well as row wrap) The legacy -webkit-/-moz- spelling is accepted as the same property. |
justify-content |
justify-content | normal, flex-start, flex-end, start, end, left, right, center, space-between, space-around, space-evenly, stretch. start/end are flow-relative and flip with flex-direction: row-reverse/column-reverse like flex-start/flex-end; left/right are physical keywords that do not flip with row-reverse, and fall back to start when the main axis isn’t horizontal (flex-direction: column/column-reverse) — both per CSS Box Alignment 3 §8.3. stretch has no main-axis growth effect (that is flex-grow’s job) and packs at the start The legacy -webkit-/-moz- spelling is accepted as the same property. |
align-items |
align-items | flex-start, flex-end, start, end, self-start, self-end, center, stretch, normal, baseline. Applies in both directions: a non-stretch alignment shrink-wraps each item to its fit-content cross size and positions it (a row item vertically, a column item horizontally), while stretch (the default) fills the line’s cross size. baseline aligns items by their first text baseline and is only meaningful for row-direction flex — column-direction flex falls back to flex-start. flex-wrap: wrap-reverse swaps the cross-start and cross-end edges for the items within a line as well as for the stack of lines, so flex-start puts a short item against the bottom of its row line and flex-end against its top, and a baseline-aligned group is flush with the line’s bottom; center reads the same either way, and an item that stretches fills its line and is on both edges at once The legacy -webkit-/-moz- spelling is accepted as the same property. |
align-content |
align-content | flex-start, flex-end, start, end, center, space-between, space-around, space-evenly, stretch, baseline. The initial value normal behaves as stretch, growing every line equally to absorb the container’s free cross space; baseline’s content-distribution fallback alignment is start The legacy -webkit-/-moz- spelling is accepted as the same property. |
align-self |
align-self | Same values as align-items, plus auto The legacy -webkit-/-moz- spelling is accepted as the same property. |
order |
order | Full support on flex items, and on grid items: auto-placement takes the items in order-modified document order (a lower order is placed first, equal orders keep source order). The legacy -webkit-/-moz- spelling is accepted as the same property. |
flex-grow |
flex-grow | Full support The legacy -webkit-/-moz- spelling is accepted as the same property. |
flex-shrink |
flex-shrink | Full support The legacy -webkit-/-moz- spelling is accepted as the same property. |
flex-basis |
flex-basis | Length, percentage, auto, content and stretch (with -webkit-fill-available/-moz-available) values are supported. The legacy -webkit-/-moz- spelling is accepted as the same property. |
flex |
flex | Shorthand for flex-grow, flex-shrink, flex-basis, including the none and auto keywords The legacy -webkit-/-moz- spelling is accepted as the same property. |
gap / row-gap / column-gap |
gap | Full support on flex containers |
margin auto values |
margin | auto on a main-axis margin absorbs free space on that line (e.g. margin-left: auto to push a flex item to the end); auto on a cross-axis margin absorbs the line’s free cross space (e.g. margin-left: auto on a column item, or margin-top: auto on a row item), overrides align-self, and stops the item stretching |
A container whose height is a percentage that cannot resolve — because its own containing block has no definite height — is treated as auto-height, per CSS Box Sizing 4 §5. A column container then sizes to its content and stacks its items normally; there is simply no free space for justify-content to distribute.
Grid
CSS Grid Layout (display: grid / inline-grid) is supported, including explicit and implicit track sizing, line-based placement with spans, named grid lines and grid-template-areas, auto-placement, box/content alignment (including block-axis baseline item alignment), and subgrid (CSS Grid Level 2 §9). Track sizes may be lengths, percentages, fr flexible lengths, auto, min-content/max-content, minmax(), fit-content(), repeat() — including repeat(auto-fill, …) / repeat(auto-fit, …), so the common responsive-card idiom repeat(auto-fill, minmax(200px, 1fr)) works — and a calc() (or min()/max()/clamp()) length, both on its own and inside minmax()/fit-content()/repeat(). fr tracks resolve via the standard flex-fraction algorithm (honoring minmax() floors); intrinsic tracks size to their items’ content, distinguishing min-content (the longest unbreakable word) from max-content (the full unwrapped line), and an item spanning several intrinsic tracks distributes its content contribution across them so a spanned track does not collapse to zero. Each track then grows toward its growth limit with the space the container actually has (§12.6), so a grid narrower than its own content shares what there is rather than overflowing itself; whatever is still free afterwards goes to the fr tracks, or, with none, stretches the auto ones.
| Property | MDN Reference | Notes |
|---|---|---|
grid-template-columns / grid-template-rows |
grid-template-columns | <track-list>: lengths, %, fr, auto, min-content, max-content, minmax(), fit-content(), a calc()/min()/max()/clamp() length (bare or inside minmax()/fit-content()/repeat()), repeat(<n>, …), repeat(auto-fill \| auto-fit, …), and named lines [name] (outside repeat()); plus subgrid (Level 2 §9) with an optional [name] line-name list — the axis adopts the parent grid’s spanned tracks. A percentage row track against an indefinite container height is treated as auto (content-sized) per §7.2.1 |
grid-template-columns: subgrid / grid-template-rows: subgrid |
subgrid | A grid item that is itself a grid adopts its parent’s tracks along the subgridded axis (on either or both axes): the item’s own children align to the parent’s grid lines, and — for auto parent tracks — the subgrid’s content grows the shared tracks so multiple subgrids’ rows line up. With no parent grid, subgrid behaves as none. Not honored: subgrid margin/border/padding insetting the adopted tracks, parent line-name inheritance into the subgrid, subgrid content contributing to the parent’s auto column sizing, and subgrid in the grid/grid-template shorthands |
grid-template-areas |
grid-template-areas | The ASCII-art named-area template (each area must be a single filled rectangle; ./... are empty cells). Each area contributes implicit name-start/name-end lines on both axes, and establishes the explicit row/column counts |
grid-auto-columns / grid-auto-rows |
grid-auto-rows | Sizes implicitly-created tracks; a <track-size>+ list cycled across the implicit tracks |
grid-auto-flow |
grid-auto-flow | row, column, and the dense packing keyword |
grid-column-start / grid-column-end / grid-row-start / grid-row-end |
grid-row-start | Line-based placement: auto, an integer line (negatives count from the end edge), span <n>, a named line (a [name] line or an area’s name-start/name-end edge), or <name> <n> for the Nth line with that name. An item explicitly placed on a line past the explicit grid (e.g. grid-column: 5 in a 3-column grid) generates the implicit tracks it needs on that axis rather than being clamped into range. span <name> is not supported |
grid-column / grid-row / grid-area |
grid-area | The slash-separated line forms plus the named forms — a bare area name (grid-area: main, grid-column: main) fills the whole named area via the custom-ident omitted-value copy rule |
grid-template |
grid-template | The §7.4 shorthand for grid-template-rows/-columns/-areas: none, the <rows> / <columns> form, and the ASCII-art areas form [<line-names>? <string> <track-size>? <line-names>?]+ [/ <columns>]? (a row with no explicit track size gets auto) |
grid |
grid | The §7.8 shorthand: a <grid-template>, or an auto-flow form (<rows> / [auto-flow && dense?] <auto-columns>? or [auto-flow && dense?] <auto-rows>? / <columns>). Every longhand it doesn’t set is reset to its initial value |
justify-items / align-items |
justify-items | Default alignment of items within their cell: start, end, self-start, self-end, center, stretch, normal, and — on justify-items only, since they’re physical keywords valid only on the inline axis — left/right and legacy [left\|right\|center] (either order). justify-items defaults to legacy, which lays out as normal in a grid; the companion keyword (and legacy’s passing of a block container’s alignment down to its children’s justify-self: auto) has no effect, because justify-self is not applied to block-level boxes. align-items: baseline (block axis) aligns the items in a row on a shared first text baseline, growing the row to the baseline group’s max-ascent + max-descent (CSS Box Alignment 3 §9.3); justify-items/justify-self: baseline (inline axis, horizontal writing mode) lines the items of a column up on the largest start margin among them, so their border boxes share a start edge, and last baseline does the same at the end edge |
justify-self / align-self |
justify-self | A grid item’s own cell alignment; auto/normal inherit the container’s *-items value. justify-self also accepts the inline-axis-only physical keywords left/right |
justify-content / align-content |
justify-content | Distributes leftover container space among the tracks: start, end, center, space-between, space-around, space-evenly, stretch (which stretches auto columns to fill the container when nothing else does) — plus, on justify-content only, the inline-axis-only physical keywords left/right |
place-items / place-content / place-self |
place-items | Shorthands for the align/justify pair (one value applies to both axes) |
gap / row-gap / column-gap |
gap | Full support on grid containers (the same property family as flexbox’s gap) |
The grid engine is fixed to a left-to-right, top-to-bottom writing mode — direction: rtl and vertical writing modes are not honored for track order or logical placement. The following are also not supported: named lines inside repeat() and span <name> (and a named line declared after an auto-fill/auto-fit repeat is positioned as if the repeat expanded to a single track); masonry; and inline-axis (justify-*) baseline alignment of items that span more than one column, or in a vertical writing mode. Block-axis baseline item alignment shifts items to a shared baseline and grows the row to the baseline group’s max-ascent + max-descent (CSS Box Alignment 3 §9.3), so a group with a disproportionately large descent no longer overflows the row. fr row tracks are not expanded to fill a definite container height (an auto row is, with align-content: normal | stretch), and a %-bearing calc() track resolves its percentage against 0 in a row or auto-fill-count context (as a plain percentage does there). subgrid is supported (see the row above) with the noted limitations.
Multi-column Layout
CSS Multi-column Layout (column-count/column-width/columns) is supported, and a column is a real fragmentainer in the sense CSS Fragmentation Level 3 §2 means it — “a column in multi-column layout, or a page in paged media”. The column a piece of content is in is what its break decisions are made against, so the break machinery works inside a column without being told about columns:
break-before: column/break-after: columnforce a break at the column boundary, so the content after them starts the next column and the one before them is left short.break-inside: avoid(andavoid-column) keeps a box off a column boundary, moving it whole to the next column.break-after: avoid/break-before: avoid(andavoid-column) keep a heading with the content it introduces at a column boundary, so a heading is not left at the foot of the column the content it belongs to has moved out of.break-before: page(and the four directional values) forces a break to the next page, not the next column: none of a container’s columns is on another page.- Monolithic content — a replaced element or a scroll container (inside a column container every scroll container qualifies) — moves whole to the next column rather than being cut by the boundary.
orphansand margin truncation apply at a column boundary the same way they apply at a page boundary (widows, which is enforced after the fact by re-running the pass that filled a fragmentainer, is a page-context rule only).- A container whose content outlasts its columns continues on the next page, resuming where its last column stopped rather than starting over, and balances the fragment that holds the end of the flow.
- A child continues into the next column rather than moving to it whole: a paragraph’s first lines stay in one column and the rest flow into the next, each fragment carrying its own background, border and padding.
- The break need not fall between two of the container’s own children. A block nested any distance below one is split at the column boundary too, and the content after the break starts the next column flush at that column’s top, inside the continuing block’s own fragment there. The continuing block’s top border and padding are re-opened above it only under
box-decoration-break: clone; underslicethe box is cut at the break and nothing is inserted there — and this holds however deep the nesting goes, so a continuing block’s own fragment begins where its content does. The multi-column container’s own border and padding are not re-opened at a column boundary, since a container is not fragmented by its own columns. See Decorations at a break.
column-fill: balance (the default) aims for equal-height columns, and column-fill: auto fills each column before starting the next.
Column breaks. The break values that name the column fragmentation context — column on break-before/break-after, and avoid-column on break-inside — apply inside a multi-column container and nowhere else, which is what §3.1 means by naming a context. Outside one there is no column boundary for them to speak about, and they are ignored rather than being treated as their page-context equivalents.
Each value covers exactly the context it names, and avoid covers both:
| Value | At a page boundary | At a column boundary |
|---|---|---|
break-before: page |
forces a break | forces one too — a column cannot span pages |
break-before: column |
ignored | forces a break |
break-inside: avoid |
keeps the box whole | keeps the box whole |
break-inside: avoid-page |
keeps the box whole | ignored |
break-inside: avoid-column |
ignored | keeps the box whole |
A forced column break carries the same propagation rule a forced page break does: declared on a container’s first in-flow child it is the break point before the container, so the container starts the next column along with the element. And an avoid that cannot be satisfied is relaxed rather than obeyed pointlessly — a box too tall for a whole column is split, since moving it to the next one would leave it splitting there instead.
Keep-with-next at a column boundary. The keep-with-next chain works at a column boundary as it does at a page one, with avoid and avoid-column naming the column context (and avoid-page naming only the page). Since the UA default stylesheet applies h1-h6 { break-after: avoid }, a heading inside a multi-column container is kept with the content below it out of the box: where that content starts the next column, the heading starts it too. §4.3’s relaxation applies as it does on the page grid — a run too tall to fit the destination column alongside the content it is chained to is trimmed from its front, so the subheading immediately above travels even where the chapter heading above that cannot.
Two details follow from a column having no lower coordinate to move a run to. The break is stated before the head of the run and the next column lays the whole run out there, rather than the members being shifted; and the head has to be a box the column being filled actually placed — a run reaching back into a column that is already filled, or one whose head is the very box this column began with, is left where it is, since starting the next column at the same place would put the identical question to it again.
The chain applies where the content is moved whole by the column boundary. A box that asks not to be broken by one (break-inside: avoid-column) moves alone: it has begun splitting, so its full height is not yet known, and §4.3 gives a constraint up rather than acting on one it cannot show to be satisfiable — moving a heading into a column of its own while the content it introduces breaks again in the next is worse than leaving it. column-fill: balance also rarely leaves room for the pull, since a balanced column’s height is derived from the content it holds; a page-bounded column (column-fill: auto, or a container taller than its page) is where a heading and its content travel together.
A forced page break escapes the container. break-before: page (and left/right/recto/verso) inside a multi-column container starts the next page: it names the page grid, and none of the container’s columns is on another page. The columns it has opened so far end there, the content after the break opens the page the break names, and the container continues in its columns on that page. A directional value reaches the side it asks for here, exactly as it does outside a container (Directional page breaks) — but the page it skips to get there is not materialized: outside a container that skipped page is emitted as a real blank page, and inside one it is dropped, so the document is a page shorter than the same break would produce elsewhere. column-fill: balance balances the columns before the break over the content that precedes it, since the break is where this fragment’s flow ends.
The above holds regardless of how deep inside the container the forced break is declared — on the container’s own child or on a descendant further down a wrapper’s subtree.
A column is at least as tall as its unbreakable content. A child a multi-column container cannot fragment — a <table>, or another layout engine’s container — is placed in one column whole, and where it is taller than the height balancing chose, the column it lands in grows to hold it rather than the overflow being clipped away. A <table> nested in a multi-column container therefore renders in full.
A floated child of a multi-column container is positioned within the column it appears in, and the container’s automatic height grows to contain its floats. A float wider than its column overflows it rather than being narrowed. A container holding only floats lays them out in its first column. Text beside a float is divided into columns like any other text: the float sits in the column where it appears and shortens the lines beside it. A float placed after text on the same line moves that text beside it when the line has room for both, and otherwise starts the next line. A block-level float whose margin box does not fit below the content already in its column moves to the top of the next column, while the in-flow content after it keeps filling the column it was in; a float taller than a column, or the first thing in one, stays. Two limitations: a float written among inline content that does not fit stays in its column and overflows it, and column balancing does not count the space a float takes before it is moved. A multi-column scroll container (overflow other than visible) with a height or max-height that holds only text is laid out as one inline flow, not in columns, and so is a multi-column container that holds a float inside a table cell.
-
A box split at a column boundary leaves that edge open, per
box-decoration-break: slice— no border is drawn where the break is, only where the box really starts and ends. See Decorations at a break. -
A decoration defined over the whole box — a
border-radius, a gradient or image background layer, abox-shadow— is measured against the whole box underslice, so a rounded, gradient-filled box divided between two columns rounds only at its true ends and its gradient runs on from one column into the next. See Decorations at a break.
A multi-column container nested inside another one splits its own children per inner column, independently in each outer column it is filled in.
| Property | MDN Reference | Notes |
|---|---|---|
column-count |
column-count | Full support The legacy -webkit-/-moz- spelling is accepted as the same property. |
column-width |
column-width | Full support; resolves to as many columns as fit at at-least this width The legacy -webkit-/-moz- spelling is accepted as the same property. |
columns |
columns | Shorthand for column-width and column-count, in either order The legacy -webkit-/-moz- spelling is accepted as the same property. |
column-gap |
column-gap | Same underlying property as flexbox/grid’s gap (initial value normal). Multicol resolves normal to 1em (matching real-world browser behavior); flexbox/grid resolve it to 0 The legacy -webkit-/-moz- spelling is accepted as the same property. |
column-rule |
column-rule | Shorthand for column-rule-width/column-rule-style/column-rule-color; renders as a real vertical line between columns, one segment per page-row the container spans The legacy -webkit-/-moz- spelling is accepted as the same property. |
column-rule-width |
column-rule-width | Full support, including thin/medium/thick The legacy -webkit-/-moz- spelling is accepted as the same property. |
column-rule-style |
column-rule-style | solid, dashed, dotted; double/groove/ridge/inset/outset render as solid. none and hidden both draw no rule at all, and force the used column-rule-width to 0. Either way the column gap is unaffected — a rule is painted in the middle of the gap rather than out of it, so its width never changes where the columns fall The legacy -webkit-/-moz- spelling is accepted as the same property. |
column-rule-color |
column-rule-color | Full support, including currentcolor The legacy -webkit-/-moz- spelling is accepted as the same property. |
column-fill |
column-fill | balance (the default) is solved per row via a binary search for the minimum column height that still packs as many children into the row as the full page budget would, floored at an even share of the content so that splittable content balances too — tighter than a single closed-form estimate, especially with unevenly-sized children. auto fills each column to capacity before starting the next The legacy -webkit-/-moz- spelling is accepted as the same property. |
column-span |
column-span | all breaks the column flow: a spanning element renders at the container’s full content width, splitting the columns before and after it into independently-balanced runs. A spanner nested deeper is recognized too when every box between it and the container is a plain in-flow block (not a float, inline, table, flex/grid item or container, list item, or a box with non-visible overflow): the ancestors are split around it, the part before keeping the top border/padding/margin and the part after the bottom ones, and each side flows in columns of its own. Behind any other box it has no effect. The split duplicates those ancestors in the box tree, so an id or tagged-PDF structure element of a split ancestor appears once per piece. The legacy -webkit-/-moz- spelling is accepted as the same property. |
Positioning
Used with position: relative, absolute, or fixed.
An absolutely (or fixed) positioned box is blockified (CSS Display 3 §2.7 / CSS 2.1 §9.7): an inline-level computed display (inline, inline-block, inline-flex, inline-table) is coerced to its block-level equivalent, so e.g. a ::before with no explicit display still becomes a real out-of-flow block. An absolute box’s containing block is the padding box of the nearest positioned ancestor (CSS 2.1 §10.1), which is what percentage width/height/top/left resolve against (not the nearest in-flow block container, when those differ); a fixed box’s is the page area of the page it is drawn on, so its offsets and percentages are measured from that page’s content corner and can differ from page to page when the pages do. With width: auto and both left and right set, the box fills the space between them (§10.3.7); likewise height: auto with both top and bottom set (§10.6.4). When top, height and bottom are all set instead, an auto block-axis margin absorbs whatever space is left over — one auto margin takes all of it, and margin-top: auto with margin-bottom: auto splits it evenly, which is the usual way to centre an absolute or fixed box vertically. Content-sized containing blocks use their final height for this calculation. A negative margin on the opposite edge is honored the same way, so a box can be pushed entirely past its containing block’s edge. Out-of-flow children of a flex or table container are laid out too (they don’t participate in flex/table layout but still get positioned and sized). An absolute box is drawn on the page its offsets place it on, even when that page comes before the one its source position falls on: with no positioned ancestor its containing block is the first page’s area, so top: 5pt puts it at the top of page 1 wherever it appears in the document. It takes no part in placing the boxes around it, so the content after it in the same block starts where it would without it. An absolutely positioned box taller than the page breaks between its lines and adds the pages it needs, as a browser prints it, and the content around it is not affected by where it breaks.
An absolutely positioned box inside inline content, such as a badge in a <span> in the middle of a sentence, stays out of the line. The text before and after it stays on the same line, and the inline around it is not split (as it would be around an in-flow block). An absolutely positioned box contributes nothing to the shrink-to-fit width of a float, table cell or flex item around it, nor to a table cell’s content height, so a badge hanging below a cell’s text does not move that text. When the nearest positioned ancestor is an inline element, such as a <span style="position: relative">, that inline is the containing block. For an inline on one line, CSS 2.1 §10.1 defines it as the box around the inline’s padding edges. For an inline that wraps, PeachPDF takes each edge from the line fragment CSS Positioned Layout 3 assigns it to, and measures it at that fragment’s padding edge as Chromium does (that specification names the content edge): the top and left edges come from the inline’s first line fragment, and the bottom and right edges from its last fragment; in rtl text, left and right come from the last and first fragments instead. So top: 0; left: 0 sits at the start of a highlighted word, and bottom: 100% puts a label just above it. An inline that holds nothing but the positioned box, such as an icon or tooltip wrapper, is a zero-width box at its place in the line, one line of its font tall, and the positioned box is placed from there.
Known limitations:
- Static position is used in block flow and among inline content. An absolutely positioned box whose offsets on an axis are both
autosits where it would have been in the normal flow on that axis (CSS 2.1 §10.3.7): at its parent’s content edge across and below the previous in-flow sibling down; a box written among inline content sits at its place on the line (right after the preceding word, at the line’s top), carried throughtext-alignand bidi reordering. In adirection: rtlparent the box’s right margin edge sits at the static position (the parent’s content right edge, or the place on the line). In a table cell withvertical-align: middleorbottom, such a box moves with the cell’s aligned content. - A positioned inline that a page break falls inside forms its containing block from the fragments placed so far. An absolutely positioned box in such an inline resolves
bottomandrightagainst the last fragment on the page the box is laid out on, rather than against the inline’s true last fragment on a later page.topandleftare unaffected, and so is an inline that does not cross a page break. - An empty positioned inline between runs of opposite direction is placed at the far end of one run. In mixed-direction text, such as an
rtlparagraph with an English phrase, a zero-width inline at the boundary between the two directions goes to the far end of the neighbouring run instead of sitting between them. Within a run of one direction it is placed correctly.
| Property | MDN Reference | Notes |
|---|---|---|
top |
top | Full support |
right |
right | Full support |
bottom |
bottom | Full support |
left |
left | Full support |
Lists
| Property | MDN Reference | Notes |
|---|---|---|
list-style |
list-style | Shorthand supported |
list-style-type |
list-style-type | disc, circle, square, none. Numeric (positional digit-substitution) styles: decimal, decimal-leading-zero, arabic-indic, bengali, cambodian/khmer, cjk-decimal, devanagari, gujarati, gurmukhi, kannada, lao, malayalam, mongolian, myanmar, oriya, persian, tamil, telugu, thai, tibetan. Alphabetic/additive styles: lower-alpha/lower-latin, upper-alpha/upper-latin, lower-roman, upper-roman, lower-greek, armenian/upper-armenian, lower-armenian, georgian, hebrew (including the 15/16 Tetragrammaton-avoidance substitution), hiragana/hiragana-iroha, katakana/katakana-iroha. Fixed styles: cjk-earthly-branch, cjk-heavenly-stem (each falls back to decimal past its 10/12-item range). ethiopic-numeric. Symbolic (fixed-glyph, uncounted) styles: disclosure-open, disclosure-closed. A literal <string> marker (e.g. list-style-type: "→ ") is also supported, with no automatic suffix. An unknown/unsupported named style falls back to decimal per CSS Counter Styles Level 3 §2. The East Asian “longhand” counter styles are supported: cjk-ideographic (the same as trad-chinese-informal), simp-chinese-formal/simp-chinese-informal, trad-chinese-formal/trad-chinese-informal, japanese-formal/japanese-informal and korean-hangul-formal, korean-hanja-formal/korean-hanja-informal. They write the numeral in the language’s own characters (the Chinese styles with 零 for zero runs and the 万/亿 myriad groups, the informal ones leaving out 一 before a leading 十; the Korean styles with a 만 group and no zero digit), put a negative sign in front (负, 負, マイナス, 마이너스 ), and give a list marker their own suffix (、, or a comma and space for Korean) in place of .. The Japanese styles cover -9999 to 9999; outside a style’s range it falls back to cjk-decimal. A style may also be an inline symbols() value (symbols(cyclic "*" "†"); cyclic/numeric/alphabetic/symbolic/fixed, string or identifier symbols) or the name of an author-defined @counter-style; <image> symbols are not supported, and a symbols() value that is invalid makes the declaration invalid. A name with no matching @counter-style falls back to decimal. Both are accepted wherever a counter style is — including counter()/counters() |
list-style-position |
list-style-position | inside, outside |
list-style-image |
list-style-image | URL, data URI, and all CSS gradient functions supported; same image types as background-image, including SVG url() sources rendering as real vector content |
Page Breaks
These properties control how content breaks across PDF pages. Both the legacy page-break-* names and the modern break-* names are recognized.
| Property | MDN Reference | Notes |
|---|---|---|
break-before |
break-before | The full value set is accepted: auto, avoid, avoid-page, page, left, right, recto, verso, avoid-column, column, avoid-region, region. page, left, right, recto and verso all force a page break, the four directional values additionally inserting a blank page where the requested side needs one (see Directional page breaks below); avoid and avoid-page participate in keep-with-next (see the note below the table). column forces a break at the next column boundary inside a multi-column container, and is ignored outside one — there is no column boundary for it to name (see Column breaks below). region and avoid-region parse for conformance but have no layout effect — see the accepted-values note below. always and all are not accepted: they are Level 4 additions, outside the value set above. Write break-before: page, or use the legacy page-break-before: always |
break-after |
break-after | Same value set, same behavior, on the trailing side of the box. A directional break-after on the last box of the document pads it with a blank page where one is needed for the page that would follow to fall on the requested side — so a volume can be made to end such that the next one opens recto |
break-inside |
break-inside | auto, avoid, avoid-page, avoid-column, avoid-region. avoid and avoid-page are honored — they move a box that would straddle a page boundary to the top of the next page. avoid and avoid-column are honored at a column boundary, moving a box that would split across one to the top of the next column (see Column breaks below). Each targeted value covers exactly the context it names, so avoid-page never suppresses a column break and avoid-column never suppresses a page break. avoid-region names a context PeachPDF does not establish and has no layout effect. On a <thead> or <tfoot> the property has a second job — it is one of the two conditions CSS Tables Level 3 puts on repeating the group across pages, and the user-agent stylesheet supplies avoid for both elements so that repetition is the default; see When a <thead> or <tfoot> repeats |
page-break-before / page-break-after |
page-break-before | The legacy aliases of break-before/break-after, with their own (smaller) value set: auto, always, avoid, left, right. They set the same underlying property, with always treated as page, avoid as avoid, and left/right carrying the same directional behavior as their break-* spellings |
page-break-inside |
page-break-inside | The legacy alias of break-inside: auto, avoid |
box-decoration-break |
box-decoration-break | Both values honored, at page breaks and at the line breaks of an inline box alike — see Decorations at a break below |
orphans |
orphans | Enforced at the break point: where a fragment would keep fewer than orphans lines of a paragraph-like box, the break falls before the box instead, so those lines travel with the rest of its content. This holds for a box of any height — including one taller than the fragmentainer, which the whole-box push below cannot help — and at a column boundary as well as a page boundary. A run of preceding siblings chained to the box by break-after: avoid travels with it, as at every other relocation. Two limits: the break is moved at most once per box (moving it again cannot give the box more room, and repeated it would walk the box down the document), and in a document with per-page left/right @page margins the decision is taken only once the per-page measures have settled, and never where it would move the box onto a page of a different measure |
widows |
widows | Enforced after the fact rather than at the break point, because how many lines follow a break is not known until the rest of the content has been flowed. Where too few lines would follow, only the minimum number of lines the spec asks for is moved: the fragment before the break keeps fewer, and the box itself does not move. That needs the fragment before the break to be laid out again, so it applies where the box has exactly two fragments — the common paragraph-across-one-page-break case. Where it cannot be arranged — a box spread over three or more fragmentainers, or a budget that would leave fewer than orphans lines behind — it falls back to laying the whole box out on the next page, which is in turn skipped for a box taller than one fragmentainer where moving it whole would recreate the violation. Like orphans, in a document with per-page left/right @page margins it is applied only once the per-page measures have settled, and never across a change of measure |
Break values with no layout effect. PeachPDF accepts the complete CSS Fragmentation Level 3 §3 value set on every break property, so a stylesheet written for a browser parses unchanged and its other declarations are unaffected. The values that change the output are page, left, right, recto and verso on break-before/break-after (forced page breaks — see Directional page breaks below), and avoid and avoid-page on break-before/break-after (keep-with-next, below) and on break-inside. column and avoid-column are honored inside a multi-column container, which is the only place a column boundary exists (Column breaks, below). The remainder are completely inert — not a weaker version of the behavior they name, but no effect at all: region and avoid-region are permanently inert, since PeachPDF has no CSS Regions fragmentation context for them to break into, and column/avoid-column say nothing about a document with no multi-column container in it. A value naming one context deliberately does not fall back to bare avoid or to page in another: a hint about column or region breaks must not change pagination, and a request for a column break must not become a page break. All of these are stored and cascade normally.
Directional page breaks (left, right, recto, verso). Per CSS Fragmentation Level 3 §3.1 these force one or two page breaks, so that the content following the break begins on a page of the requested side. Pages alternate right, left, right, … from the first page — the same left-to-right progression @page :left / @page :right select on — so a break-before: right whose content would otherwise land on a left page inserts one blank page ahead of it. recto and verso are right and left in this progression.
A page inserted this way is an ordinary page: it takes its @page context’s canvas background and margin boxes, and it counts toward counter(page) and counter(pages). @page :blank (MDN) selects exactly these inserted pages, so @page :blank { @top-center { content: none } } suppresses the running header on them while the surrounding pages keep theirs. It matches only a page a directional break inserted, never one that is merely empty or one a plain break-before: page opened.
Two limitations. Where a document also uses per-page @page left/right margin overrides, which re-wrap each page’s content to its own width, the page a break lands on (and therefore whether a blank page is needed) depends on that re-wrapping, which is resolved iteratively rather than exactly.
And the side is resolved against the page’s position in the full page grid, while the printed page number counts only the pages actually produced. Those agree for an ordinary document, but they drift apart after a page that was dropped for having no content on it — most plausibly a tall empty gap between two blocks. Past such a gap, a break-before: right can land on a page that prints as an even number, and @page :right will not style it. Keep large vertical gaps out of a document that relies on which side its pages fall on.
Keep-with-next (break-after: avoid / break-before: avoid). Per CSS Fragmentation §3.1, an avoid (or avoid-page) on either side of a sibling break point forbids an unforced break between the two boxes. PeachPDF honors this wherever it relocates content to the next page: when a box is relocated wholesale (a table whose body would cross a page boundary, a break-inside: avoid box, an orphans/widows push) or when ordinary word flow pushes a block’s first line to the next page, the maximal run of preceding siblings chained to it by avoid values goes with it, so a heading is never stranded at the bottom of the page its content just left. The run is laid out again at its destination rather than shifted there, so each of its members reads as though it had started on that page — including where the heading was placed on an earlier page than the one the decision is taken on, which is the ordinary case whenever the content’s own text breaks across the boundary: the page that placed the heading is laid out again too, and the fragments it had already produced are withdrawn. The UA default stylesheet applies h1-h6 { break-after: avoid } (under @media print, which PeachPDF always uses), so headings get this behavior out of the box. Chains are transitive (e.g. h2 + h3 + paragraph move as a group), a forced break value on either side of a pair takes precedence over avoid per §5.2, and an avoid that cannot be satisfied in full is relaxed progressively per §4.3 rather than abandoned: the run is trimmed from its front until what remains fits the destination — the subheading immediately above the content travels even where the chapter heading above it cannot — and only where no member can travel does the content move alone, exactly as it would without the avoid. Two shapes keep the older behavior of moving the group rather than laying it out again — a run whose members include a table that repeats a header, which does not survive a second layout, and a heading on a page that no single layout pass was filling (a forced break can step over a page without ending the pass). The heading still travels with its content in both; only the way it gets there differs, and the paragraph below it can then show a little extra space where the page boundary fell inside it. The run is found at the break point the content really names, so a heading is chained to it whether it is a sibling of the moving box or of a container that box begins (see break propagation above). The same chain applies at a column boundary, where avoid-column joins avoid in naming it — see Keep-with-next at a column boundary — and between two flex lines or grid rows, where the chain runs over lines rather than siblings — see Breaks in flex and grid containers.
Margin truncation at page breaks: per CSS Fragmentation Level 3 §5.2, when a vertical margin between two elements is large enough that it alone would push the following element onto a later page (an unforced break — no explicit break-before/break-after/page involved), that margin is discarded entirely and the following element starts flush at the top of the very next page, rather than the margin paginating through as literal blank pages. A margin-top large enough to visually separate content across a deliberate page boundary should use an explicit break-before: page (or page-break-before: always) instead — margins after a forced break are preserved, not truncated, per the same spec section. This applies to normal block flow, including inside a multi-column container, whose columns are fragmentainers; flex, grid and table position their own children and are not covered.
The element need not have a preceding sibling: the break point before a container’s first in-flow child is a real break point too, and its margin is truncated at one. This holds when something on the container — a border or padding — keeps that margin from collapsing out of the child and into the container. Where nothing does, the margin collapses all the way up the ancestor chain and belongs to the outermost box it reaches rather than to the child, and a margin that reaches the root that way still paginates as blank space.
Forced breaks before a first child, and break propagation. break-before (and the legacy page-break-before) is honored on a container’s first in-flow child, which is the common section > h1 { break-before: recto } idiom. Per §3.1 the break point before a first in-flow child is the break point before its container, and likewise a break-after on a last in-flow child is the break point after the container — so the value travels outward through every box the element begins or ends, and the outermost such container is what takes the break and moves with it. Its own background, border, padding and margin therefore begin the new page along with the element, rather than being left spanning the boundary with an empty copy on the page the content left. A heading chained to the moved content by break-after: avoid is carried along too, whether it is a sibling of the element or of the container, so the two shapes behave the same way.
Propagation stops before the fragmentation context itself, and before any box whose children are positioned by a layout engine of their own — a flex or grid item, a table cell, a multi-column child. A break declared on the very first thing in a document therefore has nothing before it to break from: no break is taken and no blank page is manufactured in front of it, which is §4.4’s “no empty fragmentainer” rule. For the same reason, a forced break that is not taken still counts as one for margin purposes: there is no break point in front of such a box for a margin to adjoin, so its margin is preserved rather than truncated.
Combining break values at one break point. Where more than one forced value applies at a single break point — this box’s break-before, the preceding box’s break-after, and anything either of them propagates outward — §3.1 asks that all of them be honored. A directional value forces a page break as well as naming a side, so it subsumes a plain page. Two conflicting directional values cannot both be honored, and there the spec is explicit: the value on the latest element in flow wins, which for a value travelling outward is the deeper one.
The same applies to the breaks that are decided after content has been placed rather than declared in advance — break-inside: avoid, monolithic content, and an orphans push. When one of those relocates a container’s first child, the container goes with it, so its border, background and padding open the new page rather than being cut in two. The exception is a container too tall for the destination page: moving it there would leave it not fitting either, so it stays and only the element moves — the same progressive relaxation an unsatisfiable avoid gets.
Breaks in flex and grid containers. A flex container’s break points are between its lines, and a grid container’s between its rows (CSS Fragmentation Level 3 §3.1) — not between the items sharing one, which the cross-axis alignment holds together. So break-before on an item, and break-inside: avoid (or avoid-page) or monolithic content among its items, move the whole line or row to the next page rather than the item that asked. An item spanning several grid rows travels with the first of them.
A forced break is taken from either side of the break point, as §3.1 requires: break-after: page (or the legacy page-break-after: always) on an item starts the line or row after it on the next page, exactly as break-before on that following line’s own items would. A directional value (left, right, recto, verso) is honored in full here, as it is in block flow: the line or row opens a page of the requested side, and a blank page is inserted where the side needs one — see Directional page breaks. Where more than one value applies at one break point they combine by §3.1’s rule, so a directional value on either side subsumes a plain page on the other. The line that declared the break-after stays where the flow put it — the break is after it. A break-after on the last line or row names the break point after the container itself, which a flex or grid container does not act on: a break travelling out of an item would name a position the engine is about to overwrite. An item spanning several grid rows is the earlier sibling of the row after the last row it covers, so its break-after is taken there rather than at a boundary running through the middle of the item.
A forced break at a point that already is a page boundary is already satisfied, per §4.4’s “no empty fragmentainer”: a line whose top is flush on a page’s content top stays there rather than being moved a further page on. A directional value asks for more than a boundary, though — it asks that the content begin on a page of the named side — so a flush line satisfies it only where the page it already begins is that side, and otherwise still moves.
Keep-with-next between two lines or rows. break-after: avoid (or avoid-page) on one line’s items, and break-before: avoid on the next line’s, both forbid an unforced break at the point between them, and PeachPDF honors it by moving the earlier line: where a page boundary would otherwise fall there — because the later line was relocated, or simply because the flow put it on the next page — the earlier line travels with it, so a heading that is a flex item is not stranded at the foot of the page its section body just left. The UA default stylesheet’s h1-h6 { break-after: avoid } makes this the ordinary case. Chains are transitive over consecutive lines, a forced break at the same point takes precedence per §3.1, and an avoid that cannot be satisfied is relaxed progressively per §4.3 rather than abandoned: the chain is trimmed from its front until what travels fits the destination page, and a chain reaching the top of its own page is dropped altogether, since moving all of it would leave that page blank and ask the same question again on the next one.
An item with no break value of its own is left where it is, and the page boundary cuts it — the same answer ordinary block content gets when it has no line to break at. A line or row taller than a whole page is also left alone, since moving it could not help.
Which line is above the boundary is a question about the page, not about the source. The two orders agree in a grid and in a flex container that wraps the ordinary way, but flex-wrap: wrap-reverse puts the first line in the source last down the page. PeachPDF reads such a container down the page: the lines that follow one that moves are the lines physically below it — there, the earlier ones in the source — so a relocated line is never drawn over the top of one that stayed put. Identifying the break point is still §3.1’s question about source order, so break-after on the first line’s items and break-before on the second’s name the same point, which under wrap-reverse is the boundary above the first line — and it is therefore that line which opens the next page.
The break point before the container’s first in-flow child is the break point before the container itself, and that boundary sits above the container’s topmost content whichever line happens to be there — so under wrap-reverse a break-before on the first item still starts the whole container on a new page, rather than tearing off the one line that sits at the bottom.
A column container — flex-direction: column or column-reverse, wrapping or not — stacks its lines along the inline axis instead: two side-by-side lines sit in one range down the page, so no page boundary falls between them and a break value declared at that point has nothing to act on (unchanged from before). What such a container’s lines do have are real break points between the items within one line, since those are genuinely stacked in the block axis — exactly the shape a row container has between its lines, one level down. So break-before/break-after between two items of the same column line is honored (taken from either side, per §3.1), and break-inside: avoid or monolithic content on one item moves only that item — and whatever follows it in the same line’s block-axis order — rather than the line’s earlier items or any other line. Only the container’s own first in-flow child still speaks for the boundary above the container itself: every other line’s own leading item starts at that same block-axis position with nothing genuinely before it in flow order, so a break value declared there is inert, the same way the point between two side-by-side lines is. Two side-by-side lines are independent of each other for this purpose: moving an item in one because of its own break value never displaces the other line, which is exactly what “no break point between lines” already implied.
Two limits are worth knowing. A flex or grid container inside a table is positioned against the table’s own row grid, so its lines are left to the table engine rather than moved against the page grid. And an inline-flex or inline-grid box is an atomic inline — where it sits is the line it is on to decide.
Item content fragments across pages for every flex and grid shape: a flex-direction: row/row-reverse line’s items (whether the container has one line or wraps into several), a flex-direction: column/column-reverse line’s items (walked in sequence — see below), and every grid row’s items (including a row-spanning item’s own content, and a subgrid item’s). Each item’s own content — a line of text, or a nested block-level layout such as a column-count/column-width container — continues on the next page rather than being moved whole or cut when it does not fit. This composes with everything above: the whole-line/row relocation described earlier in this section still decides which page a line or row begins on, and each item’s own content is then laid out for real once it is there.
A row/row-reverse line’s items are css-break-3 §2.1 “parallel flows” (the spec’s own example is exactly this shape) — they commit together, one item’s own content stopping partway through a page does not stop a line-mate beside it from continuing or finishing on its own. Grid rows read the same way. A column/column-reverse line’s items are instead a sequential flow along the block axis: each is walked and committed in turn, so a later item’s content continues onto the next page only once an earlier item has finished; a flex-wrap column container’s several lines, which sit side by side sharing no block-axis range, each run this sequence independently of the others. This composes with the break-value behavior described above: a forced break-before/break-after between two items of one line, or a break-inside: avoid item that straddles a boundary, decides which page each item begins on, and each item’s own content then fragments across pages from there exactly as it does anywhere else in a column line.
A line or item content taller than the page’s whole content band is left alone wherever it appears, the same monolithic content answer ordinary block flow gets, since there is no page that could hold it — moving it could only repeat the same overflow on the next page. It is clipped to the page it begins on rather than continued: give such content a break-inside: avoid (or size it so a line cannot straddle) where that would be unacceptable.
Breaks between table rows. The break point between two rows of a table is an ordinary break point (CSS Fragmentation Level 3 §3.1), and a forced value declared there is honored: break-before: page (or one of the directional values) on a <tr> starts that row on the next page even where it would have fitted, and a break-after on the row above it does the same. A value on a <tbody>, <thead> or <tfoot> is a value at the break point before its first row or after its last, so it is honored at that row. A table repeating a header carries the header onto the page such a break opens, exactly as it does at a break the row heights themselves forced.
A row that would straddle a boundary is carried onto the next page whole rather than split, wherever the engine can move it. A row whose cells wrap over several lines is otherwise split between those lines at the page foot, as a block is; break-inside: avoid (or page-break-inside: avoid) on the row, or on one of its cells, carries it onto the next page whole instead. A row that is the first on its page, or is taller than a page, is still split there, and so is a row of a table nested inside another table’s cell. Inside a multi-column container, break-inside: avoid on a row does not carry it into the next column.
A cell whose rowspan crosses a page break is split at the break. CSS Tables Level 3 asks for a row to be kept unfragmented only “if the cells spanning the row do not span any subsequent row”, so the row that ends a span is carried onto the next page whole like any other row, while the spanning cell itself is fragmented: its box closes at the foot of the page it began on and opens again at the head of the next, its borders and background running the depth of each fragment, and — as the spec requires — no border is drawn at the break on either side of it. The column-context values (column, avoid-column) name a fragmentation context a table does not establish; inside a multi-column container they are the container’s to act on.
This holds even where the spanning cell’s own content is taller than the page it began on: the cell’s content stops and resumes across pages the same way any other content does, and the cell’s box closes and reopens with it rather than being stretched across every page in between.
Limitation: a spanning cell is not split inside a multi-column container, where a table’s rows are left to the table’s own grid rather than moved against the column; and the cell’s
vertical-alignresolves against the fragment on the page it began on rather than against the whole cell, somiddlecentres its content in that fragment.
A row taller than the page is left where it is, and the rows after it continue below it. Where a row’s cell holds a block taller than the whole content band — a fixed-height figure or chart, say — there is no page that could hold that row, so it stays where it is and overflows across as many pages as it needs (CSS Fragmentation Level 3 §4.3: moving it could only recreate the straddle). The rows after it are placed immediately below where it ends, on the page its bottom actually landed on.
A repeating header is carried onto every page the table spans, and the row resumes below it. CSS Tables Level 3 repeats a <thead>/<tfoot> on each page a table spans, not on each page it broke on, and asks the user agent to “leave room” for the group there. A page that a too-tall row merely overflows through is one of those pages — and the only way to leave room on a page nothing breaks on is to slice the row itself, which CSS Fragmentation Level 3 §4.3 allows in as many words: “the UA may also fragment the contents of monolithic elements by slicing the element’s graphical representation”.
So the row is neither resized nor moved. Each page draws it from a different origin and shows the strip belonging to that page, and the strips meet exactly — a background, a gradient or an image runs continuously from one page to the next, picking up below the repeated header rather than underneath it. This applies to a block taller than the page, to a replaced element such as an <img> or an inline <svg>, and to the second and later pages of a single-row table taller than one page. A <tfoot> closes each of those pages the same way, from the other end of the band.
Limitation: the room is left on the pages a row overflows through, which are the pages no move could help with. A row that would fit the next page is moved there whole instead, as before, and a table that did not fragment at all is moved whole by the rule below — neither is sliced. Where a page’s own band is short enough that the repeated groups would leave the row no room at all, that page gives the row the whole band, since a page that took no content could never finish the document — the repeated group is still drawn there, so on that page alone it overlaps the row rather than sitting above it. Reachable only under per-page
@pagegeometry, since the quarter-of-the-page conditions above otherwise keep both groups well inside a band.
A table that did not break moves whole. A table breaks between two of its rows when the next row does not fit, and where no row break was taken at all — most often a single-row table, or one whose only row is taller than the page — the table did not fragment, and it is carried onto the next page in one piece rather than painting sliced across the boundary. This applies to every table, including one repeating a <thead> or a <tfoot>: the header follows it onto the page it lands on. A table taller than one page is left where it is, since moving it would only recreate the straddle. A heading chained to the table by break-after: avoid — the user-agent print default for h1–h6 — travels with it, as at every other relocation.
A cell’s text breaks between lines, not through one. Where a cell holds more text than the page has room for, the text continues on the next page and each line lands whole on one page or the other — a line is never cut through the middle and drawn on both. This holds for the cell’s own text and for text inside a block within it alike.
A row whose cell runs out of room continues on the next page. A table is fragmented like any other content: where a cell cannot fit the rest of its content on the page, the row it is in continues on the next one, and the rows after it are placed there rather than being left unplaced. The cells of one row are fragmented independently (CSS Fragmentation Level 3 §2.1, css-tables-3 §6.1): a short cell beside a long one finishes on the first page and none of its content is drawn again on the continuation, while the long one picks up exactly where it stopped. A table repeating a <thead> carries it onto every page such a row reaches, and room is left for it there, so the continuing cell resumes beneath the repeated header rather than being overlapped by it — the same as at a break between two rows. A repeating <tfoot> closes each of those pages the same way, read from the other end of the band: CSS Tables Level 3 asks for room to be left for a repeated header and says “the same applies for footer rows and the table bottom border”, so the continuing cell’s own text stops above the repeated footer rather than running underneath it. The footer on the table’s last page is a different footer — the one that closes the table, under its final row.
Limitation: the room left for a repeated
<tfoot>is left for the cell’s own text. Content in a cell that runs its own fragmentation — a multi-column container — and a block inside a cell that is moved whole rather than split are both placed against the full page instead, so either can end up underneath the repeated footer.
A finished cell’s box continues with its row. §6.1 continues the row’s box into the next page and every cell’s box with it, so the cell that finished is drawn there as its own background and borders running the full depth of the row’s continuing fragment, with no content in them — which is what a browser draws. That depth is set by the cells that do continue into the page, since a cell with nothing left to place asks for no height of its own. The edges the page break itself made carry no border, because box-decoration-break defaults to slice: the cell closes with a border at its real top and its real bottom only, exactly as a block spanning pages does.
When a <thead> or <tfoot> repeats. CSS Tables Level 3 makes repetition conditional, and PeachPDF applies both conditions:
- The group must carry an avoiding
break-inside. The user-agent stylesheet suppliesthead, tfoot { break-inside: avoid }under@media print(which PeachPDF always uses), so this is what every table gets by default — repetition out of the box, as in a browser. Writingbreak-inside: auto(orbreak-inside: avoid-column, which names a context a table does not establish) on the group is the way to opt out: the group is then laid out once, in flow, at the table’s top or under its last row, and the pages after the first are given wholly to the rows. - The group must be shorter than a quarter of the page — a quarter each for the header and the footer, per the spec’s “up to one quarter for header rows, and up to one quarter for footer rows”. A taller group is laid out once instead. The room a repeated group takes is genuinely reserved on every page the table spans (see above), so without the cap a tall group is charged its own height out of every one of them — and a group taller than the page’s content band would leave no page able to make progress at all. The quarter is measured against the whole page box, margins included, which is what “the page height” means here — not against the smaller content band the group is actually drawn in.
Both are decided once per table, and a page a row continues onto inherits that decision rather than re-taking it. A table laid out with no page grid at all — a measurement pass, or an unpaginated container — has no page to take a quarter of, so only the break-inside condition applies there.
A group that is not repeated still has to fit the page it is drawn on. No room is reserved for it — reserving room on every page is exactly what not repeating avoids — so where the last row runs to the foot of its page, the closing <tfoot> is carried onto the next one whole rather than drawn across the boundary. It moves as a unit, since a row group cannot be split; a footer taller than a whole page is left where it is, because moving it could only recreate the straddle.
Two limits. Keep-with-next (break-after: avoid) between two rows is not honored — the chain is read among block-flow siblings, and a table’s rows are placed by the table itself. And a break value on a box inside a cell is likewise the table’s row grid to answer, not the page grid’s.
Monolithic content moves whole rather than being split (CSS Fragmentation Level 3 §4.1). Content treated as unbreakable is carried onto the next page in one piece where it would straddle a page boundary. Two kinds qualify:
- Replaced elements —
<img>,<svg>,<iframe>, and an<object>/<video>whose resource resolved to an image. These have no inner structure to fragment. - Scroll containers — a box whose
overflowis notvisibleorclipand that has a definite logical height. A percentage height against an indefinite containing block is automatic for this purpose, and in vertical writing the logical height is the physical width. A scroll container with an automatic height can break between lines and blocks — with amax-heighttoo, for as long as its content is under the cap — so a long code listing can begin in the space remaining below its heading, and anoverflow: hiddenwrapper around the page body does not slice a line at the page edge; amin-heightalone does not make it unbreakable, andbreak-inside: avoidkeeps a short one together. The root element is excluded, because itsoverflowpropagates to the viewport rather than making it a scroll container (CSS Overflow Level 3 §3.3); a paginated document has no viewport for it to propagate to, so the commonhtml { overflow: hidden }idiom does not declare a whole document unbreakable.<body>is excluded on the same grounds, but only while the root’s ownoverflowisvisible— once the root declares one, the body’s own overflow and sizing determine whether it is monolithic. (overflow: clipis not currently implemented and computes asvisible, so it is excluded either way.)
§4.1 allows an implementation to treat an overflow: auto or scroll box as monolithic, and an overflow: hidden box only when its logical height is not auto and it has no specified maximum. PeachPDF keeps a box with a definite height unbreakable for every overflow value, and breaks one with an automatic height and a max-height like a block: between its lines, for as long as the content placed is under the cap. The cap counts the box’s consumed height across the pages it breaks over, not the distance from its first fragment, so the unused strip at a page foot is not content and the box ends on the page where its lines add up to the cap. Content past the cap (clipped away for overflow: hidden) is laid out without any break, so the content after the box is never lost and clipped lines never add a blank page. A box with a max-height that holds a table, flex or grid container stays whole instead, because those engines take breaks of their own that cannot be held back past the cap.
One boundary is worth knowing: the rule applies to normal block flow, and inside a multi-column container, whose columns are fragmentainers. A scroll container that has no size of its own and sits inside a multi-column container is nonetheless kept unbreakable, because that engine clips it per column and letting it break there dropped lines at the edge. One that is a flex or grid item breaks between its lines like any other block. Inside a flex or grid container it applies to the whole line or row the box is in — see Breaks in flex and grid containers. A monolithic box inside a table is placed against the table’s own row grid, so it can still be split.
A box that had already begun flowing its own text across the boundary is laid out again at its new position rather than moved to it, so it reads exactly as it would have if it had started there — no blank band inside it, and no unused height. The same is true of every relocation described in this section: break-inside: avoid, monolithic content, and the widows fallback below all share one mechanism. Neither of the two §5.4 line minimums relocates anything in the ordinary case: an orphans violation makes the break fall before the box (see orphans above), and a widows violation makes the fragment before the break keep fewer lines, so in both the box is laid out once, where it belongs. A scroll container taller than every page’s own content band cannot be helped by moving it, so it is not moved at all: its content simply lays out unbroken and overflows across as many pages as it needs, each page showing its own slice — the same way any other oversized content already does.
One case still moves the box instead of laying it out in place: a keep-with-next run whose heading was already settled on an earlier page — which happens when the box’s own text broke across the boundary and it finished later — moves as a group: the heading still travels with its content, but the content keeps the gap.
A line box is never split across pages (CSS Fragmentation Level 3 §4.1). When a line does not fit on the page it started on, the whole line moves to the next one — including any part of it that would have fitted.
Content continues at the page’s content edge. Where content flows onto a following page it starts at that page’s content edge, rather than fractionally below it.
Layout gives up pagination before it gives up content (CSS Fragmentation Level 3 §4.3). Every rule above may decline to place content where it would rather not go — break-inside: avoid, a keep-with-next chain, monolithic content, orphans and widows — and §4.3 asks that such constraints be given up progressively rather than at the cost of content. The last thing given up is pagination itself: if a page is reached that layout cannot get past, the rest of the document is laid out from that page on in one piece, with no further break decisions, so it runs past the page’s edge and is cut at each boundary rather than broken at one. Those pages will look wrong, but the document renders.
Decorations at a break
When a break splits a box, box-decoration-break (CSS Fragmentation Level 3 §6.2) decides whether its border, padding, background, border-radius and box-shadow belong to the box as a whole or to each piece. Both values are honored, and — as the spec requires — at every kind of break: the page breaks of a block box, the column breaks of a box in a multi-column container, and the line-box breaks of an inline box that wraps.
slice, the initial value, renders the box as though it were never broken and then cuts it at the break. So no border and no padding is inserted at the break, no shadow is drawn along a broken edge, and the background, gradient layers and corner radii follow the whole box’s geometry. A wrapping <span> with a background gradient and rounded corners therefore renders as one continuous shape whose gradient runs on across each wrap and whose only rounded corners are at its true start and end — the same as a browser.
clone wraps every piece independently: its own border on all four sides, its own padding, its own radii and shadow, and its own background, so a no-repeat background image is drawn once per piece rather than once per box. This is what to reach for when every line of a highlighted inline should be a self-contained rounded pill, or when a block crossing a page boundary should be closed off with its own border on each page. Space is reserved for the cloned border and padding, so they push the box’s content rather than overlapping it — and where boxes are nested, each piece opens inside its ancestors’ own re-opened border and padding.
Margin is cloned at a line break, alongside the border and padding, so each line of a margined inline keeps its gap on both sides. It is not cloned at a page break, because a margin adjoining one is already truncated to zero (see Margin truncation at page breaks above), leaving nothing there to clone.
Limitations:
- Room is reserved for
clone’s border and padding in normal block and inline flow, and at a column break — including for a box whose own content is blocks rather than text, which is split at a column boundary like any other. Inside a flex, grid or table, whose own engine positions its children, a cloned decoration at a break is painted but no room is made for it, so it can overlap content. - A page break is decided against the text itself rather than the line box around it, so the half of a large
line-height’s leading that falls below the last line on a page is not counted. A cloned bottom border can therefore sit within that half-leading. Ordinary line heights leave this invisible. - An inline box that wraps and is split at a column boundary has its unbroken width measured within each column rather than across both, so a gradient on such a box restarts in the second column. A block-level box is unaffected, and so is an inline box that only crosses page breaks.
- Where a line box of an inline straddles a page boundary, that one line’s decoration slice is drawn on both pages: once in its own place, and once near the top of the following page. Under
slicethe second copy is usually a thin band, partly clipped by the page edge; undercloneit is a complete closed frame, drawn entirely inside the following page’s content.
Tables
| Property | MDN Reference | Notes |
|---|---|---|
empty-cells |
empty-cells | show, hide. Applies to the separated borders model only: a border-collapse: collapse table paints its empty cells whatever the value |
background on <tr>, <thead>/<tbody>/<tfoot>, <col>, <colgroup> |
background | Per CSS 2.1 §17.5.1: each one’s background covers exactly the cells that originate in it, beneath every cell. A cell spanning several rows shows the background of the row it starts in over its whole height, so a striped row further down never covers it, and a cell spanning several columns shows that of the column it starts in. The gaps border-spacing leaves between cells show the table’s own background, as does a slot no cell fills, and a cell hidden by empty-cells: hide shows none of them (the table background shows through, as the specification says; some browsers keep the row’s background there). A <col> or <colgroup> with visibility: hidden still has its background painted, since only collapse has a meaning for columns. Background images and gradients are positioned against the row’s, group’s or column’s own rectangle, so a gradient on a row runs across all of its cells |
caption-side |
caption-side | top (default), bottom — a table-caption box is stacked above or below the table’s row grid and stretched to the table’s own content width, outside the <table> element’s own border/background per CSS 2.1 §17.4 |
table-layout |
table-layout | auto (default) and fixed. fixed sizes each column from its <col>/<colgroup> element if one states a width, otherwise from that column’s cell in the first row only (divided evenly across a colspan) — no cell content is ever measured and no row after the first affects a column’s width, so overflowing text wraps inside its column rather than widening it — and text that cannot wrap overflows the column visibly, as it does in a browser, since a cell does not clip unless it declares overflow: hidden itself. Any remaining columns split the leftover space evenly. Requires the table’s own width to be a specified (non-auto) length — with width: auto, fixed has no effect and the table uses the automatic, content-based algorithm instead, matching real browsers. max-width on the table still clamps the space being divided |
height / min-height on <table>/<tr> |
height | Per CSS 2.1 §17.5.3: a <table>’s used height is the maximum of its specified height/min-height and the rows’ own natural total — an explicit value smaller than the rows’ content never clips the table, only a larger one grows it (spec-mandated). Any surplus above the natural total is distributed across the body rows proportional to each row’s own natural height — the same rule this engine already applies to column-width surplus, since CSS 2.1 itself leaves the exact distribution undefined. A <tr>’s own height/min-height works the same way, one level down — a per-row minimum, never a clip. A repeating <thead>/<tfoot> group’s own rows keep their natural height and never receive a share of the surplus. On a writing-mode: vertical-rl/vertical-lr table, the row axis is physical width rather than height, so this same minimum-and-redistribute behavior is driven by width/min-width on the <table>/<tr> instead — height/min-height there keep their own, separate role sizing the table’s columns (the inline axis). This still applies, on a best-effort basis, even when the table’s own row loop must continue across a page boundary onto a separate, later page — the table’s total natural row-axis extent across every page is tracked as it goes, and the final page’s own remaining rows receive their own proportional share of the surplus computed against that running total. Rows already drawn on an earlier page are never touched or redrawn, so in this specific case the table’s overall realized size can still fall a little short of the declared height/min-height (by however much of the surplus those earlier, already-committed rows would otherwise have taken) — an ordinary table, including one spanning many pages via per-row breaks within a single pass, reaches the declared size exactly. This continuation case is also less precise when the table additionally declares non-default border-spacing or a <caption>, tending to under-distribute the surplus rather than reaching the fully correct proportional share |
Generated Content
| Property | MDN Reference | Notes |
|---|---|---|
content |
content | Used with ::before / ::after pseudo-elements; string, counter, attr(), and none values supported; url() (including an SVG source, rendered as real vector content) and all CSS gradient functions (linear-gradient, radial-gradient, conic-gradient, and repeating variants) are supported — image values require display: inline-block with explicit width/height on the pseudo-element. counter() accepts an optional counter-style argument (counter(name, <style>), e.g. counter(line, decimal-leading-zero)) using the same named styles as list-style-type (a literal <string> marker isn’t one of them, since it isn’t a counter style); the style defaults to decimal, and an unknown/unsupported style falls back to decimal per CSS Counter Styles Level 3 §2. counters(name, <separator> [, <style>]) is supported too, joining every value of that counter in the current scope chain (outermost first) with the separator — the usual way to render nested list numbering like 2.3.1. target-counter(<target>, <counter-name> [, <style>]) and target-text(<target> [, content | before | after | first-letter]) (CSS Content Module Level 3 §5, cross-references) resolve a counter’s value or an element’s text at another element rather than the current one — the standard mechanism for a hand-authored table of contents with real (non-stale) page numbers. <target> accepts <string> (a literal id), url(#id), or attr(href)/attr(id) (read off the declaring element, or its parent for a ::before/::after). target-counter(<target>, page) resolves against the actual page the target element lands on after layout — the same mechanism counter(page) uses inside @page margin boxes, extended to flow content; a target inside repeated running/margin-box content or a repeated table header/footer is not resolved (see Known limitation below — the same box-tree walk both features share). target-text()’s optional second argument selects content (the target’s own text, the default), before/after (the target’s ::before/::after pseudo-element content), or first-letter (the first character of the target’s text). leader(<type>?) (CSS Content Module Level 3 §6, <type> = dotted \| solid \| space \| <string>, defaulting to dotted when omitted — leader() alone requests a dotted leader) fills the remaining space on the line with a repeating pattern — the classic Chapter One .......... 12 table-of-contents idiom. When more than one leader() appears on the same line (including across separate elements), the remaining space is shared equally between them, per spec. open-quote, close-quote, no-open-quote, and no-close-quote (CSS 2.1 §12.2) insert a quotation mark from the quotes property (below) or adjust the nesting depth without inserting one; PeachPDF’s UA stylesheet declares q::before { content: open-quote } / q::after { content: close-quote }, so a bare <q> renders quote marks with no author CSS, matching browsers |
quotes |
quotes | Pairs of open/close strings consumed by the open-quote/close-quote/no-open-quote/no-close-quote content keywords above (CSS 2.1 §12.3.1). The UA default is guillemets (« »), not curly quotes. Nesting depth is the count of open-quote/no-open-quote occurrences minus close-quote/no-close-quote occurrences preceding a given occurrence in document order (independent of the source document’s own element nesting); a depth beyond the number of declared pairs repeats the last pair, and an unmatched close-quote/no-close-quote is ignored (rather than going negative) while the rest of content still inserts. quotes: none suppresses the glyphs while still tracking depth |
counter-reset |
counter-reset | Full support, including the reversed(<counter-name>) functional notation — a bare reversed(name) with no explicit value starts at the number of elements in scope that will increment it, counting down so the last one lands on 1 |
counter-increment |
counter-increment | Full support. Every element whose computed display is list-item (not just <li>) automatically increments the implicit list-item counter, per CSS2.1 12.5.1 / CSS Lists Level 3 — the same counter content: counter(list-item) and ::marker’s default numbering both read, so they always agree. <ol start>/<ol reversed> and <li value> are honored as presentational hints (lowest precedence — literal author counter-reset/counter-set targeting list-item still wins) |
counter-set |
counter-set | Full support |
string-set |
string-set | CSS Paged Media property for running headers/footers |
page |
page | Activates a named @page rule for pages containing the element |
CSS At-Rules
| At-rule | Notes | |
|---|---|---|
@font-face |
src supports url() (remote/data-URI, with a comma-separated fallback list — each candidate is tried in order until one loads) and local(). The rule’s own font-weight/font-style/font-stretch descriptors are authoritative for how that specific resource participates in matching, independent of what the font file’s own internal tables say — this is what makes real multi-variant families (e.g. separate rules for weight 400 and 700) work reliably. The descriptors take ranges as well as single values (font-weight: 100 900, font-stretch: 75% 125%, font-style: oblique 0deg 14deg, and auto): a face covers every value inside its range in matching, so a request between two ranges is measured from the nearest end of each; the weight, width and slant of the requesting box then set a variable font’s wght, wdth and slnt axes, kept inside the range (a font-weight: 300 600 face is drawn at 600 for font-weight: 900, and text that still asks for more than the range reaches is faux-bolded); font-variation-settings still wins over both. A face declared oblique also serves upright text when its range includes 0. A family’s faces are narrowed in the order the specification gives: by font-stretch first, then by font-style, then by font-weight, so a condensed italic box uses a family’s condensed upright face (with the lean faked) rather than its italic face of normal width. Among faces that declare oblique ranges, font-style: oblique <angle> takes the range that holds the angle or else the nearest (the ranges above the angle first for 11 degrees or more, the ranges below it first for less), whatever order the rules were declared in; italic takes a face declared italic when the family has one and otherwise the range nearest 11 degrees. For an angle of 0 degrees or more, oblique <angle> tries the oblique ranges leaning the same way as the angle first, then falls back to a declared italic face, and only then to the ranges leaning the other way. oblique 0deg is upright’s equivalent on this scale: a genuinely upright face is preferred to any oblique range, whether or not that range happens to include 0. A bare oblique is treated as italic. A descriptor that is left out, is auto or cannot be read leaves the face to what the file itself says, and a variable font then covers the range of its own axes. unicode-range is honored: multiple rules sharing a family but declaring different codepoint subsets each supply their own characters, and characters outside every declared subset fall back to the next family in the stack (see Per-character font matching). When two subset files share one internal font name (a common webfont pattern), each is still treated as its own resource. An installed variable font is matched the same way as one added with no descriptors: it covers the ranges of its own axes. A variable font file needs one rule: the face is matched at the weight, width and slant each box asks for, from the axes in the file or the range the rule declares, see font-variation-settings. See Fonts |
|
@counter-style |
Supported (CSS Counter Styles Level 3). Defines a named counter style for list-style-type and counter()/counters(). Descriptors: system (cyclic, numeric, alphabetic, symbolic, fixed <integer>?, additive, extends <name>), symbols, additive-symbols, negative, prefix, suffix (default ". "), range, pad and fallback (default decimal); speak-as is accepted and ignored. A rule that can’t work (a missing or too-short symbol list for its system) is ignored. A rule may override a predefined style’s name (upper-alpha, …) but not none, decimal, disc, circle, square or the disclosure-* styles; extends may name a predefined style, and an extends cycle behaves as decimal. Names are case-sensitive. Rules inside @media/@supports/@layer are honoured |
|
@font-palette-values |
Supported (CSS Fonts Module Level 4). Defines a named custom palette (a <dashed-ident>) that font-palette can select for a COLR/CPAL color font. Descriptors: font-family (required — the family the palette applies to, matched against the element’s used family), base-palette (a palette index, or the light/dark keywords), and override-colors (a comma-separated list of <index> <color> pairs that replace individual palette entries). A rule with no font-family is ignored. Applies to SVG text the same way as @font-feature-values: an inline <svg> uses the page’s rules, a standalone SVG image its own |
|
@font-feature-values |
Supported (CSS Fonts Module Level 4). Associates named OpenType feature-value aliases with one or more font families (a <family-name># prelude, e.g. @font-feature-values Font One, "Font Two" { … }), so font-variant-alternates can reference a readable name instead of a raw feature index. The block holds one or more nested @styleset/@character-variant/@swash/@ornaments/@annotation/@stylistic rules, each a list of <custom-ident>: <integer>+; declarations (e.g. @styleset { nice-style: 1 7; } — a styleset() name may list several integers, turning on that many numbered features at once). A name is scoped to the block kind and family list it was declared under; a later rule declaring the same (name, family) pair for the same block kind overrides an earlier one. Applies to SVG <text>/<tspan> as well: an inline <svg> consults the page’s rules, a standalone SVG image its own <style> rules |
|
@page |
Full support; see CSS Paged Media below | |
@property |
Supported (CSS Properties & Values API Level 1). Registers a typed custom property with its three descriptors. initial-value supplies the value a var() reference resolves to when the property is otherwise unset (so a palette or default defined only via @property works with no author declaration). inherits governs propagation: inherits: false means a descendant that doesn’t set the property resolves it to the initial-value rather than the parent’s value. syntax is validated at computed-value time — a set value that doesn’t match the declared syntax falls back to the initial-value — for the single-type grammars <length>, <number>, <integer>, <percentage>, <length-percentage>, <color>, <angle>, <ratio>, <image>, <url>, <time>, <resolution>, <transform-function>, <transform-list>, <custom-ident>, <string>, ident literals, the universal *, \| alternation, and the +/# list multipliers. A syntax naming an unsupported data type is invalid and the whole rule is ignored. calc() is accepted in a numeric slot when its resolved type matches (e.g. calc(50%) for <length-percentage> but not <length>); an initial-value calc() must additionally be computationally independent — it may use absolute lengths, numbers, angles, times, resolutions, and percentages, but not font-relative (em/rem/ex) or viewport lengths. <image> accepts url(), gradients, and image-set()/cross-fade()/element() (image-set() renders, as in background-image, list-style-image and content; cross-fade() and element() are validated for registration but not rendered). image-set() is chosen among by resolution the way a srcset is: paged output takes the highest resolution, a screen document takes the smallest option sharp enough for its device pixel ratio; an option whose type() cannot be decoded is skipped. A zoomed screen document moves to a denser option through HtmlDocument.UpgradeImagesAsync, like a srcset (border-image-source is chosen once). The registry also applies to SVG styling (see SVG support): an inline <svg> uses the host document’s registrations, and a standalone SVG uses @property rules from its own <style> |
|
@media |
Supported; print and all media types apply, screen is ignored, and feature queries (min-width/max-width/range syntax, orientation, resolution, prefers-color-scheme, …) are evaluated against the page box and renderer characteristics; see CSS Media Queries below |
|
@keyframes |
Supported when an animation is rendered as a still frame with PdfGenerateConfig.AnimationProgress; see Animations. Without that option the rule is parsed and ignored. Among rules of the same name the cascade layers order decides (an unlayered rule over a layered one, a later layer over an earlier one) and the later rule wins a tie; the name may be an identifier or a string, and a string names the same animation as the identifier spelled the same way. A rule inside @layer, a @supports whose condition holds, or an @media whose query matches the print medium is found. In a keyframe, !important declarations and animation-*/transition-* properties other than animation-timing-function are ignored. The CSS-wide keywords work: inherit, initial and unset resolve against the animated element, revert rolls back to the user-agent value and revert-layer to the author value. |
|
@starting-style |
Supported on a screen (CSS Transitions Level 2) | At the top level or nested in a style rule. Its rules take no part in the ordinary cascade; they give an element the style a transition runs from when the document first appears or the element is shown again after display: none (see Animations). Ignored when a PDF is made, and when AnimationsEnabled is off. |
@supports |
Supported (CSS Conditional Rules 3/4). (property: value), not (…), (…) and (…), (…) or (…), and arbitrary nesting are evaluated against PeachPDF’s actual implemented property set, not just whether the value parses — transition-duration parses but reports unsupported (transitions never run), while animation-name reports supported even when no AnimationProgress is set (the answer is about the implementation, not the configuration), and SVG-only properties like fill/stroke correctly report supported. A shorthand is supported only when the CSS-OM expands it into longhands that are all themselves supported, not merely by recognizing the shorthand’s name. The five CSS-wide keywords (inherit/initial/unset/revert/revert-layer) are always accepted. Two known, narrow gaps: a property with no dedicated grammar falls back to CSS-OM parse-validity rather than a full layout/paint-verified check, and a few hand-dispatched properties (mask, marker/marker-start/marker-mid/marker-end, SVG clip-path) aren’t covered by this oracle and report unsupported despite working. Any at-rule may appear inside a true-condition block and is collected normally |
|
@container |
Supported (CSS Containment 3). Size queries (@container (min-width: 300px) { … }) and style() queries (@container style(--theme: dark) { … }) are both evaluated, against the nearest ancestor query container’s real resolved size/style — see CSS Container Queries below for the full support table and behavior |
|
@layer |
Cascade layers are supported. Both the block form (@layer name { … }, including anonymous @layer { … }) and the statement form (@layer a, b, c;, which declares layer order) are parsed and honored. Layer precedence follows CSS Cascade 5: for normal declarations an unlayered rule beats any layered rule, and among layers a later-declared layer beats an earlier one — this ordering is applied ahead of specificity, so a low-specificity rule in a later layer wins over a high-specificity rule in an earlier layer. Specificity then source order break ties within a single layer. For !important declarations the layer order reverses: an earlier layer beats a later one, and a layered !important rule beats an unlayered one. Nested layers are ordered as a tree — a parent layer’s whole subtree is contiguous and its sub-layers are ordered by first appearance within that parent (so @layer a.b; @layer c; @layer a.d; ranks a.b, a.d, then c). An @font-face/@property/@page rule nested inside an @layer block (or an @media/@supports within one) is collected. Remaining simplification: when a single layer mixes direct rules with its own nested sub-layers, the parent’s direct rules are ranked before those sub-layers (the exact interleaving is not modeled) |
|
@import |
Full support; the imported stylesheet is fetched and its rules merged in place, including transitively nested @imports (with circular-import protection). Relative url() references inside an imported stylesheet — including @font-face src — resolve against that stylesheet’s own location, not the document’s |
CSS Selectors
PeachPDF evaluates a subset of CSS selectors. Selectors that are parsed but not implemented are silently ignored — rules using them will not apply.
CSS comments (/* ... */) are supported anywhere in a stylesheet, including between selectors and declarations, and are stripped before parsing.
Recognized but unmatchable selectors
A selector list is only as valid as its least-understood member: per CSS Selectors 3 §4, an unknown pseudo-class or pseudo-element invalidates the whole list it appears in, discarding the declarations for the selectors alongside it too. PeachPDF therefore recognizes the selectors it can never match, rather than treating them as unknown — they parse, they select nothing, and the rest of their list still applies:
/* The :root half applies; :host selects nothing. Both declarations survive. */
:root, :host { --brand: #2563eb; --spacing: .25rem; }
This covers the state-based pseudo-classes a static PDF has no state for (:hover, :focus, :visited, :valid, :user-invalid, :autofill, :target, :open, :popover-open, :modal, :fullscreen, :picture-in-picture, the media-playback set :playing/:paused/:seeking/:buffering/:stalled/:muted/:volume-locked, …), the Shadow DOM selectors PeachPDF builds no shadow trees for (:host, :host(), :host-context(), :defined, :state(), ::part(), ::slotted()), the pseudo-elements it generates no box for (::backdrop, ::file-selector-button, ::details-content, ::cue, ::view-transition*; ::selection, ::highlight(), ::spelling-error, ::grammar-error and ::target-text style text a host marks through PeachBrackets and match nothing in a PDF), and any vendor extension — a leading hyphen (CSS Syntax 3 §2) marks a UA-internal box or state, so ::-webkit-file-upload-button, ::-moz-focus-inner, ::-webkit-input-placeholder, :-moz-focusring and their kin are all accepted wholesale. :-webkit-any()/:-moz-any() are the legacy vendor spellings of :is() and behave exactly like it.
Pseudo-class and pseudo-element names are matched ASCII case-insensitively, so :HOVER and ::Before are recognized too.
This is deliberately not blanket acceptance of anything unknown: a genuine typo (:bogus-pseudo) still invalidates its selector list, exactly as it does in a browser.
Basic Selectors
| Selector | Syntax | Notes |
|---|---|---|
| Universal | * |
Matches all elements |
| Type | div |
Matches by element name |
| Class | .foo |
Matches by class attribute |
| ID | #foo |
Matches by id attribute |
| Compound | div.foo, .foo#bar |
Multiple simple selectors on the same element; all parts must match |
| Selector list | div, p |
Comma-separated; applies the rule to all matching elements |
Attribute Selectors
| Selector | Syntax | Notes |
|---|---|---|
| Presence | [attr] |
Element has the named attribute |
| Exact match | [attr=value] |
Attribute value equals value. In an HTML document, values are compared case-sensitively by default, except for a fixed list of legacy attributes (type, dir, align, rel, lang, and around forty others) that the HTML Standard always compares ASCII case-insensitively. SVG and MathML elements are always case-sensitive, list or no list |
| Whitespace list | [attr~=value] |
Attribute is a whitespace-separated list containing value |
| Contains | [attr*=value] |
Attribute value contains value as a substring |
| Starts with | [attr^=value] |
Attribute value starts with value |
| Ends with | [attr$=value] |
Attribute value ends with value |
| Hyphen prefix | [attr\|=value] |
Attribute value equals value or starts with value- |
| Case-sensitivity modifier | [attr=value i], [attr=value s] |
An i after the value forces an ASCII case-insensitive comparison and an s a case-sensitive one, for any of the value operators above (Selectors 4 §6.3). Without a modifier the comparison follows the document language: case-sensitive, except for the fixed HTML legacy-attribute list noted above |
Combinators
| Combinator | Syntax | Notes |
|---|---|---|
| Descendant | div p |
Matches p anywhere inside div |
| Child | div > p |
Matches p that is a direct child of div |
| Adjacent sibling | div + p |
Matches p immediately preceded by div at the same level |
| General sibling | div ~ p |
Matches p preceded by div anywhere at the same level |
Nesting
CSS Nesting is supported: a style rule may contain nested style rules, and the nesting selector & refers to the parent rule.
.card {
color: #333;
& .title { font-weight: 700; } /* == .card .title (& is explicit) */
.body { color: #555; } /* == .card .body (implicit descendant) */
&.featured { border: 2px solid gold; } /* == .card.featured */
> img { border-radius: 8px; } /* == .card > img */
}
A nested selector is resolved against its parent (& takes the parent’s specificity, exactly like :is(<parent>)); a nested selector with no & is made a descendant of the parent. Nesting works to any depth, under a parent selector list (.a, .b { & .c { } } matches under either), with a bare type selector (.card { img { } }), and inside @media/@layer (the nested rule inherits that context). A conditional group rule — @media, @supports, @container or @layer — can also be written inside a style rule: its declarations apply to the parent’s elements under the condition (as if wrapped in & { }), and it can hold further nested rules and groups.
.card {
color: black;
@media (min-width: 40em) { padding: 2rem; } /* .card, under the condition */
@supports (display: grid) { & .body { display: grid; } }
}
Any other at-rule inside a style rule is skipped.
Pseudo-elements
::before, ::after, ::marker, ::first-letter, ::first-line, ::placeholder, and (css-gcpm-3’s) ::footnote-call/::footnote-marker are supported. All other pseudo-elements are parsed but have no effect — see Recognized but unmatchable selectors for which names are recognized and why that matters for the rest of their selector list.
| Pseudo-element | Notes |
|---|---|
::before |
Full support; use with the content property |
::after |
Full support; use with the content property |
::marker |
Full support for every property the spec allows on markers — see below |
::first-letter |
Full support — see below |
::first-line |
Full support for every property CSS2.1 allows — see below |
::placeholder |
Styles the hint in an empty interactive text field — see below |
::footnote-call |
The in-flow numbered footnote reference; see Footnotes |
::footnote-marker |
The leading number inside a footnote’s own body; see Footnotes |
| All others | Parsed but ignored |
Both the single-colon legacy syntax (:before, :after) and the modern double-colon syntax (::before, ::after) are accepted. ::marker has no legacy single-colon form, matching the spec.
A pseudo-element is generated on an element (Selectors 4 §3.3), so a blanket rule such as * ::before { … } or .wrapper *::after { … } reaches the elements inside the subtree and nothing else — a bare text node is not an element and gets no pseudo-element of its own.
::marker is a real, independently laid-out and painted box (the same as ::before/::after), matching CSS2.1 §12.5.1 / CSS Lists Level 3. It’s generated for any element whose computed display is list-item — not just <li> — so <div style="display: list-item"> gets a real marker and numbering too, exactly like a <li> would.
content—normal(the default; the marker shows the automatic bullet/number/image driven bylist-style-type/list-style-imageon the list item) or an explicit override: a string,counter()/counters(),attr(),url()/gradient (rendered as a real image, same ascontent: url()on::before/::after), ornone(no marker at all). An explicit override fully replaces the automatic bullet/number/shape — unlike the automatic case, no"."suffix is added, so include any trailing punctuation/spacing in the string yourself.color, font properties (font,font-family,font-size,font-style,font-weight,font-variant, to the extent PeachPDF supports them generally) anddirectionall take effect on the marker’s own glyph/shape, independent of the list item’s own styling.- List numbering (the default, non-overridden case, and any
content: counter(list-item)override) is backed by the real CSSlist-itemcounter (see Generated Content below), so both always agree — including with<ol start>/<ol reversed>/<li value>in play. - An
outsidemarker (the default) sits beside its list item’s first line, so it belongs to the fragmentainer that line is in — a list item whose own text carries on onto the next page, or into the next column of a multi-column container, keeps its bullet or number where the item starts and gets no second one where it resumes. - An
outsidemarker sits on the baseline of its item’s first line, not at that item’s top edge, so a::marker { font-size }override makes the marker bigger without leaving its digits hanging below the text they number — and that first line grows to hold the taller marker, the same way a browser’s does (by the marker’s ascent only: it hangs outside the item’s principal box, so its descender does not push the line down). (CSS Lists Level 3 §3.5 leaves both of these expressly undefined; PeachPDF follows what browsers do.) An item whose content is block-level rather than inline keeps the older placement at the item’s own content-box top, which is indistinguishable from the baseline placement unless the marker’s font differs from the item’s. - The marker sits beside the item’s principal block box whether the item’s content is inline or block-level, so
<li><p>…</p></li>is numbered exactly like<li>…</li>, and an item mixing the two (<li>text<p>…</p></li>) is numbered once. One limitation: an item whose content is block-level and which sits inside a multi-column container may show its marker beside a later column’s continuation rather than beside its first line — give such an item inline content if it has to be numbered inside columns. - Properties outside this set (e.g.
background,border,width) have no defined effect on::marker— this isn’t a PeachPDF gap: CSS Lists Level 3 §3.1.1 itself declares marker-box width/height/margin/padding/alignment layout “not fully defined,” and restricts applicable properties to the set above (pluswhite-space/text-combine-upright/unicode-bidi, and animation/transition properties — none of which apply to PeachPDF’s static PDF output anyway). No browser implements box-model properties on::markereither, for the same reason.
/* Custom bullet + color, independent of the list item's own text color */
li::marker { content: "→ "; color: crimson; }
/* Big, bold chapter numbers */
ol.chapters > li::marker { font-size: 1.5em; font-weight: bold; }
::first-letter splits the first letter of an element’s own real text into a separate, independently styled box. Per CSS1 §1.2, any leading punctuation immediately before the first letter (e.g. an opening quote mark) is included as part of the same unit. The target text may be several inline levels deep (e.g. p::first-letter on <p><em>Hello</em> world</p> styles the “H” inside the <em>) — the search stops at (does not cross into) a nested block-level or atomic inline-level descendant (e.g. a nested <div> or inline-block), which starts its own independent first-letter scope. Targets the element’s own real text only, not ::before-generated content.
/* Classic drop cap */
p.intro::first-letter { font-size: 300%; float: left; color: crimson; }
::first-line styles whichever content ends up on a block’s first formatted line — no box is synthesized (unlike ::before/::after/::first-letter), since CSS2.1 restricts it to properties with no layout/box-model effect: color, background (solid background-color only — see note below), text-decoration, and the font-metric/spacing set font-*, word-spacing, letter-spacing, vertical-align, text-transform. Any other property set via ::first-line (e.g. margin, border) has no effect, per spec.
Width-affecting properties (font-size, word-spacing, letter-spacing) are fully supported even when a single inline element’s content straddles the boundary between the first and second line — the words that actually land on line 1 use the first-line styling and the words that overflow to line 2 correctly revert to the element’s own normal styling, rather than one or the other leaking across the boundary.
/* Small-caps drop-in lede, common in print typesetting */
p.lede::first-line { font-weight: bold; color: darkslateblue; font-variant: small-caps; }
Known narrowing: a background-image layer (as opposed to a solid background-color) set via ::first-line is not first-line-aware and paints using the element’s own normal background instead.
Inline elements on the first line, such as a <span>, inherit from ::first-line rather than from the paragraph: one that declares no font of its own has the ::first-line font and sizes the line box from it, and one that declares its own font-size, color or other property keeps that declaration. Only the declarations that match the element itself (stylesheet rules and its style attribute) are re-applied, so a revert keyword and an animation on such an element read the ordinary cascade.
::placeholder styles the hint drawn in an empty interactive PDF text field. color, opacity, and font properties such as font-family, font-size, font-style, and font-weight affect the baked hint appearance; box-model properties have no useful target because the hint is text inside the field’s PDF appearance stream, not an independently laid-out box. PeachPDF’s UA style gives an unstyled hint an opaque grey (#7f7f7f). Author-specified alpha in color or opacity uses the normal PDF transparency path: it works in ordinary PDF output and is rejected by PdfATransparencyGuard for PDF/A-1, just like translucent text anywhere else, rather than being silently discarded.
input::placeholder { color: #64748b; font-style: italic; }
Pseudo-classes
Because PeachPDF renders a static PDF with no live browsing state, most state-based pseudo-classes are parsed but not evaluated. :placeholder-shown is the useful exception: its answer is fixed by the control’s source state when the PDF is generated. Structural pseudo-classes are fully supported, including the CSS “An+B” formula.
| Pseudo-class | Notes |
|---|---|
:link |
Matches <a> elements that have an href attribute |
:any-link |
The union of :link and :visited. Since a static PDF has no browsing history, :visited never matches, so :any-link selects exactly the same elements :link does |
:root |
Matches the document’s root element (the <html> element) |
:scope |
With no scoping root in play, this is the document’s root element — the same element :root matches (Selectors 4 §6.6), so :scope p and :root p behave identically |
:empty |
Matches an element with no children other than white-space-only text. Comments do not count as children, and neither does generated content — an element with a ::before/::after/::marker box is still :empty. A non-breaking space ( ) is content, so it is not. Also evaluated for SVG, in both an inline <svg> and a standalone one |
:placeholder-shown |
Matches a placeholder-capable <input> when the placeholder attribute is present and its generation-time value is empty. It can style the field itself (for example, a lighter border while empty). The result is not live after the PDF is generated: AcroForm has no selector state that can re-run CSS when the user types. |
:lang(C) |
CSS 2.1 §5.11.4. Matches an element whose language — the lang attribute of the nearest element that has one, checking the element itself before any ancestor — is C, or has C as an ASCII-case-insensitive hyphen-delimited prefix (:lang(en) matches lang="en" and lang="en-US", but not lang="english"). Only the HTML lang attribute is consulted; XML’s xml:lang is not. Also evaluated for SVG (both inline and standalone) — an inline <svg> still sees an enclosing HTML ancestor’s lang |
:first-child, :last-child |
Equivalent to :nth-child(1) / :nth-last-child(1) |
:only-child |
Matches an element with no other element siblings |
:first-of-type, :last-of-type |
Equivalent to :nth-of-type(1) / :nth-last-of-type(1) |
:only-of-type |
Matches an element with no other same-tag element siblings |
:nth-child(an+b), :nth-last-child(an+b) |
Full “An+B” support, including odd/even keywords, negative steps (e.g. :nth-child(-n+3)), and whitespace around the offset’s sign (:nth-child(10n + 1) as well as :nth-child(10n+1)) |
:nth-of-type(an+b), :nth-last-of-type(an+b) |
Same as above, counting only same-tag siblings |
:nth-column(an+b), :nth-last-column(an+b) |
Matches a table cell against its column position. Only accounts for colspan within the same row — a cell’s column position does not account for rowspan carried over from earlier rows, since that bookkeeping only exists during layout, not at the point selectors are matched |
:nth-child(an+b of S), :nth-last-child(an+b of S) |
CSS Selectors Level 4 of <selector> extension — the An+B position is computed only among siblings matching S; S may be a comma-separated selector list |
:not(S) |
Matches an element that does not match S. Nesting :not() inside :not() (e.g. :not(:not(.foo))) is rejected — the whole enclosing selector is invalid and the rule matches nothing |
:is(S), :matches(S) |
Matches an element that matches any selector in the (comma-separated, forgiving) list S: an argument that does not parse (an unknown pseudo-class, an empty item) is dropped and the others still apply, and a list with no valid argument matches nothing without invalidating the rule. :matches() is the legacy alias for :is() |
:where(S) |
Like :is(S), but always contributes zero specificity (CSS Selectors 4 §16) — so a rule written with :where(…) is overridden by any normal selector, which is how reset/normalize layers stay low-priority |
:has(S) |
Matches an element with a descendant matching S; S may be a comma-separated, forgiving relative-selector list. Each alternative may carry its own leading combinator: :has(S)/:has(> S)/:has(+ S)/:has(~ S) match a descendant/direct child/next sibling/any following sibling matching S, respectively (e.g. :has(> .a, + .b) mixes forms across alternatives) |
:-webkit-any(S), :-moz-any(S) |
The legacy vendor spellings of :is(S): they match the same way, but their list is not forgiving (one invalid argument makes the whole selector invalid, as in browsers) |
| All others | Parsed but not matched — rules are silently ignored |
Known gap: :nth-column()/:nth-last-column()’s same-row-only limitation described above.
:hover, :active, :focus, :focus-visible and :focus-within match only when a host that shows the document on a screen tells it where the pointer and focus are (HtmlDocument.SetPointerStateAsync, ClearPointerStateAsync and SetFocusAsync in PeachBrackets); a change that only affects painting is applied without a layout pass); in a PDF they select nothing. :checked, :disabled, :enabled, :required, :optional, :read-only and :read-write match the attributes the document has when it is styled, as HTML defines them: :checked a checked check box or radio button and a selected <option>; :disabled a control with disabled, inside a disabled <fieldset> (except in its first <legend>) or, for an option, in a disabled <optgroup>, and :enabled the rest of the controls; :required/:optional the controls required applies to; :read-write a text-like <input> or <textarea> that is not readonly or disabled and anything contenteditable, and :read-only everything else. When a host changes a control after load (SetChecked, Activate, SetValue), HtmlDocument.NeedsRestyle says a rule reads the state that changed and RestyleFormAsync styles the document again with the controls as they are now, so input:checked + label follows the box; the WinUI and Android views do this themselves. The other state-based pseudo-classes besides :link/:any-link and the generation-time :placeholder-shown state (:visited, :valid, :indeterminate, etc.) are parsed but not applied — PeachPDF renders a static PDF with no browsing history or interaction state, so :visited/:active never match by design. Crucially they are recognized, so they do not invalidate a selector list they share with a selector that does match; see Recognized but unmatchable selectors for the full set and for the vendor-extension rule.
Cascade & Specificity
Rule application respects real CSS specificity, not just source order: for a given element, matching rules are applied in true document order, then stably re-sorted by specificity (inline style > ID count > class/attribute/pseudo-class count > type/pseudo-element count) so a higher-specificity rule always wins over a lower-specificity one regardless of which was declared first. Equal-specificity rules still resolve by source order (last one wins), and !important continues to take precedence over normal declarations, applied per-origin (author !important beats author normal; user-agent !important beats everything).
CSS Media Queries
PeachPDF renders to PDF, so only media queries that target the print medium (or the universal all medium) are evaluated. Rules inside @media screen are ignored entirely, which lets web stylesheets that separate screen and print styles work correctly out of the box.
Media feature queries are evaluated against the page box and the renderer’s characteristics — so responsive breakpoints select by the page width instead of all applying at once. Both the legacy min-/max- prefixes (@media (min-width: 768px)) and the range syntax (@media (width >= 48rem)) are supported; inside a media query, em/rem resolve against the initial 16px font size (not the document root’s), per Media Queries 4.
/* applied — print medium matches */
@media print {
body { font-size: 12pt; }
}
/* applied — "all" matches every medium */
@media all {
p { line-height: 1.5; }
}
/* ignored — screen-only rules are skipped */
@media screen {
body { font-size: 16px; }
}
| Media query | Applied in PDF? | Notes |
|---|---|---|
@media print { } |
Yes | Directly targets the print medium |
@media all { } |
Yes | Applies to every medium, including print |
@media only print { } |
Yes | Equivalent to @media print |
@media screen { } |
No | Screen-only rules are skipped |
@media not print { } |
No | Explicitly excluded from the print medium |
@media not screen { } |
Yes | Applies to any non-screen medium |
| Comma-separated list | Partial | Applied if any entry in the list matches print (e.g. @media print, screen applies) |
min-width/max-width/width, and height |
Yes | Evaluated against the page-box dimensions. Range syntax (width >= 48rem) and min-/max- prefixes both supported |
orientation, aspect-ratio |
Yes | Derived from the page-box width and height |
resolution, device-pixel-ratio |
Yes | Evaluated against 96 dpi times the document’s device pixel ratio (HtmlDocumentOptions.DevicePixelRatio, 1 by default, so 1 dppx; a host that shows the page on a 2x display reports 2) |
prefers-color-scheme |
Yes | Reports light by default; set PdfGenerateConfig.PreferredColorScheme = PdfColorScheme.Dark to render dark-mode styles |
color, color-index, monochrome, grid |
Yes | Fixed device characteristics: color output (8 bits/channel), not a color-index/monochrome device, not a grid/tty |
hover, any-hover, pointer, any-pointer |
Yes | Report the pointing devices of the host, which a PDF does not have: none unless HtmlDocumentOptions.Input says otherwise (InputCapabilities.Mouse, .Touch, or a mix of PointerTypes; see PeachBrackets). The boolean form (hover) is true when the device can hover, (pointer) when there is any pointer; a keyword the feature does not define never matches |
update, scripting |
Yes | Both report the static-document resting state (none) — a PDF cannot be updated or scripted |
prefers-reduced-motion |
Yes | Reports reduce (a static PDF has no motion) unless HtmlDocumentOptions.Preferences says otherwise |
prefers-contrast, forced-colors, inverted-colors, prefers-reduced-transparency, prefers-reduced-data |
Yes | Report their resting state (no-preference / none; a PDF is never drawn under a forced palette) unless a host passes the reader’s settings in HtmlDocumentOptions.Preferences (UserPreferences; see PeachBrackets). forced-colors: active also draws the page in UserPreferences.ForcedPalette (see Forced colors) |
| A feature name PeachPDF doesn’t recognize | No | The whole media query is invalid and its block does not apply (per Media Queries 4, an unknown feature is false) |
CSS Viewport Units
PeachPDF renders to a page, not a browser window, so the “viewport” a viewport-relative unit resolves
against is the PDF page box — the same page-box dimensions CSS Media Queries’ own
width/height features already use.
.hero {
width: 100vw; /* the full page width */
height: 50vh; /* half the page height */
font-size: 5vmin; /* 5% of whichever of width/height is smaller */
}
| Unit | Resolves to |
|---|---|
vw, vi |
1% of the page box’s width |
vh, vb |
1% of the page box’s height |
vmin |
1% of the smaller of the page box’s width and height |
vmax |
1% of the larger of the page box’s width and height |
vi/vb (the logical inline/block axis forms) follow the root element’s own writing-mode (CSS
Values and Units 4 §6.2), the same way cqi/cqb follow a query container’s
own writing-mode: under horizontal-tb (the default) vi/vb are identical to vw/vh, but under a
vertical-rl/vertical-lr root element vi tracks the page box’s height and vb tracks its
width — the inline/block axis actually rotates with the root’s own writing mode, matching the real
vertical-writing-mode layout supported elsewhere in the engine.
The small (sv*), large (lv*), and dynamic (dv*) viewport variants (svw, svh, svi, svb,
svmin, svmax, and the equivalent lv*/dv* forms) are all fully supported, and all resolve
identically to their plain counterpart above. Browsers distinguish these because a mobile browser’s UI
chrome (address bar, toolbars) can show or hide, changing the usable viewport size — a PDF page has no
such chrome, no scrollbar, and no way to resize once layout starts, so there is only one viewport size to
report.
The font-measured units (ch, ex, …) are described under Font-relative units.
CSS Container Queries
Supported (CSS Containment 3). An element opts in to being a query container with the container-type/container-name properties (or the container shorthand); @container rules then apply based on that container’s own resolved size or style rather than the page. Both forms — size queries and style() queries — evaluate against the nearest eligible ancestor query container, walking up from the matched element; an unnamed query uses the nearest eligible ancestor regardless of its name, and a named query (@container sidebar (...)) skips straight to the nearest ancestor declaring that name in its own container-name, even if a nearer ancestor is eligible but unnamed.
.card-list {
container-type: inline-size;
container-name: cards;
}
/* applies once .card-list's own content-box width reaches 400px */
@container cards (min-width: 400px) {
.card { display: flex; }
}
/* style queries read a container's own resolved value, most commonly a custom property */
@container style(--theme: dark) {
.card { background: black; color: white; }
}
| Property/at-rule | Notes |
|---|---|
container-type |
normal (initial — no size containment), size (tracks both the inline and block axis), inline-size (tracks only the inline axis) |
container-name |
none (initial) or a space-separated list of <custom-ident>s an @container rule can name to target this container specifically |
container shorthand |
container: <name> / <type> — expands to the two longhands above; the / <type> half is optional and resets container-type to normal when omitted |
@container <condition> { } |
Unnamed size query — applies against the nearest ancestor with container-type: size or inline-size |
@container <name> <condition> { } |
Named size query — applies against the nearest ancestor whose own container-name includes <name> and whose container-type is size/inline-size |
width/min-width/max-width (and the min-/max-/range-syntax forms) |
Evaluated against the container’s own resolved physical width, regardless of its writing-mode |
inline-size (and the min-/max-/range-syntax forms) |
Evaluated against the container’s own resolved inline-axis size (CSS Writing Modes 4 §7.1) — identical to width under the container’s default horizontal-tb, but its physical height under vertical-rl/vertical-lr |
height/min-height/max-height, aspect-ratio, orientation |
Evaluated against the container’s own resolved physical height, regardless of its writing-mode — only meaningful against a container-type: size container; against an inline-size-only container these never match (it tracks only its own inline axis), regardless of the container’s actual physical height |
block-size (and the min-/max-/range-syntax forms) |
Evaluated against the container’s own resolved block-axis size — identical to height under horizontal-tb, but its physical width under vertical-rl/vertical-lr. Same container-type: size-only restriction as height above |
style(<property>: <value>) |
Matches when the nearest eligible ancestor query container’s own resolved value for <property> equals <value>. Unlike size queries, any query container is eligible regardless of container-type — container-type: normal (the initial value, so effectively any element) still qualifies, since a style query needs no layout containment. and/or/not combine multiple style() feature checks; a combined operand needs its own parenthesized declaration (style((--a: 1) and (--b: 2))), matching @supports’s own grammar. A style() query can also be combined with size features in one condition using and/or/not and parentheses (@container (min-width: 300px) and style(--theme: dark)); a leaf with no eligible container is unknown, which never matches, even under not. Comparison is a trimmed, literal-text match against the container’s resolved value — reliable for custom properties (values are opaque, author-controlled text) and for a standard property whose authored value already matches PeachPDF’s own canonical serialization (e.g. keyword values like style(display: block)), but not full computed-value equivalence (style(color: red) won’t match a container that resolves to rgb(255, 0, 0)) |
cqw |
1% of the nearest ancestor query container’s own resolved physical width, regardless of its writing-mode |
cqh |
1% of the nearest ancestor query container’s own resolved physical height (only meaningful against a size container — 0 against an inline-size-only one, which doesn’t track that axis), regardless of its writing-mode. When the container’s own height is a percentage whose own base isn’t itself resolved yet at the point this is evaluated, this also falls back to 0 rather than the real value — a definite (explicit-length) container height is unaffected |
cqi |
1% of the nearest ancestor query container’s own resolved inline-axis size (CSS Writing Modes 4 §7.1) — identical to cqw under the container’s default horizontal-tb, but its physical height under vertical-rl/vertical-lr (the same vi/vb-style rotation described under CSS Viewport Units). Shares cqh’s percentage-height caveat above when the container is vertical (its inline axis is then the physical height) |
cqb |
1% of the nearest ancestor query container’s own resolved block-axis size (only meaningful against a size container) — identical to cqh under horizontal-tb, but its physical width under vertical-rl/vertical-lr. Shares cqh’s percentage-height caveat above when the container is horizontal (its block axis is then the physical height) |
cqmin, cqmax |
The smaller/larger of cqi and cqb |
| Container-relative unit with no eligible ancestor container | Falls back to the corresponding small-viewport unit (cqw→svw, cqh→svh, cqi→svi, cqb→svb, cqmin→svmin, cqmax→svmax) — see CSS Viewport Units |
CSS Paged Media
PeachPDF supports the CSS Paged Media specification, which controls page dimensions, margins, and running headers/footers.
@page rule
The @page at-rule targets PDF pages. A rule without a selector applies to all pages; pseudo-selector rules override it for specific pages.
@page {
size: A4 portrait;
margin: 25mm 20mm;
}
@page :first {
margin-top: 40mm; /* extra space on the cover page */
}
| Feature | Support | Notes |
|---|---|---|
@page { } — base rule |
Full | Applies to all pages |
@page :first { } |
Full | Applies only to page 1 |
@page :left { } |
Full | Applies to even-numbered pages |
@page :blank { } |
Full | Applies to a page inserted by a directional break (left/right/recto/verso); ranks with :first, above :left/:right |
@page :right { } |
Full | Applies to odd-numbered pages |
@page name { } — named page |
Full | Activated by page: name on elements; see Named pages |
bleed, marks |
Full | Bleed area and crop/cross marks outside the page box; see Bleed and printer’s marks |
Cascade order: the base rule is the fallback; a matching named-page rule overrides it; pseudo-selector rules override named-page rules. When both :first and :right match page 1, the last matching rule in the stylesheet wins.
Per-page margin variation: when a pseudo-selector or named-page rule sets margin-top, margin-left, etc., those values override the base margins for that page. All four margins are layout-affecting, per the CSS Paged Media page-box model. Top and bottom overrides give each page its own content-band height, so content paginates against those variable bands. Left and right overrides give each page its own content-box width: top-level (main-column) block content re-wraps to that page’s own measure, because the edges of the page area act as a containing block for the layout that occurs between page breaks. So @page :first { margin-left: 0 } gives a first page whose text genuinely flows into the wider measure, and mirrored @page :left / @page :right margins (for binding gutters) re-wrap each page’s text to its own width. Zero is a valid override — @page :first { margin: 0 } gives a first page whose content band is the entire physical sheet, enabling a true four-edge full-bleed cover (size the cover element to the full sheet, e.g. width: 8.5in; height: 11in). An element ending exactly on a page boundary with a forced page break after it continues on the very next page — no blank page is manufactured for an exact-fit cover (css-break-3’s forced-break-at-boundary rule). A directional break (left/right/recto/verso) is the one case that does manufacture a blank page, and only when the following content would otherwise land on the wrong side; see Directional page breaks.
Known boundaries of per-page margins:
- A block spanning a page boundary re-wraps its text mid-way, and its own outer border box now matches. A single paragraph (or other box holding direct text/inline content) that begins on one page and continues onto a differently-margined (or differently-sized) page has its continuation lines re-wrapped to that later page’s own measure, per css-break-3 §5.1 (“a fragment … is sized … using the size of its own fragmentainer”) —
text-align: left/right/center/justifyall flush against each line’s own (per-page) measure too. The block’s own outer border box (its background/border rectangle, and any non-text content sized against its own width) matches this on each page too, catching up to content that already reflowed. - Any plain, unconstrained block reflows — not just the main column. Per-page width applies to an ordinary in-flow block-level box whose entire containing-block chain, up to the page area, is auto-width with no
max-widthclamp — the document’s main column (<html>/<body>), or any plain wrapper<div>(or similar) nested arbitrarily deep below it. A percentagewidthresolves against its own containing block’s page-aware measure the same way, tracking whichever page it lands on rather than a single value computed once. An explicit-lengthwidthstill doesn’t vary by page (correctly — an author’s fixed measurement shouldn’t), and amax-widthon a box disqualifies both that box and everything nested inside it, since a capped box can no longer be assumed to genuinely span the page area. A table cell, a flex/grid item, a float, or an absolutely/fixed-positioned box each shares its containing block’s width with siblings (or is sized against its own placement) rather than claiming all of it, so none of these reflow, though an absolutely/fixed-positioned box’s own percentage width still resolves against its (now page-aware) containing block. A multi-column container’s own width now reflows too — each page it spans lays out its own columns at that page’s own width. A table’s own width now resolves against whichever page the table itself starts on too, though (unlike multicol) it does not vary further as the table continues across later pages — a table’s columns are one shared grid spanning every row, so varying the resolved width per continuation would misalign a row’s cells against a sibling row’s on a different page of the same table. A block-level flex container reflows too, and so do its items — see the next bullet. Named-page (page: <name>) left/right overrides reflow the same way an ordinary page margin override does. - The initial containing block’s width, like its height, resolves against the first page’s own area. A percentage width (or an absolutely-positioned box’s
left/right-filled auto width) that bottoms out at the initial containing block itself resolves against the FIRST page’s own content band, per CSS Paged Media 3 §3 — reflecting a@page :firstmargin orsizeoverride, never a later page’s. - The
orphans/widowsline minimums are applied after the measures settle, and never across a change of measure. Deciding which page a box is on takes several layouts here, because a box’s width depends on the page it landed on last time. A §5.4 correction moves content between pages, so taking one against an assignment that is still settling feeds back into that very question. Both minimums are therefore applied in a final layout, once the assignment has stopped changing. That final layout cannot re-wrap what it moves, so a correction that would move a box onto a page of a different measure is declined rather than leaving the box wrapped for the page it left — the line minimum is given up instead. Where the pages involved share a measure, which is the ordinary case, both minimums apply in full. - A flex container that continues across pages of different measures follows each page. The container’s own frame is its page’s measure on every page it spans (an explicit
width, andinline-flex, which shrink-wraps, keep one size). Its items follow it: a wrapping row collects, sizes and places each line against the measure of the page the line lands on — so a line that holds three items on a wide first page holds two on a narrower one — and a line that continues onto a page of another measure has its items re-fitted to that page, their frames on the earlier pages kept, the blocks inside them following, and the line’s height (and everything after the container) re-derived from where the content actually ends. Items with nothing to break at — a box of a fixed height, an image — are drawn once and cut by the page edge, and are sized on each later page the same way. A column container’s items keep the cross size they started with: the container’s frame follows the page, but a column’s items are stretched to the container’s width where their line started. Inside a multi-column container a flex container fills its column, and follows the column’s width from page to page the same way. A container centred withmargin: autokeeps its position while the page’s width changes, as an ordinary block does. - An ordinary percentage-height box already resolves correctly — against its own containing block’s already-computed height, the same ancestor a per-page-width main-column block already resolves against — with no special-casing needed. Only the true initial containing block (a box whose own containing block is the root itself — the top of the chain any percentage-height resolution eventually reaches, if nothing definite is found sooner) is a distinct case: it resolves against the first page’s own content band, per CSS Paged Media 3 §3 (“the edges of the page area on the first page establish the rectangle that is the initial containing block of the document”) — reflecting a
@page :firstmargin override, but never a later page’s, even a differently-margined one reached further into the document. Size a full-bleed element that must span every page with absolute units, notheight: 100%, since only page 1’s own band feeds the ICB. - Viewport units (
vw/vh/vi/vb/vmin/vmax, and theirsv*/lv*/dv*variants) resolve against the document’s base/configured page area, not any per-page override — CSS Values and Units 4 §6.2 defines viewport units against one viewport for the whole rendering, with no per-page concept to vary against. - Named-page bands begin at the forced break. An element whose used
pagename differs from the active one always forces a page break, so a named page’s own margins take effect exactly from that fresh page onward (through the element’s own content and descendants), and revert to the enclosing page’s band once content leaves that subtree — see Named pages. position: fixedelements andbackground-attachment: fixedlayers keep positioning against the base page box on margin-only-overridden pages (they ride the page’s content shift rather than re-resolving against that page’s own margins). Aposition: fixedbox’s own percentageleft/top/heightresolves against each page’s own area (CSS2.1 §10.1 / CSS Position 3: “in paged media, the page area of each page”) whenever the document mixes physical page sizes (a named/pseudo@pagerule’s ownsize); its percentagewidthnow resolves against each page’s own content-right edge unconditionally — a margin-only override re-wraps it exactly as a size override does — since the initial containing block a fixed box’s width is defined against (CSS2.1 §10.1) is itself page-aware (see above). An absolute-length offset or dimension is unaffected either way, since it doesn’t depend on the basis at all. A resized fixed box’s own frame (its background/border/clip, and any replaced content such as an<img>/inline<svg>, which has no wrapping algorithm to invalidate) is genuinely correct on every page; its content is not re-flowed to the new size — CSS Position 3 §3 is explicit that user agents must not paginate the content of fixed-positioned boxes, so a non-replaced fixed box’s text keeps the wrapping its single, global layout pass produced, which may not fill (or may overflow) its resized frame on a page whose measure differs.- When content-empty pages are skipped (see pagination), a kept page’s own geometry (margins/sheet size) corrects itself to match its renumbered output position — even when a
:first/:left/:rightrule’s margins orsizegenuinely change the page’s own content-box width or height, provided the change is confined to a single dimension (width alone, or height alone): the affected page is silently laid out again, once, against the corrected dimension. Two narrower cases still resolve geometry against the underlying page sequence rather than the renumbered output, when a content-empty gap precedes them: a rule that changes both the content-box’s width and height at once (an asymmetric margin override combined with asizeoverride), and any page under an active named page (page: <name>).
Units in @page margins: base and per-page rules resolve margins through the same conversion, so a textually identical margin always produces identical page geometry whether it sits in the base rule or a selector-carrying rule. All absolute units (including spec-correct px at 0.75pt — see Length units), em/ex/ch (against the @page context’s own font-size when a base or matching @page rule sets one, per css-page-3 §7.1, else the root element’s font — ex/ch take their 0.5em fallback here, since a page context has no font to measure; see Font-relative units), rem (always the root element’s font), % (against the page width for the left/right margins and the page height for the top/bottom margins, per css-page-3 §6), and calc() expressions over those units are supported in both base and per-page rules. Viewport units (vw/vh/vmin/vmax, and their logical/small/large/dynamic variants) are not supported in @page margins: a page rule defining its own geometry in terms of the viewport is self-referential, so such a declaration is invalid and is dropped (CSS Syntax error handling), leaving that side at its previously-cascaded value — the base margin for a per-page rule, or the configured (PdfGenerateConfig) / UA-default margin for a base rule. Base and per-page rules are fully symmetric here.
Page-box background: the background/background-color/background-image/background-position/background-size/background-repeat/background-attachment properties are supported directly on @page (and per pseudo-selector/named-page rule, cascaded the same per-declaration way margin/size are):
@page {
background-color: #5588ff;
}
@page :first {
background-image: linear-gradient(to bottom, #222, #555);
background-size: cover;
}
Per css-page-3 §3.1, a page’s layers paint bottom-to-top in a fixed order: the page box’s own background, then the document canvas (the <html>/<body> fill described above), then the page box’s own border, then the document’s content, then the page-margin boxes. In practice this means the page background is the one thing an opaque canvas fill (or opaque page content) can completely cover — set a page background to give one page (@page :first) or a named page (@page chapter { ... }) its own tint or cover image, and leave <html>/<body> without a background of their own so it actually shows through. background-origin/background-clip pick between the page box’s own border-box, padding-box (the default), and content-box — see Page-box border and padding below — the same three areas an ordinary element’s or a page-margin box’s background already distinguishes; a page background never extends into the page’s own margin area (background never extends into any box’s margin), so border-box is the full sheet only when margin is 0. background-attachment: fixed is unaffected by any of this — its own positioning area is always the full physical sheet, matching CSS Paged Media’s page-anchored convention for fixed backgrounds.
Page-box border and padding
Per css-page-3 §3, the page box has the same box model as any other: margin, then optionally border and padding, around the page area (the content area actual content flows into). border-* and padding-* are supported directly on @page (and per pseudo-selector/named-page rule, cascaded per-declaration the same way the page-box background above is):
@page {
margin: 20mm;
border: 2pt solid #333;
padding: 10mm;
}
This is layout-affecting: the resolved border width plus padding genuinely narrows the content area on every side, exactly as it does for an ordinary element — a document’s main content, multi-column layout, footnote areas, and everything else that paginates against the page’s content band all reflow into the smaller area. A page-margin box’s own position is unaffected — its containing block already spans the full margin-to-margin extent regardless of how that extent subdivides into border/padding/content (css-page-3 §5.3.1’s “available width”/height already includes the page box’s own border/padding).
border-*-width never accepts a percentage, matching ordinary elements. padding-* percentages resolve against the page box’s own overall sheet dimensions — horizontal against the width, vertical against the height — the most direct reading of css-page-3 §6’s wording in the absence of page-box-specific guidance.
A per-page (:first/:left/:right/named-page) border/padding override behaves exactly like a per-page margin override (see Known boundaries of per-page margins above): it narrows only the pages that rule applies to, and — like an over-large margin override — an override that would leave no room at all for content on a page falls back to the base margin with no border/padding on that page, rather than stalling pagination.
Bleed and printer’s marks (bleed, marks)
For output that goes to a commercial printer, @page accepts bleed and marks (css-page-3 §12-13). They are per-page like size and margin, so :first and named-page rules can set their own.
@page {
size: 90mm 55mm;
bleed: 3mm;
marks: crop cross;
}
bleed: <length> | auto: how far the artwork extends past the page box (the trim edge) on every side.auto(the initial value) is6ptwhenmarksincludescropand0otherwise. A negative or invalid length is treated asauto. The canvas background (the root element’s or body’sbackground, which fills the page) runs out to the bleed edge; the page box’s own@pagebackground and border, and the content, stay inside the page box.marks: none | [ crop || cross ]:cropdraws the pair of hairline strokes at each corner that continue the trim edges, andcrossdraws a registration target (a circle with a cross through it) at the middle of each edge. The marks are black, outside the bleed area. Anything else isnone.- The sheet grows around the page. The PDF page is the page box plus the bleed plus a 15pt strip for the marks (when
marksis notnone) on every side, so page layout, margins and pagination are exactly what they are without these descriptors. The PDF page’s/TrimBoxis the page box and its/BleedBoxis the page box plus the bleed, which is what prepress tools read to cut and to check the bleed; its/MediaBoxis the whole sheet. Links, form fields and bookmarks keep the page-box coordinates. - A document painted through
HtmlDocumentreports the extra space asDocumentPage.BleedPointsandMarksPoints; the canvas keeps its origin at the page box’s top-left corner, so the bleed and the marks are at negative coordinates.
size property
@page { size: A4 landscape; }
@page { size: 200mm 150mm; }
| Syntax | Example | Notes |
|---|---|---|
| Named keyword | A4, A5, A3, B4, B5, letter, legal, ledger, tabloid |
Sets width and height from the standard paper size |
| Orientation only | portrait, landscape |
Rotates the configured page size |
| Keyword + orientation | A4 landscape |
Named size with explicit orientation |
| Explicit lengths | 210mm 297mm, 595pt 842pt, 40em 60em |
Any two CSS <length> values (css-page-3 §7.1): absolute units (pt, px, in, cm, mm, pc) plus the font-relative em/ex (against the @page context’s own font-size when set, else the root element’s font — the same basis @page margins use) and rem (always the root font). Percentages are not a <length> for size, and viewport/ch units have no page-sheet basis — either leaves the configured page size in place |
When @page { size: ... } is present on the base rule it overrides the PageSize or ManualPageWidth/ManualPageHeight configured via PdfGenerateConfig, for the whole document. size is also honored on a pseudo-selector (:first/:left/:right) or named-page rule (@page <name> { size: ... } combined with page: <name> on an element, see Named pages) — the page(s) that rule matches get their own genuinely independent physical PDF page (their own /MediaBox), letting one document mix page sizes and orientations, e.g. a landscape page for a wide table inside an otherwise-portrait report. Vertical pagination adapts to a differently-sized page’s own content band, and main-column text re-wraps to that page’s own width the same way it does under a per-page margin override — see Known boundaries of per-page margins above, including what still does not (grid containers).
Margin boxes
Margin boxes are sub-rules of @page that place text inside the page margins (outside the content area). There are 16 standard boxes arranged around the four margins:
@page {
margin: 25mm 20mm;
@top-left { content: "Company Name"; font-size: 8pt; }
@top-center { content: "Document Title"; font-size: 8pt; font-weight: bold; }
@top-right { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; }
@bottom-left { content: "© 2025 Acme Corp"; font-size: 7pt; color: #888; }
}
Available boxes:
| Row / Column | Left | Center | Right |
|---|---|---|---|
| Top corners | @top-left-corner |
— | @top-right-corner |
| Top margin | @top-left |
@top-center |
@top-right |
| Bottom margin | @bottom-left |
@bottom-center |
@bottom-right |
| Bottom corners | @bottom-left-corner |
— | @bottom-right-corner |
| Left margin | @left-top |
@left-middle |
@left-bottom |
| Right margin | @right-top |
@right-middle |
@right-bottom |
Supported content values:
| Value | Example | Notes |
|---|---|---|
| String literal | content: "Header text" |
Rendered as-is |
counter(page) |
content: counter(page) |
Current 1-based page number |
counter(pages) |
content: counter(pages) |
Total page count |
string(name) |
content: string(chapter) |
Named string captured via string-set; see below |
element(name) |
content: element(chapter-title) |
The current position: running(chapter-title) element, laid out for real and shown complete with its own formatting and descendant elements — not just captured text (contrast with string() above). See Running elements. Not combinable with text/counter/string/image content in the same declaration |
| Mixed | content: "Page " counter(page) " of " counter(pages) |
Concatenated |
url(...) |
content: url("logo.svg") |
An image (raster or SVG, incl. data: URIs) — useful for a logo in a running header. Rendered at natural size, aligned within the box by the box’s text-align/vertical-align (same as text content: the default follows the box’s position — e.g. @top-right end-aligned, @top-center centered — and an explicit text-align/vertical-align applies), clipped to the box. Not combinable with text/counter/string content in the same declaration |
| Gradient function | content: linear-gradient(to right, red, blue) |
linear-gradient()/radial-gradient()/conic-gradient() (and their repeating- forms), filling the box |
none |
content: none |
Suppresses the box (useful in :first to hide the header on the cover page) |
Supported style properties in margin boxes:
| Property | Notes |
|---|---|
color |
Text color; hex and rgb() supported |
font-family |
Font name; falls back to Arial |
font-size |
Any CSS length; e.g. 8pt, 10px |
font-weight |
bold or normal |
font-style |
italic or normal |
text-align |
left, center, right; default is inferred from box position |
vertical-align |
top, middle, bottom; default is middle |
width / min-width / max-width |
Controls the width of top/bottom margin boxes; boxes with explicit widths are honoured; remaining space is distributed equally among auto boxes. Relative units resolve per css-page-3 §8: % against the margin area the box sits in (the content-box width shared by a top/bottom row), em/ex/ch against the box’s own computed font size (ex/ch take their 0.5em fallback, see Font-relative units), rem against the root. Viewport units (vw/vh/vmin/vmax, and their logical/small/large/dynamic variants) have no page context and size the box as auto |
height / min-height / max-height |
Controls the height of left/right margin boxes; relative units resolve as for width, with % against the content-box height shared by a left/right column |
margin / padding / border (all four edges, and the longhands) |
Inset the box’s content from its slot in the margin area, per css-page-3 §5.1 — a page margin box is a block-level box that accepts the whole box model, border included. A declared width/height is the content-box dimension, with margin, border and padding all additive on top of it, so width: 200pt; border: 1pt solid; padding: 0 5pt occupies 212pt of the row and draws its content 200pt wide. Percentages resolve against the box’s own containing block per axis, following css-page-3 §6, which overrides CSS 2.1’s uniform width rule here: left/right against that block’s width, top/bottom against its height. The containing block (§5.3.1) is the content band’s width by the page margin’s thickness for a top/bottom row, the margin’s thickness by the content band’s height for a left/right column, and the rectangle where two margins meet for a corner — so on a 40pt top margin, padding-top: 5% is 2pt, not 5% of the page width. A negative margin is honoured and grows the box past its band, which is how a footer can keep a fixed inset from the page edge on a page margin shallower than that inset; padding and border may not be negative and are clamped to zero. A box padded/bordered past its own extent is skipped rather than drawn inside out. Border paints like an ordinary box’s, mitred at the corners; border-radius is not supported on a margin box |
background / background-color / background-image / background-position / background-size / background-repeat / background-clip / background-origin |
Same support as an ordinary element’s background (including multi-layer background-image, gradients, and background-attachment: fixed positioning against the page box), painted before the box’s own content — so a margin box with no content at all can still be a purely decorative band. Unlike @page’s own background (see @page rule above), background-origin/background-clip genuinely distinguish the box’s border-box, padding-box and content-box rects here, since a margin box’s own border/padding are real |
Named pages
The CSS page property on an element activates a named @page rule starting on the page containing that element. Its used value is tree-based (css-page-3 §3): an element with no page of its own (page: auto) uses its parent’s used value, so the named page carries through the element’s own content and descendants — continuing correctly across a chapter’s own multi-page span — but content that leaves that subtree (a following sibling, or anything back out at an ancestor’s level) reverts to the enclosing page (the default page, or an outer named page). A named page therefore does not leak its margins or margin-box overrides onto later, unrelated content. This lets different parts of a document use different page styles (e.g., wider margins for an appendix, or a different running header per chapter). Per css-page-3, an element whose used page name differs from the one currently in effect — including a reversion back to the default — forces a page break before it, so a named page’s styles (including layout-affecting top/bottom margins) always begin on a fresh page, and the following default content resumes on its own fresh page.
@page chapter {
@top-right { content: "Chapter Section"; font-size: 8pt; }
}
/* Elements with page: chapter activate @page chapter */
div.chapter { page: chapter; }
<div class="chapter">
<h1>Chapter 1</h1>
<p>Content...</p>
</div>
| Value | Behavior |
|---|---|
page: auto (default) |
Uses the base @page { } rule |
page: <ident> |
Activates @page <ident> { } starting on the page containing this element |
If multiple elements with different page values appear on the same page, the last one in document order wins.
An @page rule’s selector may also list several comma-separated names, sharing one rule across all of them:
@page chapter1, chapter2, chapter3 {
@top-center { content: "Running Header"; }
}
Page names are case-sensitive (page: Chapter will not activate @page chapter { }).
Limitation: named-page activation and reversion are honored for normal block-flow content. A page change or reversion among the children of a flex container or table is not — those layout modes position their own children independently of the page-break machinery, so a named page applied deep inside them may not begin (or revert) on a fresh page. Apply page to a block-level element in the normal flow for reliable results.
Named strings (string-set / string())
Named strings capture element content as the document is laid out and replay it in margin boxes. This is the standard mechanism for running headers that show the current chapter or section title.
/* Capture the heading text whenever an h1 or h2 is encountered */
h1 { string-set: chapter content(); }
h2 { string-set: section content(); }
@page {
@top-left { content: string(chapter); font-size: 8pt; font-style: italic; }
@top-right { content: string(section); font-size: 8pt; }
}
string() keyword variants:
| Keyword | Behavior |
|---|---|
string(name) / string(name, first) |
First assignment of name that appears on this page; if none, the last assignment from a previous page |
string(name, last) |
Last assignment of name that appears on this page; if none, the last from a previous page |
string(name, start) |
Last assignment of name that started before this page (running header — the value in effect at the top of the page) |
string(name, first-except) |
Empty on the page where name is first assigned; otherwise same as first |
Running elements (position: running() / element())
Running elements are css-gcpm-3’s richer alternative to named strings: instead of capturing an element’s plain text, position: running(<custom-ident>) removes the whole element from normal flow and makes it available to a page margin box via content: element(<custom-ident>), which lays it out for real — complete with its own formatting (fonts, colors, borders) and any descendant elements (a <span>, an inline image), not just its text content.
h1.chapter { position: running(chapter-title); }
@page {
@top-center { content: element(chapter-title); }
}
<h1 class="chapter">Chapter One <span style="color: #c00;">— Draft</span></h1>
The <h1> no longer appears at its original position in the document; instead, its full rendered form — including the red “— Draft” span — appears in the @top-center margin box of every page until a later position: running(chapter-title) element replaces it.
element() keyword variants (identical selection rules to string() above, applied to the whole element rather than a captured string):
| Keyword | Behavior |
|---|---|
element(name) / element(name, first) |
First running(name) element that appears on this page; if none, the last one from a previous page |
element(name, last) |
Last running(name) element that appears on this page; if none, the last from a previous page |
element(name, start) |
Last running(name) element that started before this page (running header — the element in effect at the top of the page) |
element(name, first-except) |
Empty on the page where name is first assigned; otherwise same as first |
A box inside a running element that declares overflow: hidden (or clip) clips its own content to its padding edge, the same as in the page; the running element itself is clipped by nothing outside it. The same holds inside a footnote body, where text-overflow: ellipsis also applies.
Limitations:
position: running()is honored for elements in normal block flow and for flex/grid/multi-column item children. It is not currently honored on a table row or cell.- Content painted via
content: element()does not currently respect its own internal stacking order (z-index) among its descendants — it paints in document order. This only matters for a running element that itself contains absolutely-positioned,z-index-stacked content, not for ordinary text/image headers and footers.
Footnotes (float: footnote)
css-gcpm-3 footnotes: give an inline-level element float: footnote and PeachPDF pulls it out of normal flow, leaves a numbered in-flow reference in its place, and renders the element’s own content in a note area at the bottom of the page the reference landed on. Unlike a fixed-height @page margin box, the note area’s height is reserved dynamically, based on how many footnotes actually land on a given page.
<p>PeachPDF renders footnotes<sup style="float: footnote">
This note's content moves to the bottom of this page automatically.
</sup> without any manual positioning.</p>
- Source element — the element carrying
float: footnote, of anydisplaythat generates a box: the common case is a<sup>or<span>reference inside running text, but a block-level element works too. It is replaced by the numbered call, which is inline, so among block-level siblings the call gets an anonymous block box of its own (or joins the run of inline content beside it). A source that is absolutely positioned,position: running(),display: noneor a table-internal display (a row, a cell…) is not a footnote and renders as ordinary content. -
Numbering — footnotes are numbered automatically, in document order, through a real
footnotecounter you can control from@pageand@footnote. By default each page restarts at 1, because the user-agent stylesheet declares@page { counter-reset: footnote }and@footnote { counter-increment: footnote }; sincecounter-resetis a single property, declaring it yourself on an applicable@pagereplaces that default outright:Declared on an applicable @pageEffect nothing Each page restarts at 1 (the default) counter-reset: noneNothing resets the counter — numbering runs continuously through the document counter-reset: chapter 1(any set that omitsfootnote)Also continuous, for the same reason counter-reset: footnote 10Each page restarts at 10, so its first note is 11 @footnote { counter-increment: footnote <n> }sets the step (default 1);counter-increment: none, or a set that omitsfootnote, steps by zero so every note on the page shares one number. A reset declared on a page carrying no footnotes of its own still takes effect there. - The call (
::footnote-call) — the numbered in-flow reference left at the footnote’s original position. By default it renders as a small superscript number; style it like any other pseudo-element:::footnote-call { color: #2563eb; } - The marker (
::footnote-marker) — the leading number PeachPDF prepends to the footnote’s own body once it’s shown in the note area (e.g. “1.”). Style it the same way:::footnote-marker { font-weight: bold; }Both pseudo-elements support the same style properties
::markerdoes (color, font properties,direction) — see::markerabove. An explicitcontentoverride on either replaces the automatic number: a literal string,attr()or an image all work, andcounter(footnote)/counters(footnote, <sep>)resolve to the live number — including a counter style, e.g.content: counter(footnote, lower-roman)fori,ii,iii. - The note area — a thin divider rule above the stacked footnote bodies, in document order.
- The
@footnotearea rule — style the note area itself with an@footnoteblock nested inside@page, the same way you’d declare@top-centeror another page margin box:@page { @footnote { border-top: 2pt solid #2563eb; margin-top: 8pt; padding-top: 6pt; } }border-topstyles the divider rule (only a solid divider is painted — a declaredborder-top-styleother thannone/hiddenstill paints as a solid line, andnone/hiddenremoves the divider entirely);margin-topis the space above the divider;padding-topis the gap between the divider and the first footnote body. Any longhand left undeclared falls back to PeachPDF’s own default (a 1pt solid black divider, 4pt above it, 4pt below it) exactly as an ordinary CSS cascade fills in an unset property — declaring onlyborder-topstill gets the default margin/padding.heightsets a fixed height for the band the footnote bodies stack in — the area’s content box, so the divider and its surrounding space are added on top of it. A stack shorter than the declared height still reserves the whole band, with the slack below the last body (exactly as a fixed-height block box top-aligns its content); a taller one overflows it.height: auto(the default) sizes the area to its content.max-heightis also accepted, and refers to the same content box asheight; on its own (under the defaultfootnote-policy: auto) it does not shrink or clip the note area — seefootnote-policybelow for what makes it and an overflowedheighttake effect. Percentages onheight/max-heightresolve against the page’s own content band, since they are block-axis lengths; percentages onmargin-top/padding-topresolve against the content width, as the CSS box model specifies.counter-incrementcontrols how much the footnote counter advances per note — see Numbering above.floatandcolumn-span(both present in the spec’s own default UA stylesheet for@footnote) are parsed and ignored: PeachPDF’s note area is always at the bottom of the page and always spans the full content width, so declaringfloat: noneorcolumn-span: noneneither moves nor narrows it. footnote-display: block | inline | compact— how a footnote’s body stacks in the note area, set on thefloat: footnotesource element itself (the same elementfloat/footnote-policyare declared on):sup { footnote-display: compact; }block(initial) — every footnote body starts its own row, stacked in document order (PeachPDF’s original, only behavior).inline— a body runs alongside the previous one on the same row, wrapping to a new row only when it no longer fits the page’s content width. A body whose own content already needs more than one line at the full content width still takes a full-width row of its own (there is no narrower re-wrap of multi-line content).compact— PeachPDF places a body inline when it is short enough to fit on a single line at the full content width, and as its own block row otherwise. This is the concrete rule behind the spec’s own UA-discretion wording (“the user agent determines whether a given footnote element is placed as a block element or an inline element”).
footnote-policy: auto | line | block— what happens when a page’s note area can’t hold everything that landed on it, set the same way asfootnote-display:sup { footnote-policy: block; }auto(initial) — no forced break; a note area that doesn’t fit overflows, same as today.block— forces a page break before the paragraph (containing block) carrying the footnote’s call, so both the call and its note move to the next page together.line— forces a page break at the start of the specific line carrying the call, moving only that line and what follows in the same paragraph — earlier lines of the paragraph stay on the current page. Widows/orphans are still honored, so the actual break may land on an earlier line than the call’s own.
Both values only react to the note area’s fit on the current page — its own
@footnotemax-height, content overflowing a fixed@footnoteheight, or its natural height alone already exceeding the whole page. They don’t first check whether the destination page will actually have room either. If it doesn’t, PeachPDF forces another break from there and keeps retrying, bounded by the same small pass limit the note area’s own height-reservation convergence already uses; an unsatisfiable case (e.g. amax-heighttoo small for any page) settles wherever that limit is reached rather than searching indefinitely.- Column-scoped areas (
float-reference: column) — a footnote whose reference lands inside a multi-column container can have its note placed at the foot of that column rather than the foot of the page, using css-page-floats’float-reference:sup { float: footnote; float-reference: column; }Each column then gets its own note area — its own divider, its own width, and its own reserved strip that the column’s content stops above — and a page can carry both kinds at once, since the property is set per footnote rather than per page. The same
@footnoterule styles both, with percentage lengths resolving against the column the area actually occupies.Numbering stays page-scoped:
float-referencedecides where a note goes and says nothing about counters, so two notes in two columns of one page are 1 and 2, not 1 and 1. Acolumnreference on a footnote that is not inside a multi-column container after all falls back to the page’s area, which is the spec’s own “if the float anchor is not inside a column” rule applied to a footnote.
Limitations:
float: inline-footnoteis not supported, and is not a CSS feature — it is a PrinceXML extension with no equivalent in css-gcpm-3, which definesfootnoteas the only footnotefloatvalue.- A column-scoped note area taller than the column it belongs to is declined rather than reserved, and overflows the column instead — reserving the whole column would leave no room for the content the note is attached to.
float: footnoteinside a repeated table<thead>/<tfoot>is left as ordinary content: those groups are laid out once and translated onto every page, so a call inside one has no single page of its own. A footnote inside a table cell, or inside a flex or grid item, works like one anywhere else. A footnote that is itself a direct child of a flex or grid container is replaced by its call, which is then the item.- A footnote authored inside another footnote’s body is inert — it renders as ordinary text rather than becoming a second footnote.
- A footnote body taller than a whole page’s content band overflows the note area rather than splitting across pages.
- Links and bookmark-candidate headings inside a footnote body are not collected into the PDF’s outline or link annotations.
Page floats (float: top/bottom/top-bottom/snap/inside/outside)
CSS Page Floats — the module the css-gcpm-3 floats section
now points to — extends float past the inline left/right edges to the page (or column) itself: a
figure or callout floated to the top or bottom edge of whichever page its source position lands on,
rather than positioned inline with the text around it. This is the CSS-only way to pin content to a
page edge without manual, page-aware absolute positioning.
<div style="float: top; border: 1pt solid; padding: 8pt;">
This box floats to the top of whichever page it lands on.
</div>
<p>The surrounding text flows around it, starting below the reserved strip.</p>
top/bottom— floats to the block-start or block-end edge of the page the element’s own (ordinary, block-flow) position falls on. Unlikeleft/right, this removes the element from the inline formatting context entirely — nothing wraps beside it — and instead reserves room at that edge of the page, the same “reserve space, shrink the usable content band” mechanismfloat: footnotealready uses for its note area (see Footnotes). Multiple floats on the same edge of the same page stack in document order, the first nearest the flow content.top-bottom— triestop; if the float doesn’t fit in the room still available at the page’s top edge, falls back tobottom.snap— floats to whichever edge (top or bottom) the element’s own natural position is nearer to.inside/outside— likeleft/right, but resolved against a two-sided document’s binding rather than a fixed physical side:insideis the edge nearest the spine (left on a right-hand/recto page, right on a left-hand/verso page — the same page-parity rule@page :left/:rightuses),outsideis the mirror. These two behave exactly likeleft/rightotherwise — text wraps beside them, and everyleft/rightrule above (shrink-to-fit, containment, overflow) applies unchanged.-
float-reference: column— inside a multi-column container, pins the float to the block-start or block-end edge of the column its source position falls in, rather than the page’s. Only that column gives up the room: its neighbours keep their full height, and a column with a float at its top starts its content below the float while the column beside it starts at the top of the band. Acolumn-referenced float outside any multi-column container falls back to the page.<div style="column-count: 2;"> <div style="float: top; float-reference: column; height: 60pt;">Spans only its own column.</div> <p>…</p> </div> top-bottom/snapare this implementation’s own interpretation. No currently published specification defines these two keywords’ disambiguation rule under this name — they come from the historical keyword vocabulary this feature targets, not from a rule this implementation can cite.
Limitations:
- A page float taller than the whole page’s content band is placed at the top of its page and its content carries on across the following pages, none of it lost. Nothing is reserved for it (a strip as tall as the band would leave the page no room for anything else), so in-flow content on those pages can overlap it.
- A page float resolves against the page unless it says
float-reference: column: the initialinlinereference is not treated as a distinct one. - A
float-reference: columnfloat as tall as its column overflows it rather than reserving the whole column, and one in a multi-column container that holds nothing but out-of-flow children resolves against the page. - Prince’s own extensions on top of this keyword set (
-prince-float-reference,-prince-float-policy,top-corner/bottom-corner,sidenote/widereference targets,align-top/align-bottom,inline-footnote) are not implemented.
Headers and footers — complete example
<!DOCTYPE html>
<html>
<head>
<style>
@page {
size: A4 portrait;
margin: 25mm 20mm 25mm 20mm;
@top-left { content: "Acme Corp"; font-size: 8pt; font-family: Arial; color: #555; }
@top-center { content: string(chapter); font-size: 8pt; font-family: Arial; font-weight: bold; }
@top-right { content: "Confidential"; font-size: 8pt; font-family: Arial; color: #c00; }
@bottom-left { content: "© 2025 Acme Corp"; font-size: 7pt; font-family: Arial; color: #888; }
@bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; font-family: Arial; }
}
/* Suppress the header on the cover page */
@page :first {
@top-left { content: none; }
@top-center { content: none; }
@top-right { content: none; }
}
h1 { string-set: chapter content(); }
</style>
</head>
<body>
<!-- Cover (page 1 — no header) -->
<div style="page-break-after: always;">
<h1>Annual Report 2025</h1>
<p>Cover page — header is suppressed by @page :first</p>
</div>
<!-- Chapter 1 (page 2+) -->
<h1>Introduction</h1>
<p>Running header now shows "Introduction" in the top-center margin.</p>
</body>
</html>
CSS-wide Keywords
All five CSS-wide keywords are supported on every CSS property. They are resolved during the cascade, before a property value reaches the rendering engine.
| Keyword | Behavior |
|---|---|
inherit |
Uses the parent element’s computed value for the property. On the root element (no parent), falls back to the initial value. |
initial |
Resets the property to its CSS specification initial value, ignoring inheritance. |
unset |
Acts as inherit for inherited properties (e.g. color, font-size) and as initial for non-inherited properties (e.g. margin, padding). |
revert |
Rolls back to the value from the previous cascade origin. In an author stylesheet rule, reverts to the user-agent (UA) stylesheet value. In an inline style, reverts to the author stylesheet value. |
revert-layer |
Layer-aware: rolls the property back to the value it would have from the lower-priority @layer cascade layers of the same origin (then, if none, the previous origin) — revealing an earlier layer’s value rather than discarding the whole author origin the way revert does. Works for both normal and !important declarations, and for custom properties. |
All five keywords can be combined with !important.
The all shorthand
all takes exactly one of the five keywords and applies it to every property at once — HTML and MathML properties and the SVG presentation properties alike — except direction, unicode-bidi and custom properties (--*), which it never touches. It expands like any other shorthand, so declaration order decides the winner (all: initial; color: red keeps the red, color: red; all: initial does not), !important applies to everything it sets, and @supports (all: initial) is true. Any other value, including a var() reference, makes the declaration invalid. A typical use is isolating a component from inherited and author styling: .widget { all: initial; }, or all: revert to return to the browser-default look.
CSS Custom Properties
PeachPDF supports CSS custom properties (--foo: value) and the var() function, including inheritance, fallback values, and interaction with the CSS-wide keywords above.
.card {
--brand-color: #2c3e50;
background: var(--brand-color);
border: 1px solid var(--accent-color, #333); /* fallback used since --accent-color is undefined */
}
| Feature | Support | Notes |
|---|---|---|
Declaration (--name: value) |
Full | Custom property names are case-sensitive (--Foo and --foo are distinct) and accept almost any token sequence as a value |
var(--name) |
Full | Substituted with the custom property’s cascaded value before the containing declaration is applied |
var(--name, fallback) |
Full | The fallback (which may itself contain var(), including further fallbacks) is used when --name is undefined |
| Inheritance | Full | Custom properties are always inherited, regardless of whether the property they’re used in is normally inherited |
| Shorthand properties | Full | var() inside a shorthand (e.g. margin: var(--a) var(--b)) is resolved before the shorthand is expanded into its longhands |
inherit / unset on a custom property |
Full | Pulls the parent element’s value, since custom properties are always inherited |
initial on a custom property |
Full | Clears the property to the guaranteed-invalid value (absent) |
revert / revert-layer on a custom property |
Full | revert restores the value from the previous cascade origin; revert-layer restores the value from lower-priority cascade layers of the same origin (see the CSS-wide keywords note), same as for built-in properties |
| Cyclic references | Handled | A custom property that references itself, directly or through a chain (--a: var(--b); --b: var(--a);), resolves to the guaranteed-invalid value instead of looping |
@property (typed/registered custom properties) |
Supported | A custom property registered via @property gains a typed syntax, an initial-value, and an inherits flag (see the @property at-rule row); this applies to both HTML and SVG styling |
When a var() reference can’t be resolved and no fallback is given, the containing declaration falls back the same way unset does: to the parent’s value for an inherited property, or to the property’s initial value otherwise.
CSS Math Functions
PeachPDF supports calc(), min(), max(), and clamp() anywhere a <length>, <percentage>, <angle>, <integer>, or plain <number> is accepted — width, height, margin, padding, inset (top/left/right/bottom), border-width, border-spacing, border-radius, gap, flex-basis, font-size, line-height, text-indent, the length/number arguments of transform functions like translateX()/scale(), the angle argument of rotate()/skewX()/skewY() and gradient direction angles, the hue component of hsl()/hsla(), and the <integer>-typed properties z-index, order, and widows (e.g. z-index: calc(3 - 2)).
.card {
width: calc(100% - 40px);
padding: clamp(8px, 5%, 24px);
transform: rotate(calc(45deg + 10deg));
margin-left: min(5vw, 10px); /* resolves against the page box - see CSS Viewport Units above */
}
| Feature | Support | Notes |
|---|---|---|
calc() — +, -, *, / |
Full | Standard CSS type-checking rules apply: +/- require matching operand categories (percentages freely combine with lengths), *// require one operand to be a plain number |
min() / max() |
Full | Any number of comma-separated arguments, all of the same category |
clamp(min, val, max) |
Full | Exactly 3 arguments; if min is greater than max, the used value is max (per spec). The none keyword for an unbounded min/max is not supported |
| Nesting | Full | calc()/min()/max()/clamp() may nest inside each other and inside parentheses, to any depth |
Combined with var() |
Full | The custom property is substituted first, then the resulting expression is validated and evaluated the same as a literal one |
| Percentages inside a math function | Full | Resolved against the same base the plain percentage form would use at that property (e.g. containing-block width for width/margin, parent font-size for em-relative font-size). Not accepted at all for the length-only properties (border-width, border-spacing), matching those properties’ plain (non-calc()) behavior |
Angle units (deg, grad, rad, turn) inside a math function |
Full | Mixed angle units fold to a single value at parse time (e.g. rotate(calc(1turn / 4)) → rotate(90deg)), since angle units, unlike lengths/percentages, never need layout context to resolve |
Divide-by-zero / invalid category mixes (calc(10px + 5), calc(1px * 1px), calc(10px + 5deg)) |
Rejected | The whole declaration is treated as invalid, the same as any other malformed CSS value |
Time and resolution units (s, dpi) inside a math function |
Not supported | PeachPDF doesn’t support these unit categories at all, with or without a math function |
Viewport units (vw/vh/vmin/vmax, and their logical/small/large/dynamic variants) inside a math function |
Full | Resolved against the page box, the same basis the plain (non-calc()) unit form uses — see CSS Viewport Units |
Container-relative units (cqw/cqi/cqb/cqmin/cqmax) inside a math function |
Full | Resolved against the nearest ancestor query container’s own size, the same basis the plain (non-calc()) unit form uses — see CSS Container Queries |
| A math function inside CSS Grid track sizing | Full | A calc()/min()/max()/clamp() length resolves as a grid track size — bare, and inside minmax()/fit-content()/repeat() arguments (a wrong-category math function, e.g. an angle, drops the whole track list at parse time) |
A math function for an <integer>-typed property (z-index, order, widows) |
Full | Must type-check as a plain <number> (a length/percentage/angle-category expression is rejected); the result is rounded to the nearest integer, ties away from zero, and folded to a literal at parse time — the same as a <number>-typed property, calc() is resolved eagerly rather than kept symbolic, since an integer-category expression has no relative unit to defer to layout |
Note: background-position and background-size are not listed in the table above because they’re not part of the math-function-specific test matrix — both fully support calc() in their length/percentage values (e.g. background-position: calc(50% - 10px) center), resolved via the same length parser used everywhere else in this table.
Tagged PDF (PDF/UA) Support
PeachPDF can optionally produce a tagged PDF — one with a logical structure tree (/StructTreeRoot) exposing the document’s headings, paragraphs, lists, tables, links, and images to assistive technology (e.g. screen readers), per ISO 32000-1’s tagged-PDF conventions and in the direction of PDF/UA conformance.
Tagging is off by default and enabled with a single PdfGenerateConfig flag — see Enabling tagged PDF (PDF/UA) output in Usage Examples for the configuration snippet and everything that turning it on does (automatic /Lang from <html lang>, alt-attribute /Alt entries, /Link structure elements cross-referenced with their annotations, and /Lbl//LBody list-item splitting). When EnableTaggedPdf is left at its default (false), none of this runs — output is byte-for-byte the same as if tagging didn’t exist in the codebase at all.
This same structure tree is also what the accessible “A” levels of PDF/A conformance (PdfA1A/PdfA2A/PdfA3A) build on — see Generating PDF/A-conformant output in Usage Examples.
-peachpdf-pdf-tag-type (tagged PDF structure type)
The HTML-tag → PDF-structure-type mapping is not hardcoded — it’s driven entirely by a custom CSS property, -peachpdf-pdf-tag-type, applied via ordinary CSS rules (PeachPDF’s own default stylesheet sets it for standard HTML elements; author stylesheets can override it like any other property).
| Initial value | auto |
| Applies to | All elements, and the ::marker pseudo-element (see Pseudo-elements above) |
| Inherited | No |
| Percentages | N/A |
/* Promote a styled <div> to a real BlockQuote in the structure tree */
div.pull-quote { -peachpdf-pdf-tag-type: BlockQuote; }
/* Make a purely decorative wrapper invisible to the structure tree - its children attach
directly to the nearest tagged ancestor instead */
span.decorative-wrapper { -peachpdf-pdf-tag-type: none; }
/* Suppress marker tagging for a purely decorative list */
ul.decorative li::marker { -peachpdf-pdf-tag-type: none; }
Accepted values (case-insensitive): auto, none, Part, Art, Sect, Div, Index, BlockQuote, Caption, TOC, TOCI, P, H1–H6, L, LI, Lbl, LBody, DL, DL-Div, DT, DD, Span, Quote, Table, TR, TH, TD, THead, TBody, TFoot, BibEntry, Code, Figure, Formula, Artifact, Note, Reference, Link.
auto(the initial value) — resolved from the element’s own HTML tag via the default mapping table below. An element with no default mapping and no author override resolves toDiv(block-level) orSpan(inline-level). A block that holds nothing but inline content (text, or inline elements) is a paragraph, so it is taggedPinstead ofDiv, whether it came from the fallback or from the defaultDivmapping ofdiv,header,footer,mainand the like; text between two blocks is aPas well.none— the element is fully transparent in the structure tree: no structure element is created for it, and its children attach directly to the nearest tagged ancestor. This is the escape hatch for purely presentational wrapper elements.- Any other value is used directly as the element’s PDF standard structure type, author-overridable on any element regardless of what (if anything) the default stylesheet set.
This property only has an effect when EnableTaggedPdf is true — with tagging disabled, it’s parsed and cascades normally (so it doesn’t break unrelated selector matching) but is never read by anything.
Default tag-type mapping
| HTML | -peachpdf-pdf-tag-type |
|---|---|
h1–h6 |
H1–H6 |
p |
P |
div, header, footer, main, address, hgroup, fieldset, form, center, dir, menu, pre |
Div |
span |
Span |
ul, ol |
L |
li |
LI |
li::marker |
Lbl |
dl |
DL |
dt |
DT |
dd |
DD |
table |
Table |
tr |
TR |
th |
TH |
td |
TD |
thead |
THead |
tbody |
TBody |
tfoot |
TFoot |
caption, figcaption |
Caption |
img, svg, figure |
Figure |
blockquote |
BlockQuote |
q |
Quote |
article |
Art |
section, nav, aside |
Sect |
hr |
Artifact |
math |
Formula |
code, kbd, samp, var |
Code |
a[href] |
Link (a bare <a> with no href is not a hyperlink and does not default to Link) |
html, body |
none (transparent — children attach to the synthetic document root) |
Any tag not listed here (e.g. <cite>, <mark>, <time>) falls through to the auto fallback: block-level elements resolve to Div (or P when they hold only inline content), inline-level elements to Span.
Known limitation — anonymous (CSS-generated) table structure cannot be tag-overridden
A table assembled purely through CSS (display: table / table-row / table-cell on arbitrary elements, rather than real <table>/<tr>/<td> markup) gets its row/cell/group tagging (TR/TH-or-TD/THead/TBody/TFoot) from a hardcoded fallback based on the computed display value, not from -peachpdf-pdf-tag-type — the synthesized anonymous boxes PeachPDF creates to complete the table model have no source HTML element for any selector, author or default stylesheet, to match against. Authors who need override control over table structure tagging (e.g. distinguishing header cells from data cells, which the anonymous fallback cannot do — it always tags anonymous cells TD) must use real <table>/<tr>/<th>/<td>/etc. markup rather than relying on CSS’s table display model to synthesize the structure implicitly.
What a structure element carries
Besides its type and content, a structure element takes what the document’s accessibility tree knows about its element:
- Alternative text (
/Alt) of aFigureorFormula: thealtattribute (empty marks a picture decorative), or else the element’s accessible name — anaria-label, anaria-labelledbytarget, an inline SVG’s<title>, a formula’salttextor spoken reading. - Language (
/Lang) when the element sets one of its own withlang; the document’s language is on the catalog. - Table cells: a
THhas a/Scope(ColumnorRow, fromscopeor its place), and a cell that spans several columns or rows has/ColSpanand/RowSpan, asTableattributes.
MathML Associated Files
When tagged PDF output is enabled, a <math> element’s own Formula
structure element additionally carries the element’s original MathML source as a PDF 2.0
(ISO 32000-2) Associated File (/AF, §14.13) — the standard
mechanism accessible-math tooling and assistive technology use to recover the exact semantic markup
behind a rendered formula, rather than trying to reconstruct it from drawn glyph positions. The
associated file is:
- Named
formula.mml, with MIME typeapplication/mathml+xml. - Marked
/AFRelationship /Supplement— the ISO 32000-2 relationship value for content that supplements (rather than replaces) the structure element’s own visible content. - Attached both on the
Formulastructure element itself and indexed in the document’s catalog-level/AFarray.
This works regardless of which PdfVersion the document targets, but
is only fully spec-conformant under PdfVersion.Pdf20 — /AF on a structure element is a PDF 2.0
addition to ISO 32000-1’s structure element dictionary. If an author retargets <math>’s own
-peachpdf-pdf-tag-type away from Formula (e.g. to P), the MathML source is still attached to
whichever structure element the override produces. The formula’s /Alt is its alttext, its aria-label,
or, when the author gave neither, a plain English reading of its structure (x² + 1 is “x squared plus 1”; see
Accessibility). No /ActualText is set, and the reading is deliberately
simple: the attached exact MathML source remains the complete, spec-conformant accessible representation.
Interactive PDF Forms Support
PeachPDF can optionally produce a fillable PDF — real AcroForm fields (ISO 32000-1 §12.7) a reader can type into, check, or select, instead of the default static rendering. There is no W3C CSS spec for this (it’s inherently PDF-specific); PeachPDF exposes it as its own CSS extension, the same shape as -peachpdf-pdf-tag-type above.
Interactive forms are off by default and enabled with a single PdfGenerateConfig flag — see Enabling interactive PDF forms in Usage Examples for the configuration snippet. When EnableInteractivePdfForms is left at its default (false), no -peachpdf-pdf-form-field* property is ever read, no AcroForm object is created, and the page’s own static rendering is unchanged — output is byte-for-byte the same as if the feature didn’t exist in the codebase at all.
Only real <input>/<select>/<textarea> elements are ever eligible to become a field — <button> and any other element are always rendered as static boxes, regardless of the flag. This is a deliberate scope boundary for the initial implementation, not a bug. A <select multiple> is likewise not yet supported as a multi-select list box — it becomes a single-value combo box, honoring only the first selected <option>.
Field appearance and styling
A field’s border, background-color, padding, color, and font (family, weight, style, size — including a custom @font-face) are all ordinary CSS, read from the element exactly like any other box’s, and drive the actual widget appearance a reader shows — not just the flag-off static look:
input[type=text] {
border: 1pt solid #8a2be2;
background-color: #f5e9ff;
padding: 4pt 8pt;
color: #4b0082;
font: italic 12pt Georgia, serif;
}
input/select get a plain default appearance (a thin solid black border, white background, small horizontal/vertical padding) from PeachPDF’s own UA stylesheet when the author sets none of these — the same look every field had before per-field styling existed. A checkbox or radio takes no padding from that default (a browser’s own UA stylesheet zeroes it there too) and gets a small margin instead, so an unstyled one is a 13×13px square — or circle — with room around it, the way a browser draws it. width/height size the content box like any other element, so the drawn control is that size plus its own border and padding; give a checkbox or radio equal width and height and it stays square. A checkbox/radio’s circular shape and check-mark/dot glyph are fixed (not stylable via border-radius or similar); its border/background/glyph color still follow border/background-color/color.
The value a text or combo-box field draws is enclosed in the /Tx marked-content sequence ISO 32000-1 §12.7.3.3 defines for variable text, so a reader that regenerates the field replaces exactly that region and keeps the CSS border and background around it. This is what makes editing a field behave: the previous value disappears instead of staying visible behind the new one, and clearing a field leaves an empty box rather than a ghost of what was there.
Once a user actually starts typing into a text/select field, a reader regenerates its look from /DA (PDF’s own “default appearance” string) rather than the baked-in widget appearance above — /DA always uses the PDF standard Helvetica font (Latin-1/WinAnsi only), regardless of the field’s own font-family. This is deliberate: PeachPDF, like most PDF generators, embeds only the glyphs a document’s text actually used, so a custom embedded font has no glyph ready for a character the user types that never appeared in the original value — Helvetica’s complete, no-embedding-needed WinAnsi coverage avoids that failure mode for live editing. A field’s initial appearance (what the PDF shows before anyone edits it) always uses the real font, including for non-Latin-1 text.
Form-control attributes
These HTML attributes map to their PDF equivalents on the generated field, with no CSS involved:
| HTML | Effect in a reader | |
|---|---|---|
readonly |
/Ff ReadOnly |
The value can be seen but not changed. |
disabled |
/Ff ReadOnly + NoExport |
Not editable, and not included when the form is submitted — HTML defines a disabled control as both. |
required |
/Ff Required |
The reader flags the field as one that must be filled in before submitting. |
maxlength |
/MaxLen |
Caps how many characters can be typed. Ignored when it is absent, zero, or not a valid integer. |
type="password" |
/Ff Password |
Typing is echoed unreadably, and a prefilled value is drawn as asterisks rather than legible text. |
placeholder |
/TU and the empty field’s appearance |
Drawn automatically as hint text while the field is empty, matching HTML without a CSS opt-in, and used as the tooltip on hover and the name assistive technology reads in place of the name attribute. Style the hint with ::placeholder and the empty field itself with :placeholder-shown. |
A maxlength that disagrees with -peachpdf-pdf-form-field-comb
loses to it: a comb field’s cell count is its maximum length, so the two cannot both be honoured.
required is worth one warning, because it looks like a bug the first time you see it: Adobe Acrobat
and Reader draw a red outline around every required field and keep it there, filled in or not.
That is the reader’s own “Required Fields Highlight Color” preference marking the field as required,
not a validation error about its current value, and it is under each viewer’s control rather than the
document’s. Use required when the field genuinely must be filled in, not as a styling hint.
type="password" masks the field’s appearance only — the value attribute still becomes the
field’s real /V, in readable form, because the document asked for the field to be prefilled with
it. A password that should not be in the PDF should not be in the HTML either.
An applicable placeholder is drawn automatically when the field’s generation-time value is empty,
as HTML defines; no
PeachPDF-specific CSS switch is required. The hint is appearance only: /V stays empty, so the field
still submits as unfilled. It lives inside the field’s replaceable /Tx region, which lets a PDF
reader remove it on the first keystroke instead of leaving it behind the user’s text. Use
::placeholder to style the hint and
:placeholder-shown to style the empty control itself; the latter is evaluated
once during generation because AcroForm cannot re-run CSS after an edit.
-peachpdf-pdf-form-field
| Initial value | auto |
| Applies to | input, select |
| Inherited | No |
| Percentages | N/A |
/* Force a styled <div>-like custom control to become a checkbox field (only works on a real <input>/<select> - a <div> never becomes a field regardless) */
input.agree-checkbox { -peachpdf-pdf-form-field: checkbox; }
/* Opt an input out of interactive forms even though EnableInteractivePdfForms is on */
input[readonly] { -peachpdf-pdf-form-field: none; }
Accepted values (case-insensitive): auto, none, text, checkbox, radio, select.
auto(the initial value) — the field kind is inferred from the element: an<input>withtype="checkbox"becomes a checkbox field,type="radio"becomes a radio field, a<select>becomes a select (combo-box) field, and every other<input>type (text,email,password,number,tel,url,search,date, or notypeat all) becomes a text field.type="hidden","submit","reset","button","image", and"file"are not text-like and resolve tononeunderauto.none— the element never becomes an AcroForm field, however it classifies underauto; it keeps its static-box rendering.text,checkbox,radio,select— forces the field kind regardless of the element’s own tag/type(e.g. an<input type="text">forced tocheckboxstill becomes a checkbox field).
Radio buttons sharing the same name attribute become one AcroForm field with mutually exclusive states, exactly like an HTML radio group’s own mutual-exclusivity rule.
Text-field sub-settings
Three more longhand properties, meaningful only on a field that resolves to text (directly or via auto):
| Initial value | none (-peachpdf-pdf-form-field-comb also accepts an integer) |
| Applies to | input |
| Inherited | No |
| Percentages | N/A |
-peachpdf-pdf-form-field-auto-font-size: auto \| none— whenauto, the field’s font size auto-fits its box height instead of using a fixed size.-peachpdf-pdf-form-field-comb: none \| <integer>— divides the field into the given number of evenly spaced character cells (ISO 32000-1’s “comb” field), the classic boxed layout for things like a one-character-per-box confirmation code input.-peachpdf-pdf-form-field-do-not-scroll: auto \| none— whenauto, tells the PDF reader not to scroll the field’s text when it overflows the box.
input.confirmation-code { -peachpdf-pdf-form-field-comb: 6; }
Default field-kind inference
| HTML | -peachpdf-pdf-form-field: auto resolves to |
|---|---|
select |
select |
input[type=checkbox] |
checkbox |
input[type=radio] |
radio |
input[type=text], input[type=email], input[type=password], input[type=number], input[type=tel], input[type=url], input[type=search], input[type=date], input (no type) |
text |
input[type=hidden], input[type=submit], input[type=reset], input[type=button], input[type=image], input[type=file] |
none |
textarea |
text (a multi-line field) |
button, any other element |
none (fixed — cannot be overridden) |
PDF Annotations Support
Eleven properties turn an element into a real PDF annotation: a sticky note, a highlight or underline over its text, an outlined shape, a box of free text, a rubber stamp, freehand ink, or a clickable icon that opens a file embedded in the document. They are PeachPDF’s own extension (no W3C specification), modelled on the annotation properties of other CSS-to-PDF engines, and a reader shows what they make with its own annotation tools: notes open their text on click, an attached-file icon opens the file, the viewer’s comment list shows them all.
Nothing happens until -peachpdf-pdf-annotation-type names a kind; no document that does not use it gets an annotation. Every annotation carries an appearance stream, so the output is valid at any PDF/A level that allows annotations at all; an attached-file icon additionally needs a PDF/A-3 level (or no PDF/A), since that is where embedded files are allowed.
-peachpdf-pdf-annotation-type
| Initial value | none |
| Applies to | all elements |
| Inherited | No |
| Percentages | N/A |
.note { -peachpdf-pdf-annotation-type: text; -peachpdf-pdf-annotation-contents: "Check these figures"; }
mark { -peachpdf-pdf-annotation-type: highlight; }
| Value | Annotation | Drawn over |
|---|---|---|
none |
— | — |
text |
A sticky note: an icon that opens -contents |
An icon at the element’s top-left corner |
highlight, underline, wavy, line-through |
A text mark (PDF Highlight, Underline, Squiggly, StrikeOut) |
Each line of the element’s text |
file-attachment |
An icon that opens an embedded file, named by -attachment |
An icon at the element’s top-left corner |
square, circle |
The outline of a rectangle, or of the ellipse inside it | The element’s box |
line |
A straight line from the box’s top-left to its bottom-right corner | The element’s box |
free-text |
A box of text (the element’s -contents) on a pale background with a thin border |
The element’s box |
stamp |
A rubber stamp reading the element’s -contents (or -title), in capitals, inside a double border; Draft if it has neither |
The element’s box |
ink |
Freehand strokes from -path |
The strokes themselves |
A mark, shape, free-text box, stamp or line needs an element with some area (an empty element draws nothing); an icon does not.
Icon size and place. An icon is 16pt square and sits at the element’s top-left corner. If the element’s own box is between 8pt and 48pt on both sides — an inline-block you sized like an icon — the icon fills that box instead. An empty <span></span> has no width and takes up no room, so the icon (12pt) is drawn where the span stands and covers what follows it; give the span display: inline-block and a width and height to reserve space for it.
-peachpdf-pdf-annotation-attachment
| Initial value | none |
| Applies to | all elements |
| Inherited | No |
| Percentages | N/A |
A <string>: the FileName of one of the files in PdfGenerateConfig.Attachments. The annotation points at that one embedded file, so the file is stored once however many icons refer to it, and it still appears in the viewer’s attachments panel.
<p>
Q3 sales by region
<span style="-peachpdf-pdf-annotation-type: file-attachment;
-peachpdf-pdf-annotation-attachment: 'q3-sales.csv';
display: inline-block; width: 14pt; height: 14pt"></span>
</p>
An annotation that names no file, or a file the document does not embed (or is generated with a PDF/A level that forbids embedded files), is left out.
Text properties of an annotation
| Property | Value | PDF entry | Notes |
|---|---|---|---|
-peachpdf-pdf-annotation-title |
none | <string> |
/Subj |
The annotation’s subject |
-peachpdf-pdf-annotation-contents |
none | <string> |
/Contents |
The text a note opens; the text of free text and stamps |
-peachpdf-pdf-annotation-author |
none | <string> |
/T |
Who made it; a viewer shows it as the author |
-peachpdf-pdf-annotation-createdate |
auto | <string> |
/CreationDate |
An ISO 8601 date such as '2026-03-04T05:06:07Z' |
-peachpdf-pdf-annotation-modifydate |
auto | <string> |
/M |
As above; the time of writing when unset |
All are non-inherited and apply to all elements.
-peachpdf-pdf-annotation-icon
auto |
note |
comment |
key |
help |
new-paragraph |
paragraph |
insert |
paperclip |
push-pin |
graph |
tag (initial value auto) |
The icon of a text or file-attachment annotation. A note takes the first seven (default note), an attached file the last four (default paperclip); a name from the other set falls back to the default. The icons are drawn as vectors in the annotation’s appearance stream, so they look the same in every viewer.
-peachpdf-pdf-annotation-color
auto |
<color> (initial value auto) |
The colour of the annotation: an icon’s fill, a mark’s colour, a shape’s outline, ink, the border and text of free text and stamps. auto picks the kind’s own: yellow for notes and highlights, green for underlines, red for strike-outs, shapes, ink and stamps, black text for free text.
-peachpdf-pdf-annotation-position
auto |
[ left | right | top | bottom | center | page-left | page-right | page-top | page-bottom | <length-percentage> ]{1,4} (initial value auto) |
Where the annotation’s visible icon, or its popup window, goes.
- For an icon annotation (
text,file-attachment), it places the icon. By default the icon is at the element’s top-left corner.left,right,top,bottomandcenterput it against that edge of the element’s box; thepage-*keywords put it against that edge of the page; a length (or percentage of the box’s width or height) offsets it, the first horizontally and the second vertically. - For every other kind, it places the popup window a reader opens to show
-contentsor-title(written as a linkedPopupannotation, and only when there is text to show), with the same keywords. The mark or shape itself always stays on the element.
.margin-note { -peachpdf-pdf-annotation-type: text; -peachpdf-pdf-annotation-position: page-right 4pt; }
mark { -peachpdf-pdf-annotation-type: highlight; -peachpdf-pdf-annotation-contents: "Up 12%"; -peachpdf-pdf-annotation-position: page-right; }
-peachpdf-pdf-annotation-merge
none |
duplicates |
key(<string>) (initial value none) |
By default every element produces its annotation, once per page it appears on. duplicates keeps only the first of the annotations with identical kind, text, colour and attachment; key("id") keeps only the first annotation of all the elements that give the same key. Use it for something that repeats (a table header on every page) or that should be a single annotation however many elements mention it.
-peachpdf-pdf-annotation-path
none |
path(<string>) (initial value none) |
The strokes of an ink annotation, as SVG path data in CSS pixels from the element’s top-left corner. Each subpath is one stroke; curves are sampled and an arc is drawn as the straight line to its end. An ink annotation without a path is left out.
.signature { -peachpdf-pdf-annotation-type: ink; -peachpdf-pdf-annotation-color: #1a73e8;
-peachpdf-pdf-annotation-path: path("M 4 40 C 20 -10 40 90 60 20 S 90 50 104 8"); }
Limits
- An annotation is placed from the element’s rectangles on each page, with a CSS transform and a fixed or sticky position followed. A box that continues onto another page gets one annotation per page.
- A mark’s appearance is drawn over the text rather than blended with it, so a highlight is a translucent fill the text shows through.
- Annotations inside page margin boxes are not produced.
PDF Bookmarks (Outline) Support
PeachPDF automatically generates a PDF outline (the navigable bookmark sidebar most PDF readers show) from a document’s headings, per CSS Generated Content Module Level 3’s bookmark properties. This needs no configuration: h1–h6 get a bookmark by default, and a document with no headings simply produces no outline at all.
bookmark-level
Determines whether an element generates a bookmark, and its nesting depth (1 is shallowest).
| Initial value | none |
| Applies to | All elements |
| Inherited | No |
| Percentages | N/A |
/* Give every top-level <section>'s heading a bookmark of its own, one level deeper than its
parent section's heading */
section h1 { bookmark-level: 1; }
section section h1 { bookmark-level: 2; }
/* Exclude a heading from the outline entirely */
h2.no-bookmark { bookmark-level: none; }
Accepted values: none, or a positive <integer> (1 and up). Nesting is not required to follow a strict hierarchy — a bookmark’s parent is the nearest preceding bookmark (in document order) with a shallower level, or the outline root if none exists; a level with no shallower ancestor ever declared still generates a valid, root-level bookmark.
bookmark-label
The bookmark’s text, as a <content-list> — the same string/counter()/attr()/content()/string() grammar the content property accepts (url(), gradients, and the quote/element() functions are not accepted here, since none of them produce text).
| Initial value | content(text) |
| Applies to | All elements |
| Inherited | No |
| Percentages | N/A |
/* Default: the heading's own rendered text becomes the bookmark label */
h1 { /* bookmark-label: content(text) is the initial value - no rule needed */ }
/* A generated label combining a counter, a literal string and an attribute */
h1 { counter-increment: chapter; bookmark-label: "Chapter " counter(chapter) ": " attr(data-title); }
bookmark-state
The bookmark’s initial expansion state in the reader’s outline panel.
| Initial value | open |
| Applies to | Block-level elements |
| Inherited | No |
| Percentages | N/A |
/* Collapse deep subsection bookmarks by default */
h3 { bookmark-state: closed; }
Accepted values: open, closed.
-peachpdf-bookmark-target (bookmark link target)
By default a bookmark links to the element that generated it. -peachpdf-bookmark-target overrides that — this is PeachPDF’s own extension (not part of css-content-3, which defines no such property); it follows the same naming convention as -peachpdf-pdf-tag-type for a PDF-output feature with no real spec-track name.
| Initial value | self |
| Applies to | All elements |
| Inherited | No |
| Percentages | N/A |
/* Point a table-of-contents entry's bookmark at the chapter it lists, not the ToC row itself */
.toc-entry { -peachpdf-bookmark-target: attr(href); }
/* Link straight to an external URL instead of anywhere in this document */
h1.appendix-ref { -peachpdf-bookmark-target: url(https://example.com/appendix); }
Accepted values: self, url(<url>), or attr(<attribute-name>). A value resolving to #fragment targets the element with that id in the same document; any other resolved value is treated as an external URL. A target that can’t be resolved (e.g. a missing id) leaves that bookmark without a destination rather than removing the bookmark itself.
Default bookmark-level mapping
| HTML | bookmark-level |
|---|---|
h1–h6 |
1–6 |
| Everything else | none (this property’s own initial value) |
Known limitation — bookmarks inside repeated running/margin-box content or repeated table headers/footers are not collected
A heading that only ever appears as running element content in an @page margin box (repeated per page, with no single canonical position) does not get a bookmark, even if it would otherwise have a non-none bookmark-level — only the main document content tree is walked for bookmark candidates. The same applies to a <thead>/<tfoot> repeated across a page break: a heading placed there produces no bookmark either.
Unsupported CSS Features
The following CSS features are not supported:
- Grid: masonry — the core grid formatting model (tracks, placement, named lines,
grid-template-areas, auto-flow, alignment,subgrid, and thegrid/grid-templatemega-shorthands) is supported; see Grid. Masonry, named lines insiderepeat(), andspan <name>are not - Transitions and animation playback in PDF — in a PDF transitions never run and an animation is rendered as a single frame rather than played; on a screen they play (see Animations), but an animation of a property that can change layout does not
- CSS selectors — see the CSS Selectors section above for what is and is not supported