From XHTML and CSS to a document
The builder reads a chapter once, token by token, gives each element its style from the CSS cascade the moment it opens, and writes what it reads as a flat list of blocks of styled runs, every length in ems and every position in characters.
A chapter of an EPUB is a web page. A browser would show it with an HTML parser, a CSS engine and a layout engine. The reader has no browser, so Inkshelf carries its own small version of each, limited to what books use.
This chapter covers the first half: from the chapter's XHTML and CSS to a document, a simple model that the layout code turns into pages. The document knows nothing of HTML or CSS. It is a list of paragraphs, each a list of pieces of text in one style.
Picture someone taking notes during a talk. They write each sentence as they hear it, in its final form, and never go back. They know what was said before, never what comes next. The builder works like that.
Reading as a stream
The builder does not load the chapter into a tree first. It reads the XHTML as a stream of tokens: a start tag, some text, an end tag. It keeps a stack of the elements that are open: a start tag pushes one, its end tag pops it. Text goes straight into the document, in the style of the element on top of the stack.
Each element remembers two things besides its name and attributes: its parent, and its previous sibling (the last child its parent closed before it opened). When an element closes, its own children are forgotten.
<body> <h1>Chapter 1</h1> <p class="first">Once upon a time.</p> <p>It was <em>dark</em>.</p> <p>The end.</p> </body>
<body> opens and is pushed on the stack. Its style is computed now, before anything inside it or after it is read. Its selectors can see 0 ancestors and 0 previous siblings. Whether it is the last child, or empty, is unknown yet.So an element gets its style as soon as it opens, from what is known at that moment: the element, its ancestors, its previous siblings. That decides which CSS selectors can work:
div p,div > p,h1 + pandh1 ~ plook back: they work.:first-child,:first-of-type,:nth-child(),:root,:linkand:not()look at the element and what came before it: they work.:last-child,:only-child,:empty,:last-of-typeneed to know what comes after the element. When it opens, nobody knows. Inkshelf drops these selectors when it reads the stylesheet, as it drops:hoverand the others that need a user.
Only the selector is dropped: in p:last-child, p.end { … } the rule still applies to
p.end. Books are full of CSS written for other readers, so nothing in a stylesheet ever
makes a chapter fail.
The cascade: which rule wins
Several rules often give the same element the same property. The CascadeCascade: CSS’s rule for which declaration wins when several apply: !important first, then the style attribute, then specificity, then the later rule. decides which one wins. Inkshelf's cascade holds, in order:
- Its own user-agent stylesheet (
userAgentCSSinstyle.go), the defaults:<p>has a 1em margin above and below,<h1>is bold and 1.7 times larger,<em>is italic. - The book's stylesheets, in the order the chapter gives them: each
<link>ed sheet (after the sheets it@imports) and each<style>block. Rules inside@mediaare kept only if the media matches the reader, taken as a 600×800 screen: the test book's@media amzn-kf8 { … }, meant for Kindles, is dropped.
To compute an element's style, Cascade.Compute takes the rules whose selectors match it and
sorts them by SpecificitySpecificity: How precise a CSS selector is, counted as (ids, classes, element names). The more precise rule wins., then by their order. The element's style attribute
goes above them all. Then it applies every normal declaration in that order, and then every
!important declaration in the same order. The last value written wins. In other words:
- an
!importantdeclaration beats a normal one; - at the same importance, the
styleattribute beats any rule; - then the more specific selector wins: ids over classes over element names;
- and between equals, the later rule wins.
Specificity is counted as three numbers (ids, classes, element names), packed into one
integer: ids×2²⁰ + classes×2¹⁰ + names. div.chapter p is (0, 1, 2) and beats p.first,
(0, 1, 1), even though p.first sounds more precise.
p:last-childdropped: :last-child needs to know what comes after the element.
Computed style: everything in ems
The cascade gives declared values, as the book wrote them: 1.5em, 24px, 80%,
small. The layout wants numbers. computeStyle turns the declared values into the few
properties Inkshelf follows, with inheritance (a <span> inside an italic <p> is italic),
and every length in hundredths of an emem: A length relative to the font size: 1.5em is one and a half times the text size. Inkshelf stores lengths in hundredths of an em. of the reader's font size.
Why ems? Because the reader picks the text size, from 32 to 64 pixels. A margin stored in ems grows with the text, as in a browser when you zoom. Nothing in the document is in pixels.
- The font size comes first, since the other lengths depend on it. It is kept in percent of the reader's size, and clamped between 50% and 300%: a book's tiny footnote text or giant title stays readable. A pre-paginated page keeps its own sizes, unclamped.
- An em is the element's font size:
1.5emin a normal paragraph is 150. - A CSS pixel is 1/16 em, as browsers count it by default, so
24pxis 150 too. It is not a screen pixel: it grows with the reader's text. - A percentage of a margin or an indent is of the page's width, taken as a 600 pixel CSS page: 3750 hundredths.
- Margins and indents are capped at 600, six ems (
maxMargin): books written for wide screens indent too much for a reader's page. Paddings are added to the margins.
switch length.Unit {
case "em":
hundredths = length.Value * float64(fontSize)
case "rem":
hundredths = length.Value * 100
case "%":
hundredths = length.Value * float64(hundred) / 100
default: // px, pt, in, cm…
pixels, _ := length.Pixels()
hundredths = pixels * 100 / pixelsPerEm // 16
}
Only when a page is laid out do hundredths become pixels: length × FontSize / 100, at the
reader's text size.
A paragraph before, in the reader's own size, for comparison. It is long enough to wrap around once or twice on the page.
The paragraph styled by the book: its size, its first-line indent and its left margin, in real InkPad 3 pixels.
Flattening: blocks of runs
HTML nests elements inside elements. The document does not. It is a flat list of BlockBlock: One paragraph, heading, image or rule in Inkshelf’s document model, with its margins and a list of runs.s, and each paragraph block is a list of RunRun: A stretch of text with one style (italic, bold, a size, a link…). A block is a list of runs.s:
- A block element (
<p>,<h1>,<li>,<div>) ends the block being built and starts another. A block is aParagraph, aHeading(with itsLevel, 1 to 6), anImageor aRule, with its margins, indent, alignment and font scale. - An inline element (
<em>,<span>,<a>) does not make a block. It changes the style of the text inside it: that text becomes a run with its ownStyle(bold, italic…), size, font or link. Neighbouring text in the same style joins the same run. - Whitespace is collapsed as HTML does: a run of spaces and newlines is one space, and none at the start or end of a block.
- What does not fit a flat list is described beside it. A
<div>with a border becomes aBorderBoxthat names the range of characters it surrounds.
Take <p>Hello <span class="it">world</span></p>, with the test book's
p { text-indent: 1.5em; margin: 0 } and span.it { font-style: italic }. The builder makes
one block:
Block{Kind: Paragraph, Indent: 150, Runs: []Run{
{Text: "Hello"},
{Text: " world", Style: Italic},
}}
Look at the space. The builder holds a space back until the next character comes, and then writes it in that character's run. So it lands in the italic run, not after "Hello". Only when the difference would show (an underline, a strike-through, a link, a change of direction) does the space keep the style of the text it came from.
Every position in a document is a Rune offsetRune offset: A position in a chapter counted in characters (Go calls them runes). It stays the same when the font size changes; page numbers do not.: a count of characters (Go calls
them runes) from the start of the chapter. This block is 11 runes long; an image or a rule
counts as one. Each block knows its Start, and the document its total, Chars. These
offsets never depend on the font or the screen, which is how a reading position survives a
change of text size.
<div class="chapter"> <h1>Chapter 1</h1> <p class="first">Once upon a <em>time</em>, it rained.</p> <p>Hello <span class="it">world</span>!</p> <div class="box"><p>A boxed <b>note</b>.</p></div> <hr/> </div>
p { text-indent: 1.5em; margin: 0 }
p.first { text-indent: 0 }
span.it { font-style: italic }
.box { border: 1px solid black; padding: 0.5em }How Inkshelf does it
Book.Chapter(index) calls buildDocument, which starts a fresh css.Cascade with the
user-agent sheet and hands the chapter's bytes to a builder. Its parse loop reads tokens
with Go's encoding/xml:
- a start tag goes to
start: push the element, follow a<link rel="stylesheet">, compute its declared values (cascade.Compute) and its style (computeStyle), hide it ifdisplay: none, and open a block if it is a block element; - text goes to
text, thenappendText, which collapses whitespace into runs; - an end tag goes to
endandclose: a<style>adds its stylesheet to the cascade, a block elementflushes its runs into aBlockand gives it its bottom margin.
Document.Add sets each block's Start and adds its length to Chars.
Going deeper
- The user agent's sheet is just the first sheet. In a browser, any rule of the page beats the browser's defaults. Here the defaults lose ties, because they come first, but a more specific default would beat a less specific book rule. They are nearly all plain element names, so it rarely shows.
- Broken markup. Real books have it. When the XML decoder fails, what was read before the error is kept, unless nothing was.
<br>ends the block: the next one continues the paragraph, with no top margin and no indent. A<br>on an empty line leaves a line's space instead.- Prefixes. EPUB 3 books write
-epub-hyphensor-webkit-text-emphasis.computeStylereads them as the bare property, unless the bare one is declared too. - More than blocks. Tables, floats, footnotes, inline SVG and MathML formulas all come out of the same builder, each described beside or inside the flat list. The document model's reference covers them.
- Offsets are a file format. The index saved at download time records each chapter's rune
count. A change to the builder that changes a chapter's count must bump
epub.IndexVersion(7 today), so that books on the reader are indexed again.
Where it lives in the code
internal/ebook/epub/build.go · builderReads a chapter token by token, with its stack of open elements.internal/ebook/epub/build_text.go · appendTextCollapses whitespace and gathers text into runs; flush makes the block.internal/web/css/selector.go · parseSelectorListParses selectors and drops those a stream cannot follow.internal/web/css/cascade.go · Cascade.ComputeThe declared values of an element: specificity, order, style attribute, !important.internal/ebook/epub/style.go · computeStyleDeclared values to computed style: inheritance, the font size clamp, lengths in ems.internal/ebook/epub/style.go · userAgentCSSThe default style of the elements, under the book’s.internal/typesetting/document/document.go · DocumentThe model: blocks of runs, rune offsets, border boxes.
The full story: From an EPUB to pixels and The document model.
Next: before the document's paragraphs can be cut into lines, every piece of text must be measured and drawn: Text in gray.