npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 Worker

ESM-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 removed

Whole 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 order

It 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:

  • physicalHeight measures the glyphs actually present, so it grows with descenders, accents and punctuation
  • physicalCapHeight is 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

  1. A design URL is POSTed to the comparison Lambda
  2. The Lambda loads the design via the CE factory, extracts all text placements, and renders each one with both CE and TR
  3. Both outputs are rasterised to PNG and diffed pixel-by-pixel with pixelmatch
  4. Results (match score, images, state) are stored in S3 and indexed in DynamoDB
  5. A browser comparison is also run via Puppeteer against the deployed demo app
  6. The comparison dashboard at packages/tr-comparison/src displays 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:platform

It 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 DOMParser and back out through an XMLSerializer.

The two added faces are OFL, with their licences beside them in __tests__/fixtures/.

Updating the references

UPDATE_FIXTURES=true npm run test:platform

Rewrites 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

  1. Run a comparison in the dashboard against a design URL
  2. Select the record and click Save as fixture in the detail panel
  3. Enter a descriptive name (e.g. curved-jersey-40mm) — two files download
  4. Place both files in __tests__/fixtures/regression/
  5. Run npm test to confirm the fixture passes, then commit

Updating fixtures after an intentional change

UPDATE_FIXTURES=true npm test

This 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.