The layout toolkit
Writing a template means returning a string of SVG, which is manageable by hand for a heading and a rectangle, and much less so once the layout has a photo behind the text with a gradient over it and a row of chips along the top.
The toolkit is the set of small functions the built-in templates are made from. Nothing in it holds state: each function takes values and returns them, whether that is a rectangle, a list of lines or a string of SVG. A template that would rather write its own SVG by hand still can.
import { box, drawLines, inset } from "@kensio/colophon/layout";The subpath loads no rasteriser, no syntax highlighter and nothing from Node, so
a template built on it is a plain function over strings. Everything is exported
from the root entry point too, if you are importing defineConfig anyway and
would rather have one import.
Rectangles
Section titled “Rectangles”Every primitive takes a rectangle, and the one a template starts with is the
image itself. inset is how you get from there to somewhere sensible:
const full = { x: 0, y: 0, ...dimensions };const content = inset(full, Math.round(dimensions.width * 0.08));const belowHeader = inset(content, { top: 120 });One number brings every edge in. An object brings in the edges it names. An inset bigger than the rectangle collapses it rather than turning it inside out.
Text goes in two steps, because the two questions are separate: how many lines does this become, and where do they go.
blockLines answers the first. It reads a prop, fits it to a width, shrinks it
if it does not fit, and gives back the lines with the size they ended up at:
const lines = blockLines(props.title, measure, config.fontFamily, { maxWidth: content.width, maxLines: 3, fontSize: Math.round(height * 0.1), floor: 0.62, // may shrink to 62% of fontSize before it is cut instead fontWeight: 800, opacity: 1,});measure comes in on the template context rather than from this module, because
only the build knows which fonts it loaded. See
Fonts for what it can and cannot measure exactly.
drawLines answers the second, placing the lines as one block and writing the
<text> elements:
drawLines(lines, content, { fontFamily: config.fontFamily, fill: config.colors.foreground, anchor: "middle", // omit to draw from the left edge});Concatenate the lines from several calls to lay out a title and a subtitle
together, giving the second group a gapBefore to separate them. That is all
the banner template does.
For finer control there is placeLines, which returns the baselines and leaves
the drawing to you, and baselineFor, which is the single-line case: give it
the top of a band one font size tall and it returns the baseline to draw at.
const heading = { x: 0, y: 60, width, height: 54 };textElement(title, { y: baselineFor(heading.y, 54), fontSize: 54, ...attrs });Reach for it wherever a template has reserved a strip for one line, since picking the baseline by eye is how the clear space around a line stops matching the space that was set aside for it. The descender takes what is left of the band below the baseline, so the room under a line is not the room above it.
There is also measureIn, which binds a measurer to one family and weight so
you can ask how wide something is:
const widthOf = measureIn(measure, config.fontFamily, 700);const chipWidth = widthOf("release", 32) + 48;linesHeight says how tall a block of lines will be before it is drawn, which
is what a template needs when the words are one item in a group rather than the
whole of it. The wordmark template uses it to stack a logo above a name:
both have to be measured before either can be placed.
const [markSlot, textSlot] = stack( [{ size: 240 }, { size: linesHeight(lines), gapBefore: 60 }], content,);fillLines is blockLines with the question turned round. Instead of a size to
draw at and a line budget to shrink into, it takes a box, and gives back the
largest size the whole of the text fits in it:
const lines = fillLines(props.title, measure, config.fontFamily, { maxWidth: content.width, maxHeight: content.height, lineHeight: 1.08, // pass the same value to drawLines maxFontSize: Math.round(height * 0.5), minFontSize: Math.round(height * 0.125), fontWeight: 800, opacity: 1,});Reach for it where the text is the picture rather than a heading in one, so
that three words are drawn much larger than twelve. That is a narrow case, and
the thumbnail template is the built-in it exists for: a video thumbnail is
looked at in a fraction of the space it is rendered at, so the words have to
take the room they are given. Everywhere else blockLines is right, because a
heading that grew to fill its space would stop looking like a heading.
Two things it does that are worth knowing. More lines set larger beats one line
set small, so a long title wraps rather than shrinking. And it will not break a
word in half to fill the box, which a search measuring only the height it filled
would happily do. fillText is the same thing without the prop reading, for
text you already have in hand.
clampLine is the other way to make text fit: it cuts one line to the width it
has and marks the cut with an ellipsis. Shrinking is the better answer wherever
there is room for it, which is what blockLines does. Reach for this where a
line cannot shrink on its own without looking like a mistake, such as one item
in a list set at the same size as the rest.
clampLine(change, content.width - indent, widthOf, 44);trackingFor goes the other way again: rather than fitting text to a box, it
stretches one line to the width of another by spacing its characters out. Give
it the line, the width it has and the width it wants, and it returns the spacing
to put on TextLine.letterSpacing; trackedWidth says how wide the result will
be, which is what a layout centring the block needs.
const spacing = trackingFor(tagline, widthOf(tagline, 40), nameWidth);The arithmetic is exact because of how the renderer applies it: SVG puts the
space between characters and not after the last one, so a line of n characters
has n - 1 gaps, the tracked width lands on the target, and middle and end
anchors stay where they were put. That was checked by rendering rather than read
from a specification. Nothing is returned for a line already at the target, since
that would mean negative tracking, or for a line of one character, which has no
gaps to put the space in. The cover template is the built-in that uses it; see
its tracking prop.
And stringList reads a prop that may be a YAML sequence or a single value,
which is what a hand-written tags: or breadcrumb: field turns out to be:
stringList(props["tags"]); // ["typescript", "testing"], or [] for nothing usableBoxes and panels
Section titled “Boxes and panels”box is a rectangle with a fill, corners and a stroke. Attributes you do not
name are left out.
box(rect, { radius: 20, fill: "#ffffff", fillOpacity: 0.16 });panel is a box that casts a shadow, so that it reads as sitting on top of the
image rather than as part of it:
panel(rect, { radius: 24, fill: "#0d1117", shadow: 12 });The shadow is a second rectangle offset downwards rather than a blur filter, which is much cheaper for the rasteriser and reads as depth just as well at these sizes.
Images
Section titled “Images”image draws a raster image within a rectangle. Pass a data: URI: an image
read at build time and inlined renders the same wherever the build runs, with
nothing to fetch.
import { readFile } from "node:fs/promises";
const bytes = await readFile("hero.jpg");image(full, `data:image/jpeg;base64,${bytes.toString("base64")}`);fit defaults to cover, which fills the rectangle and crops the overflow.
Pass contain for a logo, since cropping a wordmark ruins it. radius rounds
the corners and needs an id to hang its clip path on.
Scrims
Section titled “Scrims”A scrim is a wash of colour over an image so the text on top of it can be read. Photographs have light and dark in them wherever they like, and white text over a bright sky is invisible.
scrim(full, "hero-scrim", { from: 0.1, to: 0.8 });That shades from nearly clear at the top to mostly dark at the bottom, which
suits a headline sitting low. Pass the same value for from and to for a flat
wash, which needs no gradient and so ignores the id.
Rows and stacks
Section titled “Rows and stacks”stack places items down an area and row places them across one. Both return
a rectangle per item, and both take an alignment of start, centre or end.
const chips = row( tags.map((tag) => ({ size: widthOf(tag, 32) + 48, gapBefore: 16 })), { ...content, height: 64 }, "start",);A group larger than the space it was given starts at the edge and runs over
rather than being squeezed, so a template that has overflowed can see that it
has. Both are the same function underneath, distribute, which is exported for
laying something out along an axis that is neither.
Ids have to be unique
Section titled “Ids have to be unique”Gradients and clip paths are referenced by id, and an image is one SVG document, so two scrims sharing an id means the second one wins in both places. Name them after what they are for rather than after the primitive, and add the size name if a template draws several.
A whole template
Section titled “A whole template”import { blockLines, drawLines, image, inset, scrim, type Template,} from "@kensio/colophon/layout";
export const photo: Template = { name: "photo", render({ props, config, dimensions, measure }) { const full = { x: 0, y: 0, ...dimensions }; const content = inset(full, Math.round(dimensions.width * 0.07));
const lines = blockLines(props.title, measure, config.fontFamily, { maxWidth: content.width, maxLines: 3, fontSize: Math.round(dimensions.height * 0.09), floor: 0.6, fontWeight: 800, opacity: 1, });
return ( image(full, String(props["photo"])) + scrim(full, "photo-scrim", { from: 0.1, to: 0.8 }) + drawLines(lines, content, { fontFamily: config.fontFamily, fill: config.colors.foreground, align: "end", }) ); },};Register it under config.templates as Templates describes.
