@unmade/text-renderer
v1.3.0
Published
Pure function text renderer for SVG output
Readme
@unmade/text-renderer
Pure function SVG text renderer. No side effects, all dependencies injected explicitly. Web worker compatible.
Public API
import { getRenderedText } from '@unmade/text-renderer';
const svg = await getRenderedText({
text: 'HELLO',
fontUrl: 'https://example.com/font.ttf',
physicalSize: [40, 'mm'],
boxDimensions: {
width: 500,
height: 200,
physicalWidth: 100,
physicalHeight: 40,
physicalUnits: 'mm',
},
spacing: { outlineWidth: 0, letterSpacing: 0, letterSpacingOutline: 0 },
baseline: 'flat', // or 'curved', or { type: 'custom', path: '...' }
horizontalAlignment: 'centre', // 'left' | 'centre' | 'right' | 'distributed'
verticalAlignment: 'centre', // 'top' | 'centre' | 'bottom'
fillColour: { hex: '#000000' },
});Import paths
import { getRenderedText } from '@unmade/text-renderer'; // Browser
import { getRenderedText } from '@unmade/text-renderer/node'; // Node.js
import { getRenderedText } from '@unmade/text-renderer/worker'; // Web WorkerESM-only
This package is ESM-only — require('@unmade/text-renderer') will not work. This is a hard constraint: harfbuzzjs (the font shaping engine) uses top-level await for WASM initialisation, which Rollup cannot emit as CJS.
Node.js / Lambda: Node 18+ supports ESM natively, so this is not a blocker. When integrating from a CJS codebase, use dynamic import:
const { getRenderedText } = await import('@unmade/text-renderer/node');CE Lambda integration: When wiring text-renderer into the Configuration Engine Lambda, use the /node ESM entry (@unmade/text-renderer/node) from an ESM Lambda handler, or use the await import() pattern above from an existing CJS handler. The ce-adapter sub-package (@unmade/text-renderer/ce-adapter) retains a CJS build and can be require()'d normally.
Choosing a font size
getRenderedText draws text at a size you give it. These work out what that size should be.
import { getFontSizeToFitBox, loadFont } from '@unmade/text-renderer';
const font = await loadFont('https://example.com/font.ttf');
const { fontSize, fits, boundBy } = getFontSizeToFitBox({
font,
text: 'HELLO',
boxDimensions: {
width: 500,
height: 200,
physicalWidth: 100,
physicalHeight: 40,
physicalUnits: 'mm',
},
size: { mode: 'fitToBox', minFontSize: 20, maxFontSize: 200 },
spacing: { outlineWidth: 0, letterSpacing: 0, letterSpacingOutline: 0 },
baseline: 'flat', // or 'curved', or { type: 'custom', path: '...' }
verticalAlignment: 'centre', // affects where a curved baseline sits
});For flat and custom baselines this is a direct calculation rather than a search: glyph
widths, outline stroke and letter spacing are all fontSize * constant, so measuring
once at a reference size and solving for a scale factor is exact. Curved baselines are
bisected instead, against both limits at once: neither the arc's length nor the arch's
position is proportional to font size, so both are evaluated at each candidate size.
The height check asks where the text will actually land, not how tall it is. The arch is positioned from a standard capital, so that its shape does not change as characters are typed — which means a descender hangs below the arch's ends, outside a box the arch itself sits comfortably inside. The check reads the arch's placement from the same function that generates the baseline path, so a size it accepts is one the renderer can draw.
The three ways to decide a size
size covers each of them, so none is a special case:
| size | Meaning |
|---|---|
| { mode: 'fitToBox', minFontSize, maxFontSize } | Solve for the largest size that fits the box |
| { mode: 'fontSize', fontSize } | Pin a size in pixels |
| { mode: 'physicalSize', physicalSize: [60, 'mm'] } | Pin a physical size — what a fixed size preset is |
minFontSize and maxFontSize belong only to fitToBox, because they only mean
anything while solving. A pinned size overrides them by design.
Pinning inverts the problem. Instead of the text being fixed and the size free, the size is fixed and the text length becomes the free variable — hold 60mm, and fit as many characters as will go.
Reading the result
interface FitTextToBoxResult {
fontSize: number;
fits: boolean; // false when it won't fit even at the smallest allowed size
boundBy: 'width' | 'height' | 'requestedSize';
}boundBy says which limit decided the outcome — when it fits, what capped the size;
when it doesn't, what couldn't be satisfied. Callers generally need to respond
differently to each: text too wide for its box and text too tall for its box call for
different remedies, and in the configuration engine they surface as different messages.
requestedSize means the box wasn't the limiting factor: either maxFontSize was
reached while solving, or a pinned size was simply granted.
Shortening text that won't fit
import { fitAndTruncateTextToBox } from '@unmade/text-renderer';
const result = fitAndTruncateTextToBox({ font, text, boxDimensions, size, spacing });
result.text; // possibly shortened
result.truncated; // whether anything was removed
result.linesDropped; // whole lines removed
result.charactersRemoved; // trailing characters removedWhole lines are dropped before characters are trimmed. Both counts are reported rather than a single mode, because one call can do both — drop a line, then find the remainder still needs trimming — and because the caller usually wants to say something different about each.
Works in every size mode. With a pinned size it is the only thing that can give: the
size stays put and the text shortens.
Rotation
Deliberately absent. Pass the box you want text laid out in and apply rotation to the rendered output. Rotating the frame rather than reasoning about rotation inside the solver keeps it working in one coordinate space, and is also what lets a rotated tall placement use its long side for the baseline.
Measuring text
renderText returns artwork; measureText returns its dimensions, for the same
inputs.
import { measureText } from '@unmade/text-renderer';
const m = measureText({
font,
text: 'HELLO',
boxDimensions,
physicalSize: [20, 'mm'],
spacing: { outlineWidth: 0, letterSpacing: 0, letterSpacingOutline: 0 },
baseline: 'flat',
});
m.bbox; // { x, y, width, height } in box pixels
m.physicalWidth; // in the box's units
m.physicalHeight;
m.physicalCapHeight; // see below
m.lines; // per line, in rendered orderIt measures the same positioned glyphs that get drawn, rather than
re-deriving dimensions from font metrics. That matters for more than tidiness: a
curved line's bounds follow the arch, which no flat metrics calculation can know
about, and any second implementation eventually disagrees with the first.
renderText and measureText share one layout pass for exactly that reason.
It throws where renderText would throw, and for the same reasons — so if
measuring succeeds, rendering the same options will too.
Cap height is reported, not derived
physicalCapHeight is the physical height of a standard capital at the chosen
font size. It is not physicalHeight, and can't be calculated from it:
physicalHeightmeasures the glyphs actually present, so it grows with descenders, accents and punctuationphysicalCapHeightis a property of the font and size alone, so the same size always reports the same cap height regardless of what was typed
Both are needed. Cap height is what a fixed size preset targets and what gets persisted against a design for manufacturing; the measured height is what the artwork actually occupies.
Configuration Engine adapter
The ce-adapter sub-package bridges CE (Configuration Engine) editor state to renderer options:
import { extractTextPlacements, mapStateToRendererOptions } from '@unmade/text-renderer/ce-adapter';
const placements = extractTextPlacements(editor); // LoadedTextPlacement[]
const options = mapStateToRendererOptions(placements[0]); // GetRenderedTextOptions
const svg = await getRenderedText(options);extractTextPlacements reads an editor instance and returns one LoadedTextPlacement per text placement, resolving fonts, colours, and position data from the CE state.
mapStateToRendererOptions converts a LoadedTextPlacement into the GetRenderedTextOptions shape that getRenderedText accepts — including alignment normalisation (CE uses American English, TR uses British English).
Comparison system
The packages/tr-comparison package provides a pipeline for validating TR output against the CE's own rendering for real production designs.
How it works
- A design URL is POSTed to the comparison Lambda
- The Lambda loads the design via the CE factory, extracts all text placements, and renders each one with both CE and TR
- Both outputs are rasterised to PNG and diffed pixel-by-pixel with pixelmatch
- Results (match score, images, state) are stored in S3 and indexed in DynamoDB
- A browser comparison is also run via Puppeteer against the deployed demo app
- The comparison dashboard at
packages/tr-comparison/srcdisplays all results
Stored S3 artefacts
For each placement comparison:
comparisons/{designUrlHash}/{timestamp}/{placementId}/
ce.png # Config Engine render
tr.png # Text Renderer render
diff.png # Pixel difference heatmap
state.json # LoadedTextPlacement (CE state passed to the adapter)
renderer-options.json # GetRenderedTextOptions (mapped TR input)Platform bundle suites
The package builds a separate bundle per platform — browser, worker and node —
each resolving a different variant of @unmade/platform:
| | DOMPoint | DOMMatrix | DOMParser |
|---|---|---|---|
| node | polyfill | @thednp polyfill | linkedom |
| worker | native | @thednp polyfill | linkedom/worker |
| browser | native | native | native |
__tests__/platform/ tests those built bundles, each in the host it ships into:
the node bundle in the test process, the browser bundle on a page, and the worker
bundle inside a real DedicatedWorkerGlobalScope. Nothing else runs them — the
unit suite tests src under node, where neither DOMPoint nor DOMMatrix
exists, so it can only ever exercise the polyfills.
npm run test:platformIt builds first, because it loads dist and a stale bundle would otherwise give
a green run against code that is no longer there. It needs a browser:
npx playwright install chromium.
The two suites
render.test.ts draws every scenario in scenarios.ts on all three bundles,
through both public render entry points — getRenderedText, which loads its own
font, and renderText, which takes one already loaded. Each render is rasterised
here, by one resvg call for all three, and checked twice:
- pixel-identical to the node bundle's, with no tolerance. All three go through the same rasteriser in the same process, so nothing legitimate can move a pixel. This is the assertion that the differing geometry and DOM implementations do not reach the artwork.
- within 1% of a committed reference in
platform/references/, so a change that shifts all three together is caught as well.
api.test.ts covers the rest of the public API, which nothing else would
call on the browser or worker bundles: constants, baseline generation and
validation, measurement, font loading and the font cache. Each is called with
identical inputs on all three bundles and the results compared. It also asserts
the three export the same names, that every export has a probe, and that each
bundle ran in the host it is built for — a typeof DOMPoint check cannot tell a
page from a worker, since both have the native globals.
The report
Every run writes __tests__/platform-output/report.html: one self-contained file
with each scenario's committed reference beside what each bundle drew, badged
with both comparisons, plus the diff images and the browser's user agent. It is
gitignored and collected as a CI artifact from platform-test whether the run
passed or failed, so a by-eye check is always available. The SVG each bundle
produced is written alongside it, so a difference the report shows as pixels can
be diffed as text.
Fonts
Served from the test process, so every platform fetches byte-identical bytes from the same URL and the font is never a variable. Three, chosen for what they exercise:
- Bangers — irregular display face: curves, diagonals, varying stroke widths. Caps only.
- Lora — high-contrast serif with true lowercase, so descenders feed the baseline and line-height calculation.
- giants-color — glyphs stored as SVG documents, the only path that goes
through a
DOMParserand back out through anXMLSerializer.
The two added faces are OFL, with their licences beside them in
__tests__/fixtures/.
Updating the references
UPDATE_FIXTURES=true npm run test:platformRewrites every reference from the node bundle's render, then still checks the other two against what it just stored. Review the diffs in the report before committing.
Regression tests
Regression tests live in __tests__/regression.test.ts and use fixture pairs in __tests__/fixtures/regression/:
| File | Contents |
|------|----------|
| {name}.json | Fixture metadata + LoadedTextPlacement |
| {name}.png | Reference PNG (expected TR render) |
The test pipeline for each fixture:
LoadedTextPlacement
→ mapStateToRendererOptions()
→ getRenderedText()
→ SVG → PNG (resvg-js)
→ pixelmatch diff against {name}.png
→ fail if diff > 1%Adding a fixture
- Run a comparison in the dashboard against a design URL
- Select the record and click Save as fixture in the detail panel
- Enter a descriptive name (e.g.
curved-jersey-40mm) — two files download - Place both files in
__tests__/fixtures/regression/ - Run
npm testto confirm the fixture passes, then commit
Updating fixtures after an intentional change
UPDATE_FIXTURES=true npm testThis re-renders all fixtures and overwrites the stored PNGs. Review the diffs, then commit.
Regression tests require network access to fetch fonts from their CDN URLs.
