CSS support

CSS support

htmlbag matches stylesheet rules against the DOM with its own CSS parser and selector engine; both lived in the separate package csshtml up to htmlbag v0.0.53. CSS applies through three sources, in order of increasing specificity:

  1. <style> blocks in <head>.
  2. <link rel="stylesheet" href="…"> external files.
  3. Inline style="…" attributes.

Recognized properties

The CSS properties reference lists every property htmlbag reads with its accepted values. It is generated from the code, so it is the authoritative list. The sections below explain the behavior behind some of the properties.

Box model

On tables border-collapse and border-spacing have the CSS defaults separate and 2pt, so a table wants border-collapse: collapse like in a browser.

Typography

Fonts, alignment, decoration and spacing are in the reference. Two features deserve a closer look:

OpenType features

font-feature-settings takes the standard CSS syntax and passes the features to the shaper, so small caps, superior letters, oldstyle figures and friends come straight from the font:

.century { font-feature-settings: "sups" 1; }   /* XIXe with real superiors */
.caps    { font-feature-settings: "smcp" 1; }   /* the font's small caps */
.table   { font-feature-settings: "tnum" 1, "liga" 0; }

A missing value means on; on, off and integers (for alternate selectors like "salt" 2) work as in the spec, and normal resets the accumulated list. font-variation-settings selects variable-font axes the same way ("wght" 650).

"smcp" needs a font that has small caps. font-variant-caps: small-caps (also through font-variant and the font shorthand) works with any font: it uses smcp when the face has it and synthesizes the small caps otherwise, as capitals at 70% of the size; font-synthesis-small-caps: none turns the synthesis off.

Dropcaps (initial-letter)

initial-letter: <n> turns the block’s first letter into a dropcap spanning n lines: the letter’s cap height reaches from the first line’s cap line down to the baseline of line n, and the first n lines indent to make room. Leading punctuation (an opening quote) hangs together with the letter, per CSS Inline Layout 3.

p.opening { initial-letter: 3; }

Current limits: the size must be an integer, the optional sink argument (raised caps) is not supported, and the property is written on the block element itself because ::first-letter pseudo-element matching is not implemented yet.

Page-level layout

@page rules take the descriptors listed in the reference: the paper size, the margins, a border box with padding and a background.

Page margin boxes

The margin-box at-rules from CSS Paged Media 3 place generated content into the page margins. Supported areas: @top-left, @top-center, @top-right, @bottom-left, @bottom-center, @bottom-right and the four corner boxes (@top-left-corner and so on). The content property accepts strings, counter(page) and element(...) (see running elements below):

@page {
  @bottom-center { content: counter(page); }
  @top-right     { content: "Draft"; font-size: 8pt; color: gray; }
}

Running elements

CSS GCPM running elements move body content into the page chrome: an element with position: running(name) is removed from the normal flow and captured under its name; a margin box places the most recent capture on every page with content: element(name). This is how a footer or header shows data that lives in the document body:

footer          { position: running(pagefooter); }
@page { @bottom-center { content: element(pagefooter); } }

Per-page rules with :first / :left / :right

The pseudo-class page selectors from CSS Paged Media 3 cascade over the generic @page rule: a pseudo rule inherits any size, margin or background the generic rule sets and overrides only what it redeclares. :first matches page 1; :right and :left match odd and even pages.

@page        { size: A4; margin: 2cm; }
@page :first { margin-top: 8cm; }   /* room for a letterhead on page 1 only */

Margins resolve per page, so vertical start position, content height and the left edge follow the rule in force on each page. When a later page has a different content width, running text is set again at that width; see limitations for what keeps the width it started with.

Page backgrounds (letterheads)

background-image on @page paints a raster image or an imported PDF page across the whole sheet, behind the content. Combined with the per-page selectors this drives a letterhead without any Go or Lua code, straight from CSS plus Markdown:

@page        { size: A4; margin: 2cm; background-image: url(letterhead.pdf); -bag-background-page: 2; }
@page :first { background-image: url(letterhead.pdf); -bag-background-page: 1; }

