BORROWDALEJS / PACKAGE REFERENCE
@borrowdale/text
Hand-written text for SVG and canvas. Labels are measured from the font's own advance widths, with no DOM, so a chart can size its axes from the labels it is about to draw, on the server or in the browser, and get the same numbers in both. The default font is Excalifont, bundled with the package.
npm install @borrowdale/textimport { svgElement, toSvgDocument } from '@borrowdale/core';
import { fontFaceCss, layoutText, measureText, toSvgText } from '@borrowdale/text';
import { excalifontFiles } from '@borrowdale/text/excalifont-files';
measureText('12,500', { fontSize: 12 });
// { text: '12,500', width: 39.96, ascent: 10.63, descent: 4.49, missing: [] }
const label = layoutText('12,500', { x: 60, y: 40, fontSize: 12, anchor: 'end', baseline: 'middle' });
const svg = toSvgDocument({
width: 120,
height: 80,
defs: [svgElement('style', {}, fontFaceCss(excalifontFiles, [label.text]))],
body: [toSvgText(label)],
});Measuring
measureText(text, { fontSize?, font? }) returns the width, ascent and descent of the text,
rounded to two decimals. fontSize defaults to 16 and font to Excalifont.
layoutText(text, options) measures the text and places it. The renderers only draw what it
returns; they never measure.
| Option | Default | |
|---|---|---|
x, y |
required | where the text goes |
fontSize |
16 | in user units |
anchor |
start |
which part of the text sits at x: start, middle or end |
baseline |
alphabetic |
which line of the text sits at y: alphabetic (the baseline), top, middle or bottom |
rotate |
0 | clockwise degrees around (x, y), from −360 to 360 |
font |
Excalifont |
The result holds the drawing position (x, y of the baseline start), the width, ascent and
descent, the box the text takes up before rotation, rotate, the rotation pivot, and the
family and fontSize it is drawn in. Anything else a layout holds can change in any release, so
draw a layout with the same version of @borrowdale/text that made it. The renderers draw a layout
as it is and do not check it again, so draw only layouts that layoutText returned.
Both return the text exactly as it will be drawn, which is the text that was measured:
- variation selectors 15 and 16 and bidirectional controls removed, because they switch characters to and from emoji and reorder the text in ways a measurement cannot follow;
- accents composed (Unicode NFC), because browsers draw
efollowed by U+0301 asé, which can have a different width; - runs of spaces and tabs collapsed to one space, and spaces at either end removed, as SVG does.
textBounds(layout) returns the upright box that holds a rotated layout: its box turned by
rotate around the pivot, for keeping rotated labels clear of each other and of the edges.
fitText(text, maxWidth, { fontSize?, font? }) returns the text as it will be drawn, whole if it is
at most maxWidth wide, or else as much of it as fits followed by "…" ("..." in a font that has dots
but no ellipsis), without the spaces before the cut. It cuts only between whole characters as they
are drawn: never inside a surrogate pair, between a letter and its marks, inside an emoji sequence
joined by zero-width joiners or with a skin tone, or between the two halves of a flag. It returns
undefined when not even the ellipsis fits, and throws for a maxWidth that is not a number from 0
to 2,000,000. Which characters count as marks follows the Unicode version of the JavaScript engine,
so a mark newer than an engine's Unicode tables may be cut differently there.
A character the font does not cover is measured as 1 em wide and listed in missing, with any
combining marks after it (below); missing lists each such piece of text once. The browser still
draws it, usually in a fallback font at that font's width (an accented letter may
instead be drawn from its letter and accent in this font), so the measurement is then approximate.
Check missing when it matters. Characters browsers draw as nothing, such as the soft hyphen, the
zero-width joiner and the remaining variation selectors, are measured as nothing. The few such
characters Chromium draws with a width (the Hangul fillers U+3164 and U+FFA0, U+180F and
U+1BCA0-U+1BCA3) count as uncovered.
A browser draws a character and the combining marks after it from one font file, or all of them in
a fallback font, so missing lists a letter together with its marks. Excalifont keeps most of its
marks in other files than its letters, so a letter with a mark that cannot be composed with it into
one character (a Cyrillic stress mark, for example) is drawn in a fallback font and listed:
measureText('приве́т').missing is ['е́']. It is measured at Excalifont's widths, so the
measurement is then approximate.
Kerning and ligatures are not applied when measuring, and no renderer applies them, so the drawn
text is as wide as measured: SVG text switches them off, and drawText draws each piece that
fitText keeps whole (a character with its marks, an emoji sequence, a flag) where the one before
it ends. The test suite draws every reference label the font covers in Chromium, Firefox and WebKit,
through all three renderers, and requires the drawn width to match the measured width within 0.05
user units. SVG text asks for text-rendering: geometricPrecision, and drawText does where the
canvas supports it (WebKit's does not), so Chromium keeps fractional glyph advances even under full
font hinting. Where the request is lost, as under a Content-Security-Policy that blocks
toSvgText's style attribute, a browser that rounds each glyph to whole pixels draws text wider
or narrower: by about 2 units across the test suite's labels, and more on longer text.
Rendering
| Function | Output | Needs |
|---|---|---|
toSvgText(layout, style?) |
<text> markup string |
nothing (SSR-safe) |
appendText(parent, layout, style?) |
appends a <text> element to an SVG element |
a DOM |
drawText(context2d, layout, style?) |
draws into a canvas or an OffscreenCanvas | the font loaded |
style takes fill (default #000): a colour (a hex code, a name, rgb(), rgba(), hsl() or
hsla()), or url(#id) for a pattern or gradient in the same SVG document. The text and every
attribute are escaped. A name the browser does not know is ignored, so SVG text then takes its
parent's fill and a canvas keeps the fill it had; a canvas cannot paint url(#id) either, and
ignores it the same way.
The font has to reach whoever views the drawing:
- An SVG document carries it in a
<style>element:fontFaceCss(excalifontFiles, labels)returns@font-facerules for only the font files thatlabelsneed. Pass every label of the document. Each label counts as it is drawn, so the spaces at either end of a label and characters drawn as nothing, such as the soft hyphen, add no file. Latin text needs one file of about 34 KB; the whole font is about 88 KB. - A page can use the same rules, or load every file of the font with
await loadFont(document.fonts, excalifontFiles). A second call adds no file the first one added. It hands the browser the font's bytes rather than a URL, so a Content-Security-Policy'sfont-srcdoes not block it. - A canvas draws once and never redraws, so text drawn before its font has loaded would keep
a fallback font.
drawTextthrowsFont not loadedinstead. Load the font withloadFontfirst, not withfontFaceCss(files, labels)alone: the browser only knows about the files those rules name, sodrawTextcannot tell that text outside them would fall back. A page can have both:drawTextalso counts the faces offontFaceCssrules, which stay unloaded until the page draws text with them, soloadFontloads every face of the font's family the page already has. Call it after adding such rules, and again after adding more. It fetches any of those faces that come from a URL and waits for them, with no time limit. One that cannot load, as the rules' faces cannot under afont-srcthat blocksdata:URLs, does not makeloadFontfail, butdrawTextthen throws for any text in that face's range, even whereloadFont's own faces could draw it: on such a page, leave the rules out or allowdata:fonts.loadFontfails only if a face it made for the family, in that call or an earlier one, cannot load.
Fonts and font files must come from you, never from chart data: a family name is global to the page, so a font file named like one of the page's own fonts would redraw the page's text, and measuring reads every range of a font's files for each label.
SVG text drawn before its font has loaded is redrawn by the browser when the font arrives.
The type declarations refer to DOM types (Element, CanvasRenderingContext2D, FontFaceSet), so
TypeScript projects need "dom" in compilerOptions.lib, even when they only render on the server.
The measurement assumes nothing around the text changes how it is drawn. Do not apply
letter-spacing, word-spacing, font-weight, font-style, font-stretch, font-variant,
font-feature-settings, text-transform, text-anchor, dominant-baseline or
alignment-baseline to Borrowdale text through CSS or a parent element, and keep stylesheet rules
that select the text elements themselves (.tick text { font-size: 10px }) away from them: such a
rule overrides the font family and size the renderers set. The renderers keep the text left to
right against the page, a parent element and such rules, unless a rule is !important, and
except toSvgText text under a Content-Security-Policy that blocks its style attribute (below):
there only its direction attribute remains, and a stylesheet rule overrides that. A page's
lang can switch on a language's own letter forms: on a Catalan (ca) page, l·l draws narrower
than measured.
toSvgText sets the direction, switches kerning and ligatures off and requests fractional glyph
advances with a style attribute, and embedded fonts arrive in a <style> element. A
Content-Security-Policy that restricts styles, through style-src or else default-src, blocks
both unless it allows them. 'unsafe-inline' allows both, but browsers ignore it once the directive
also lists a nonce or hash. A hash of a <style> element's content allows that element, and a hash
of the attribute's value allows the attribute when the directive also lists 'unsafe-hashes'; the
value is the same in every toSvgText output, so one hash covers every text element. A
style-src-attr or style-src-elem directive takes the place of style-src for the attribute or
the element. The embedded fonts also need data: in font-src. Under a policy that blocks them,
use appendText, which sets the same properties through the CSSOM, and load the font with
loadFont.
Validation
A value that cannot be drawn throws an error naming it. The wording of these messages is for people and can change in any release, so do not match on it in code:
- Text must be a string of at most 1,000 characters (UTF-16 code units), both as given and with its accents composed, on one line: line feed, carriage return, U+0085, U+2028 and U+2029 are rejected. Characters an XML document cannot hold (C0 control characters other than tab, U+FFFE, U+FFFF, unpaired surrogates) are rejected.
fontSizemust be greater than 0 and at most 10,000.xandymust be within ±1,000,000.- A
Font'sfamilymust be letters, digits, spaces, hyphens and underscores, starting and ending with a letter or digit.unitsPerEmmust be from 1 to 16,384,ascenderfrom 0 to 32,767,descenderfrom −32,768 to 0, and every advance from 0 to 65,535. Itsfiles, when given, must be one or more files, each with one or more[first, last]ranges of code points, asFontFilesfiles must. fillmust be a colour (a hex code, a name,rgb(),rgba(),hsl()orhsla()) orurl(#id), at most 100 characters. Other paints, such as aurl()naming another file or avar(), are rejected, and so areinherit,initial,unsetandrevert.fontFaceCss's labels must be an array, and each label must pass the checks for text above.- Unknown option names are rejected (
Unknown option: "fontSzie"). An option set toundefinedcounts as not set. Only an options object's own properties are read: inherited values, including any onObject.prototype, are ignored.
Fonts
A Font holds what measuring needs: family, unitsPerEm, ascender, descender and the
advances of the code points it covers. A font split into files by Unicode range, as Excalifont
is, also lists its files, each with the ranges of code points it serves: its FontFiles files
without their bytes. Measuring uses them to find a letter and its combining marks that no one
file serves together, or a character no file serves, which the browser draws in a fallback font.
Leave files out for a font in one file. files is read only as the font's own property: a font
that inherits it, through Object.create for example, counts as one file. excalifont is the
default. Measurement and rendering both take the family from the Font, so a label is always drawn
in the font it was measured with.
FontFiles holds a font's woff2 files as base64, each with the code points it serves. Excalifont's
are in @borrowdale/text/excalifont-files, a separate entry point, so code that only measures does
not load them.
excalifont and excalifontFiles are frozen, all the way down, so no code can change them for the
rest of the page. To change one, make a copy:
{ ...excalifont, advances: { ...excalifont.advances, 0x41: 700 } }.
Excalifont covers printable ASCII, Cyrillic (U+0400 to U+045F) and most of Latin-1, Latin
Extended-A and Greek. It lacks ¤ § ¬ ² ³ µ ¹, ĸ Ŋ ŋ ſ, and Δ Ω, which are measured as 1 em and
listed in missing.
License
The code is MIT. Excalifont is licensed under the SIL Open Font License 1.1; see OFL.txt. Excalifont was designed by Ján Filípek (DizajnDesign), based on Virgil by Your Own Font Foundry. It is copyright Excalidraw, and "Excalifont" is a trademark of Excalidraw. This package is not affiliated with Excalidraw. The font files are included unchanged.