A small CSS2 document renderer built from specifications
npm install dropflow
Dropflow is a CSS layout engine created to explore the reaches of the foundational CSS standards (that is: inlines, blocks, floats, positioning and eventually tables, but not flexbox or grid). It has a high quality text layout implementation and is capable of displaying many of the languages of the world. You can use it to generate PDFs or images on the backend with Node and node-canvas or render rich, wrapped text to a canvas in the browser.
* Supports over 30 properties including complex ones like float
* Bidirectional and RTL text
* Hyperscript (h()) API with styles as objects in addition to accepting HTML and CSS
* Any OpenType/TrueType buffer can (and must) be registered
* JPEG, BMP, PNG, and GIF s supported (backend support may differ)
* Font fallbacks at the grapheme level
* Colored diacritics
* Desirable line breaking (e.g. carries starting padding to the next line)
* Optimized shaping
* Inherited and cascaded styles are never calculated twice
* Handles as many CSS layout edge cases as I can find
* Fully typed
* Lots of tests
* Fast
Following are rules that work or will work soon. Shorthand properties are not listed. If you see all components of a shorthand (for example, border-style, border-width, border-color) then the shorthand is assumed to be supported (for example border).
| Property | Values | Status |
| -- | -- | -- |
| color | rgba(), rgb(), #rrggbb, #rgb, #rgba | ✅ Works |
| direction | ltr, rtl | ✅ Works |
| font-family | | ✅ Works |
| font-size | em, px, smaller etc, small etc, cm etc | ✅ Works |
| font-stretch | condensed etc | ✅ Works |
| font-style | normal, italic, oblique | ✅ Works |
| font-variant | | 🚧 Planned |
| font-weight | normal, bolder, lighter light, bold, 100-900 | ✅ Works |
| letter-spacing | | 🚧 Planned |
| line-height | normal, px, em, %, number | ✅ Works |
| tab-size | | 🚧 Planned |
| text-align | start, end, left, right, center | ✅ Works |
| text-decoration | | 🚧 Planned |
| unicode-bidi | | 🚧 Planned |
| vertical-align | baseline, middle, sub, super, text-top, text-bottom, %, px etc, top, bottom | ✅ Works |
| white-space | normal, nowrap, pre, pre-wrap, pre-line | ✅ Works |
| word-breakoverflow-wrap,word-wrap | break-word, normalanywhere, normal | ✅ Works |
| Property | Values | Status |
| -- | -- | -- |
| clear | left, right, both, none | ✅ Works |
| float | left, right, none | ✅ Works |
| writing-mode | horizontal-tb, vertical-lr, vertical-rl | 🏗 Partially done1 |
1Implemented for BFCs but not IFCs yet
| Property | Values | Status |
| -- | -- | -- |
| background-clip | border-box, content-box, padding-box | ✅ Works |
| background-color | rgba(), rgb(), #rrggbb, #rgb, #rgba | ✅ Works |
| border-color | rgba(), rgb(), #rrggbb, #rgb, #rgba | ✅ Works |
| border-style | solid, none | ✅ Works |
| border-width | em, px, cm etc | ✅ Works |
| top, right, bottom, left | em, px, %, cm etc | ✅ Works |
| box-sizing | border-box, content-box | ✅ Works |
| display | block | ✅ Works |
| display | inline | ✅ Works |
| display | inline-block | ✅ Works |
| display | flow-root | ✅ Works |
| display | none | ✅ Works |
| display | table | 🚧 Planned | |
| height | em, px, %, cm etc, auto | ✅ Works |
| margin | em, px, %, cm etc, auto | ✅ Works |
| max-height, max-width,min-height, min-width | em, px, %, cm etc, auto | 🚧 Planned |
| padding | em, px, %, cm etc | ✅ Works |
| position | absolute | 🚧 Planned |
| position | fixed | 🚧 Planned |
| position | relative | ✅ Works |
| transform | | 🚧 Planned |
| overflow | hidden, visible | ✅ Works |
| width | em, px, %, cm etc, auto | ✅ Works |
| z-index | number, auto | ✅ Works |
| zoom | number, % | ✅ Works |
Dropflow works off of a DOM with inherited and calculated styles, the same way
that browsers do. You create the DOM with the familiar h() function, and
specify styles as plain objects.
``ts
import * as flow from 'dropflow';
import {createCanvas} from 'canvas';
import fs from 'node:fs';
// Register fonts before layout. This is a required step.
const roboto1 = new flow.FontFace('Roboto', new URL('file:///Roboto-Regular.ttf'), {weight: 400});
const roboto2 = new flow.FontFace('Roboto', new URL('file:///Roboto-Bold.ttf'), {weight: 700});
flow.fonts.add(roboto1).add(roboto2);
// Always create styles at the top-level of your module if you can.
const divStyle = flow.style({
backgroundColor: {r: 28, g: 10, b: 0, a: 1},
textAlign: 'center',
color: {r: 179, g: 200, b: 144, a: 1}
});
// Since we're creating styles directly, colors are numbers
const spanStyle = flow.style({
color: {r: 115, g: 169, b: 173, a: 1},
fontWeight: 700
});
// Create a DOM
const rootElement = flow.dom(
flow.h('div', {style: divStyle}, [
'Hello, ',
flow.h('span', {style: spanStyle}, ['World!'])
])
);
// Layout and paint into the entire canvas (see also renderToCanvasContext)
const canvas = createCanvas(250, 50);
await flow.renderToCanvas(rootElement, canvas);
// Save your image
fs.writeFileSync(new URL('file:///hello.png'), canvas.toBuffer());
`
This API is only recommended if performance is not a concern, or for learning
purposes. Parsing adds extra time (though it is fast thanks to @fb55) and
increases bundle size significantly.
`ts
import * as flow from 'dropflow';
import parse from 'dropflow/parse.js';
import {createCanvas} from 'canvas';
import fs from 'node:fs';
const roboto1 = new flow.FontFace('Roboto', new URL('file:///Roboto-Regular.ttf'), {weight: 400});
const roboto2 = new flow.FontFace('Roboto', new URL('file:///Roboto-Bold.ttf'), {weight: 700});
flow.fonts.add(roboto1).add(roboto2);
const rootElement = parse(
);const canvas = createCanvas(250, 50);
flow.renderToCanvas(rootElement, canvas);
canvas.createPNGStream().pipe(fs.createWriteStream(new URL('hello.png', import.meta.url)));
`Performance characteristics
Performance is a top goal and is second only to correctness. Run the performance examples in the
examples directory to see the numbers for yourself.* 8 paragraphs with several inline spans of different fonts can be turned from HTML to image in 9ms on a 2019 MacBook Pro and 13ms on a 2012 MacBook Pro (
perf-1.ts)
* The Little Prince (over 500 paragraphs) can be turned from HTML to image in under 160ms on a 2019 MacBook Pro and under 250ms on a 2012 MacBook Pro (perf-2.ts)
* A 10-letter word can be generated and laid out (not painted) in under 25µs on a 2019 MacBook Pro and under 50µs on a 2012 MacBook Pro (perf-3.ts)The fastest performance can be achieved by using the hyperscript API, which creates a DOM directly and skips the typical HTML and CSS parsing steps. Take care to re-use style objects to get the most benefits. Reflows at different widths are faster than recreating the layout tree.
API
The first two steps are:
1. Register fonts
2. Create a DOM via the Hyperscript or Parse API
Then, you can either render the DOM into a canvas using its size as the viewport:
Or, you can use the lower-level functions to retain the layout, in case you want to re-layout at a different size, choose not to paint (for example if the layout isn't visible) or get intrinsics:
1. Load dependent resources
2. Generate a tree of layout boxes from the DOM
3. Layout the box tree
4. Paint the box tree to a target like canvas
Fonts
The first step in a dropflow program is to register fonts to be selected by the CSS font properties. Dropflow does not search system fonts, so you must construct a
FontFace and add it at least once. The font registration API implements a subset of the CSS Font Loading API and adds one non-standard method, loadSync.file:/// URLs will load() synchronously on the backend via readFileSync. To get synchronous behavior without having promises swallow errors, you can use the loadSync method.ArrayBuffers are loaded immediately in the constructor, just like in the browser.`ts
const fonts: FontFaceSet;class FontFaceSet {
ready: Promise;
has(face: FontFace): boolean;
add(face: FontFace): FontFaceSet;
delete(face: FontFace): boolean;
clear(): void;
}
class FontFace {
constructor(family: string, source: URL | ArrayBuffer, descriptors?: FontFaceDescriptors);
load(): Promise;
loaded: Promise;
}
interface FontFaceDescriptors {
style?: 'normal' | 'italic' | 'oblique';
weight?: number | 'normal' | 'bold' | 'bolder' | 'lighter';
stretch?: 'normal' | 'ultra-condensed' | 'extra-condensed' | 'condensed' | 'semi-condensed' | 'semi-expanded' | 'expanded' | 'extra-expanded' | 'ultra-expanded';
variant?: 'normal' | 'small-caps';
}
`$3
`ts
import * as flow from 'dropflow';const roboto1 = new FontFace(
'Roboto',
new URL('https://cdn.jsdelivr.net/fontsource/fonts/roboto@latest/latin-400-normal.ttf')
);
const roboto2 = new FontFace(
'Roboto',
new URL('https://cdn.jsdelivr.net/fontsource/fonts/roboto@latest/latin-700-normal.ttf'),
{weight: 'bold'}
);
flow.fonts.add(roboto1).add(roboto2);
for (const font of flow.fonts) font.load();
await fonts.ready;
// now you can do layout!
`$3
`ts
import registerNotoFonts from 'dropflow/register-noto-fonts.js';
``ts
async function registerNotoFonts(): void;
`Registers every Noto Sans font family. The fonts are published by FontSource and hosted by jsDelivr.
Note that this is a big import: there are more than 200 Noto Sans fonts, and the CJK fonts have large
unicodeRange strings. It is probably better to register individual fonts for production use in a web browser. You could also copy and paste what you need from register-noto-fonts.ts.For Latin, italic fonts are registered. For all scripts, one normal (400) weight and one bold (700) is registered.
Since dropflow cannot use system fonts, this is similar to having fallback fonts for many languages available on your operating system.
> [!NOTE]
> While this will make the vast majority of text renderable, some scripts should be displayed with fonts made specifically for the language being displayed. For example, Chinese, Korean, and Japanese share common Unicode code points, but can render those characters differently. There is also a small cost to inspecting every character in the document. It is always better to use specific fonts when possible.
$3
`ts
function createFaceFromTables(source: URL | ArrayBufferLike): Promise;
`This can be used if you want a font to be described (family, weight, etc) by its internal metadata. It also reads language information from the font, which will rank it more optimally in the fallback list for a run of text. It will also result in a more appropriate CJK font being chosen for CJK text when the language is known.
A
Promise is returned if the URL is a non-file:// URL, otherwise, a FontFace is returned directly.This function partly exists to keep behavior that dropflow used to have, since it did not used to support specifying custom font metadata for font selection (it _only_ read metadata from inside the font). The test suite also takes advantage of the fallback list being properly ordered by language for its convenience. In most cases, it is fine to use the
FontFace constructor instead.`ts
function createFaceFromTablesSync(source: URL | ArrayBufferLike): FontFace;
`If the
source is an ArrayBuffer or a file:// URL in Node/Bun, this can be used to load synchronously and get synchronous exceptions.$3
1. Because dropflow doesn't use system fonts, all registered
FontFaces are valid choices for fallback fonts. In the browser, if there isn't an exact @font-face or FontFace match for a font-family, none of them are used. Dropflow instead treats all registered fonts that can render the text as if they were specified in font-family.
2. file:// URLs are supported server-side and can be called with the non-standard loadSync() method.FontFaces registered with a URL must have their load or loadSync methods called before layout. It's best to have this done automatically by calling flow.load or flow.loadSync on the entire document.Hyperscript
The hyperscript API is the fastest way to generate a DOM. The DOM is composed of
HTMLElements and TextNodes. The relevant properties of them are shown below. More supported properties are described in the DOM API section.$3
`ts
function style(properties: DeclaredStyleProperties): DeclaredStyle;
`Use the
style function to create a style for passing to the attributes of an element later. DeclaredStyleProperties is defined in style.ts.$3
`ts
type HsChild = HTMLElement | string;class HTMLElement {
children: (HTMLElement | TextNode)[];
}
class TextNode {
text: string;
}
interface HsData {
style?: DeclaredStyle | DeclaredStyle[];
attrs?: {[k: string]: string};
}
function h(tagName: string): HTMLElement;
function h(tagName: string, data: HsData): HTMLElement;
function h(tagName: string, children: HsChild[]): HTMLElement;
function h(tagName: string, text: string): HTMLElement;
function h(tagName: string, data: HsData, children: HsChild[] | string): HTMLElement;
`Creates an HTMLElement. Use styles from the previous section. Currently the only attribute used is
x-dropflow-log, which, when present on a paragraph, logs details about text shaping.$3
`ts
function t(text: string): TextNode;
`Creates a TextNode. Normally you don't need to do this, just pass a string as an
HsChild to flow.h. If you need to build a DOM breadth-first, such as in a custom parser, you can use this and mutate the text property on the returned value.$3
`ts
type HsChild = HTMLElement | string;function dom(el: HsChild | HsChild[]): HTMLElement
`Calculates styles and wraps with
if the root tagName is not "html".The entire
h tree to render must be passed to this function before rendering.Parse
This part of the API brings in a lot more code due to the size of the HTML and CSS parsers. Import it like so:
`ts
import parse from 'dropflow/parse.js';
`Note that only the
style HTML attribute is supported at this time. class does not work yet.
$3
`ts
function parse(str: string): HTMLElement;
`Parses HTML. If you don't specify a root
element, content will be wrapped with one.Render DOM to canvas
This is only for simple use cases. For more advanced usage continue on to the next section.
`ts
function renderToCanvas(rootElement: HTMLElement, canvas: Canvas): Promise;
`Renders the whole layout to the canvas, using its width and height as the viewport size.
Load
`ts
type LoadableResource = FontFace | Image;class Image {
src: string;
reason: unknown;
}
function load(rootElement: HTMLElement): Promise;
`Ensures that all of the fonts and images required by the document are loaded.
For fonts, that means looking at the
style's font-family and other font properties as well as the text of the document in order to find suitable FontFaces from flow.fonts.For images, it means fetching the image data so it's ready for layout. In addition to calling
flow.environment.resolveUrl for each image, this will call flow.environment.createDecodedImage.Because the whole fallback list for every unique set of font properties is appended to the returned loaded list, it may contain duplicate fonts. And since there is only one
Image instance per unique URL, there may be duplicate images. Deduplication is left up to the caller since it impacts performance. The list is in document order.`ts
function loadSync(rootElement: HTMLElement): LoadableResource[];
`If your URLs are all file:/// URLs in Node/Bun,
loadSync can be used to load dependencies.If the document contains images, painting to canvas will throw an error. The canvas backend requires decoding images asynchronously.
$3
`ts
function createObjectURL(buffer: ArrayBufferLike): string;
function revokeObjectURL(url: string): void;
`These functions can be used to send image buffers to
. Since there is no associated document (unlike the browser), memory is retained between createObjectURL and revokeObjectURL.Generate
$3
`ts
function generate(rootElement: HTMLElement): BlockContainer
`Generates a box tree for the element tree. Box trees roughly correspond to DOM trees, but usually have more boxes (like for anonymous text content between block-level elements (
divs)) and sometimes fewer (like for display: none).BlockContainer has a repr() method for logging the tree.Hold on to the return value so you can lay it out many times in different sizes, paint it or don't paint it if it's off-screen, or get intrinsics to build a higher-level logical layout (for example, spreadsheet column or row size even if the content is off screen).
Layout
$3
`ts
function layout(root: BlockContainer, width = 640, height = 480);
`Position boxes and split text into lines so the layout tree is ready to paint. Can be called over and over with a different viewport size.
In more detail, layout involves:
* Margin collapsing for block boxes
* Passing text to HarfBuzz, iterating font fallbacks, wrapping, reshaping depending on break points
* Float placement and
clearing
* Positioning shaped text spans and backgrounds according to direction and text direction
* Second and third pass layouts for intrinsics of float, inline-block, and absolutes
* Post-layout positioning (position)Paint
This step paints the layout to a target. Painting can be done as many times as needed (for example, every time you render your scene to the canvas).
Canvas and SVG are currently supported. If you need to paint to a new kind of surface, contributions are welcome. It is relatively easy to add a new paint target (see the
PaintBackend interface in src/paint.ts).There is also a toy HTML target that was used early on in development, and kept around for fun.
$3
`ts
function paintToCanvas(root: BlockContainer, ctx: CanvasRenderingContext2D): void;
`Paints the layout to a browser canvas, node-canvas, or similar standards-compliant context.
$3
`ts
function paintToSvg(root: BlockContainer): string;
`Paints the layout to an SVG string, with
@font-face rules referencing the URL you passed to flow.FontFace.$3
`ts
function paintToSvgElements(root: BlockContainer): string;
`Similar to
paintToSvg, but doesn't add