Page 1 shows page 1 of letterhead.pdf; every following page shows page 2. The custom property -bag-background-page: <n> selects the source page of a multi-page PDF (default 1). Two separate one-page files work the same way, one url() per rule.

Set -bag-background-page on every rule that needs it. @page :first cascades over the generic @page and inherits any property it does not redeclare, so a -bag-background-page: 2 on @page would otherwise leak onto page 1. The 1 above is therefore explicit. The default of 1 applies only when no rule in the cascade sets the property at all.

The url() path is resolved relative to the document (the Markdown or HTML file’s directory), so a bare filename next to the source is found. A file that cannot be loaded is logged and skipped rather than aborting the render. See the page-background-letterhead example for a full two-page letterhead.

Notes and limits:

  • background-image accepts raster images (PNG, JPEG) and PDF pages. SVG page backgrounds are not yet supported.
  • Behind the scenes each page’s background resolves through the same @page cascade as its margins, so :first / :left / :right all select their own background.

Page breaks

break-before: always (or page-break-before) starts an element on a new page, break-after: always starts the content after it on one. break-after: avoid keeps an element on the page of the one that follows; a chain of them moves on together, so a heading and a subheading with avoid go to the next page with the first lines of the text after them instead of staying alone at the foot of a page.

h1, h2 { break-after: avoid; }
figure  { break-inside: avoid; }

break-inside: avoid keeps a block in one piece: when it does not fit, it starts on the next page. Without it, a block container breaks between its children and a paragraph between its lines.

widows and orphans are the fewest lines of a paragraph left at the top of the next page and at the foot of the current one. Both default to 2; a paragraph that would leave fewer starts a line earlier or moves on whole, and 1 switches the control off. For table rows see Tables, for the typographic side with examples Page breaks in the glu chapter.

Insert positioning

float: top | before | bottom | after — see inserts. The standard CSS float: left | right places a side float with the text wrapping around it. A float without a width is as wide as its longest line, at most as wide as its container; where it leaves no room beside it, the text goes on below it. The float’s margin on the side of the text is the space between them; where that margin is zero, htmlbag leaves -bag-float-gutter (9pt by default) instead. A negative margin lets the text run over the float by that much, as in CSS. A browser leaves nothing there, and margin: 0 cannot be told from no margin at all, so a stylesheet that wants the browser’s spacing switches the gutter off:

body { -bag-float-gutter: 0 }    /* inherited by every float */
.figure { -bag-float-gutter: 1em }

Display

display: none hides an element entirely.

display: block and display: inline override the element’s default formatting context. A <span class="title"> with display: block becomes a block-level container that starts on its own line; a <div class="badge"> with display: inline flows inline with its surrounding text. The override is read from the resolved style, so selector-driven and class-driven rules work the same as inline styles.

display: flow-root is a block that keeps clear of a float as a whole. A block beside a float runs on under it, with its border and background, and only its lines give way; a box with flow-root is set narrower beside the float instead, the way a framed box beside a picture is usually meant.

Lists and markers

list-style-type (disc, decimal, lower-roman, upper-alpha, …) controls the visible marker on <ol> / <ul> items.

list-style-position: outside (the default) places the marker in the padding band to the left of the item’s content, so multi-line items keep their text aligned at a single left edge. list-style-position: inside is accepted but not implemented yet, the marker stays outside.

The marker itself can be styled via the ::marker pseudo-element:

li::marker {
    color: darkslateblue;
    font-weight: bold;
    font-family: monospace;
}

li.note::marker {
    content: "→ ";
}

The content property on ::marker replaces the default list-style-type glyph for that item. See the marker-pseudo example.

Generated content and counters

::before and ::after pseudo-elements render generated content via the content property. Strings, attribute values (attr(name)), counter functions and the leader(…) token are supported:

h2::before {
    content: counter(section) ". ";
}
figcaption::before {
    content: "Figure " counter(figure) ": ";
}

CSS Counters work as in CSS Lists 3:

Property Effect
counter-reset: name [value] (Re)set a named counter on entering this element.
counter-increment: name [step] Bump the counter by step (default 1) when this element matches.
counter(name) The current value of the counter as a string.
counters(name, sep) Joined values up the ancestor stack (for nested numbering like 1.2.3).

Counters are scoped by the element tree: a counter-reset on a section boundary clears nested counters, exactly as the spec prescribes.

body { counter-reset: section; }
h2   { counter-reset: subsection; counter-increment: section; }
h3   { counter-increment: subsection; }
h2::before { content: counter(section) " "; }
h3::before { content: counter(section) "." counter(subsection) " "; }

See the numbered-sections-counters example for a full document with hierarchical numbering.

boxesandglue extensions

Vendor-prefixed (-bag-…) properties expose typesetting knobs that have no direct CSS-spec equivalent. They take part in the normal CSS cascade: set them on body for a document-wide default, on a class for per-element tuning, override with !important.

Property Type Default Effect
-bag-linebreak-tolerance float 4 The largest adjustment ratio a line may have: how far its spaces may stretch, as a multiple of their stretchability. Higher values let the line breaker accept looser lines, lower values give more even spacing and more overfull lines where no break is good enough. Unlike TeX’s \tolerance it is a ratio, not a badness: TeX’s 200 is about 1.26, its 10000 about 4.6.
-bag-linebreak-hyphen-penalty int 50 Demerits added at a hyphenation breakpoint (TeX \hyphenpenalty). Lower values encourage hyphenation; useful for German compound words where the default prefers a slightly overfull line to a hyphenated loose one.
-bag-italic-correction auto / none none Heuristic kern where a slanted run directly abuts an upright one, so an italic f no longer collides with a following parenthesis or quote. OpenType fonts carry no italic-correction metric for text; the kern is derived from the boundary glyph’s ink overhang. Inherited.
-bag-font-expansion percentage 5% How much a glyph may be narrowed or widened to even out the spacing of justified lines (hz). The line breaker counts on both; a line gets narrower glyphs only when its spaces have shrunk all they can. 0% turns it off.
-bag-leading-model half / trailing / registered name half Where the leading of a line goes: half splits it above and below the line as CSS does, trailing puts all of it below the line as TeX does. A Go program can add models of its own with CSSBuilder.RegisterLineModel and select them by name (see the library reference). See glu’s Lines and leading.
-bag-line-breaker auto / registered name auto How a paragraph chooses its breaks among its legal breakpoints. auto is Knuth-Plass; a Go program can add breakers of its own with CSSBuilder.RegisterBreaker and select them by name (see the library reference). Inherited.
-bag-tab-stops list / none none Positions that a tab in the text advances to, see tab stops below. Inherited.
/* Document-wide: more even spacing, hyphenate eagerly. */
body {
	-bag-linebreak-tolerance: 2;
	-bag-linebreak-hyphen-penalty: 5;
}

/* Narrow column: more permissive. */
.sidenote {
	-bag-linebreak-tolerance: 8;
}

Hyphenation patterns themselves are selected by the standard lang="…" HTML attribute (resolved through CSS Text 3 §6 hyphens), or — for whole documents — by the front-matter lang: key in the Markdown frontend. Tags without TeX patterns (Arabic, Hebrew, CJK, unknown) resolve to a no-op hyphenator and never produce break points; the tunables above only matter when patterns are present.

Tab stops

With -bag-tab-stops, a tab in the text advances to the next stop past the text before it, so columns line up without a table:

.toc li { -bag-tab-stops: 12mm, 100% end leader(dotted); }
<li>1&Tab;Introduction&Tab;3</li>

A stop is a length or a percentage of the line width, optionally followed by its alignment (start, end, center, decimal or decimal(",")) and a leader that fills the space before it (leader(" . "), dotted, solid or space). Where stops are set, a tab survives white-space collapsing, but whitespace with a newline in it stays source formatting: a tab that indents a line of the source does not count. After a <br>, a tab on the same line starts the new line at the first stop. glu’s tab stops how-to has a worked example.

PDF bookmarks

htmlbag builds a PDF outline (the bookmark tree a viewer shows in its navigation panel) automatically. Every h1–h6 becomes a bookmark labeled with the heading’s text, nested strictly by level: an h2 is a child of the preceding h1, an h3 a child of the preceding h2, and so on. A level jump (e.g. h1 straight to h3) attaches to the nearest shallower ancestor. Clicking a bookmark jumps to the heading’s exact vertical position on the page. A document without headings produces no outline.

The -bag-bookmark property overrides this per element and lets any element take part in the outline, not just headings:

Token Effect
<integer> Outline nesting level (overrides the implicit heading level).
open / closed Whether the entry shows its children expanded. Default open.
none Remove the element from the outline. A heading stays in the document; it is just absent from the bookmark tree.

The value is a space-separated list; an integer level and an open/closed state may be combined in either order.

/* An <h3> that should sit at the top level of the bookmarks: */
h3.chapter   { -bag-bookmark: 1; }

/* Collapse a deep section's children by default: */
h2.reference { -bag-bookmark: 2 closed; }

/* Keep an appendix heading out of the outline entirely: */
.appendix h2 { -bag-bookmark: none; }

/* Turn a non-heading element into a bookmark: */
p.toc-title  { -bag-bookmark: 1; }

The bookmark label is the element’s text content. Outline generation is on by default; see the library configuration (Go API) or the bagme docs for the opt-out switch.

Named colors (@-bag-color)

@-bag-color defines a color name for the document, the way DefineColor does in xts and the speedata Publisher. The name then works in every property that takes a color: color, background-color, the border-*-color family and text-decoration-color.

@-bag-color muted { value: #6A6A6A; }
@-bag-color brand { model: cmyk; c: 0; m: 80; y: 90; k: 10; }
@-bag-color spot  { model: spotcolor; colorname: "PANTONE 300 C"; c: 100; m: 44; y: 0; k: 0; }

h1        { color: brand; }
p.note    { color: muted; border-left: 2pt solid spot; }

The descriptors mirror the DefineColor attributes:

Descriptor Meaning
value Any CSS color: #hex, rgb(), cmyk(), device-cmyk(), a CSS color name or a name defined by an earlier @-bag-color. Used when no model is given.
model cmyk, rgb, gray (components 0 to 100), RGB, GRAY (components 0 to 255) or spotcolor.
c, m, y, k The components for cmyk; the optional fallback tint for spotcolor. Percentages are accepted too.
r, g, b The components for rgb and RGB. g alone is the value for gray and GRAY.
colorname The ink name of a spotcolor, written into the PDF Separation color space. Defaults to the color name.

A defined name overrides a CSS color of the same name, so a house style can redefine gold without touching the rules that use it. The rule works in external style sheets, in <style> elements and in the StyleSheet command of xts. Colors with a value are resolved when the style sheet is registered with the document, so a name has to be defined before it is referenced by another @-bag-color.

Selector support

The full CSS3 selector grammar works: type, class, id, attribute, descendant, child and sibling combinators, plus structural pseudo-classes like :first-child, :last-child, :nth-child(n), :nth-of-type(n), :not(...). A common use case is alternating-row styling on tables — see the zebra-table example.

Box model details

padding-left and margin-left on a block container shift child boxes to the right and reduce their content width accordingly, exactly as CSS prescribes. So ul { padding-left: 20pt } indents list items, blockquote { margin-left: 20pt } indents the quote body, and nested containers stack their shifts.

What’s resolved when

CSS is fully resolved before htmlbag walks the DOM — by the time CSSBuilder.OutputPagesFromText runs, every element carries its computed properties in HTMLItem.Styles (a map[string]string). This means selector matches and class-driven rules are picked up; you do not need to inline styles to make them visible to htmlbag’s detection.