sdxc

[ Content & feeds ]

Set text as SVG paths

Measure text with a font's real metrics on the server, shorten it to fit a width, and draw it as SVG path outlines that look the same wherever the image is shown, for a README badge that needs no web font.

Last updated 2026-10-15

A badge in a README, such as a project's latest version, is an SVG image the README links to. An image loads no web fonts, so an SVG <text> element is drawn in whatever font the viewer's machine picks, and a badge sized for one font clips or gaps in another. Measuring the text with the font's own metrics and drawing each glyph as a path gives a badge that is the same width and shape on every screen.

@sdxc/font reads a TrueType font in plain TypeScript: its metrics, its kerning and its glyph outlines. This guide uses it to size a two-part badge, shorten a long value to fit, and write the text as SVG path data, served from a route.

npm add @sdxc/font @sdxc/http @sdxc/result remix

Load the font

parseFont takes the bytes of a .ttf with TrueType (glyf) outlines. In a Worker, bundle the file as a data module by naming it with a .bin extension, for example resources/fonts/inter-regular.ttf.bin, and declare the module shape for TypeScript, as Draw social card images shows. Parse it on the first badge and keep it for the isolate:

app/initializers/badge-font.ts
import type { Font, FontError } from "@sdxc/font";
import type { Result } from "@sdxc/result";

import { parseFont } from "@sdxc/font";

import INTER_REGULAR from "~/resources/fonts/inter-regular.ttf.bin";

let parsed: Result<Font, FontError> | undefined;

export function badgeFont(): Result<Font, FontError> {
	parsed ??= parseFont(INTER_REGULAR);
	return parsed;
}

Opening a font reads only its table headers, and each glyph is decoded the first time it is used and cached on the Font, so the cost of a font grows with the characters you draw. A failure is a FontError whose code is unsupported for a CFF-based .otf, a font collection, or a font with no Unicode character map, and missing-table or malformed for a damaged file.

Measure in pixels

measure(text, size) is the width a line takes at size pixels per em, with the font's pair kerning applied. The font's vertical metrics, ascender, descender (negative) and lineGap, are in font units; dividing by unitsPerEm and multiplying by the size converts them to pixels.

That is enough to shorten text to a width. Drop characters from the end until the text plus an ellipsis fits:

app/services/badges/fit-width.ts
import type { Font } from "@sdxc/font";

export function fitWidth(
	font: Font,
	text: string,
	size: number,
	maxWidth: number,
): string {
	if (font.measure(text, size) <= maxWidth) return text;
	let characters = [...text];
	while (characters.length > 0) {
		characters.pop();
		let candidate = `${characters.join("").trimEnd()}…`;
		if (font.measure(candidate, size) <= maxWidth) return candidate;
	}
	return "…";
}

Spreading the string iterates by code point, the unit layout works in, so an astral character is never split in half. Layout maps one code point to one glyph, with kerning and no ligatures, which suits Latin, Greek and Cyrillic labels; a script that needs shaping, such as Arabic or Devanagari, calls for a shaping engine.

glyphIndex(codePoint) answers 0, the font's .notdef box, for a character the font does not have. Checking it before drawing tells you whether a second font is needed:

app/services/badges/covers.ts
import type { Font } from "@sdxc/font";

export function covers(font: Font, text: string): boolean {
	for (let character of text) {
		if (font.glyphIndex(character.codePointAt(0) ?? 0) === 0) return false;
	}
	return true;
}

Turn glyphs into path data

layout(text, size) places each glyph on a line, answering its glyph index and its x from the line's start. path(glyph, size, x, y) answers that glyph's outline in pixels, y down, with (x, y) on the baseline, as move, line, quadratic quad and close commands. Each one maps to an SVG path command:

app/services/badges/text-path.ts
import type { Font } from "@sdxc/font";

export function textPath(
	font: Font,
	text: string,
	size: number,
	x: number,
	baseline: number,
) {
	let parts: string[] = [];
	for (let placed of font.layout(text, size).glyphs) {
		for (let command of font.path(placed.glyph, size, x + placed.x, baseline)) {
			if (command.type === "move")
				parts.push(`M${round(command.x)} ${round(command.y)}`);
			else if (command.type === "line")
				parts.push(`L${round(command.x)} ${round(command.y)}`);
			else if (command.type === "quad") {
				parts.push(
					`Q${round(command.cx)} ${round(command.cy)} ${round(command.x)} ${round(command.y)}`,
				);
			} else parts.push("Z");
		}
	}
	return parts.join("");
}

function round(value: number): number {
	return Math.round(value * 100) / 100;
}

Two decimals is a hundredth of a pixel, finer than any screen shows, and it keeps the path data short. Composite glyphs, such as an accented letter built from a base and a mark, arrive already resolved into one outline.

outline(glyph) answers the same commands in font units with y up, for a caller that scales and flips them itself, such as one writing a viewBox in font units.

Lay out the badge

A badge is a grey label and a coloured value, each padded on both sides. Both widths come from measure, and the baseline centres the font's ascender-to-descender box in the badge's height:

app/services/badges/badge-layout.ts
import type { Font } from "@sdxc/font";

import { fitWidth } from "~/app/services/badges/fit-width";
import { textPath } from "~/app/services/badges/text-path";

const SIZE = 11;
const HEIGHT = 20;
const PADDING = 6;
const MAX_VALUE_WIDTH = 160;

export interface BadgeLayout {
	width: number;
	height: number;
	labelWidth: number;
	value: string;
	labelPath: string;
	valuePath: string;
}

export function badgeLayout(font: Font, label: string, value: string): BadgeLayout {
	let shown = fitWidth(font, value, SIZE, MAX_VALUE_WIDTH);
	let labelWidth = Math.ceil(font.measure(label, SIZE)) + PADDING * 2;
	let valueWidth = Math.ceil(font.measure(shown, SIZE)) + PADDING * 2;
	let scale = SIZE / font.unitsPerEm;
	let baseline = HEIGHT / 2 + ((font.ascender + font.descender) / 2) * scale;

	return {
		width: labelWidth + valueWidth,
		height: HEIGHT,
		labelWidth,
		value: shown,
		labelPath: textPath(font, label, SIZE, PADDING, baseline),
		valuePath: textPath(font, shown, SIZE, labelWidth + PADDING, baseline),
	};
}

Rounding each half's width up to a whole pixel keeps the edge between the two colours on a pixel boundary, where it stays sharp.

Serve it from a route

Render the SVG with remix/component's renderToString and answer it with the SVG content type. routes.badge is a route such as /badges/:name.svg, and Releases is your own model:

app/http/controllers/badge.tsx
import { SVG } from "@sdxc/http/content-type";
import { internalServerError, notFound } from "@sdxc/http/response/html";
import { isFailure } from "@sdxc/result";
import { renderToString } from "remix/component/server";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import Releases from "~/app/models/release";
import { badgeFont } from "~/app/initializers/badge-font";
import { badgeLayout } from "~/app/services/badges/badge-layout";
import routes from "~/routes/web";

export default createAction(routes.badge, async (ctx) => {
	let { name } = s.parse(s.object({ name: s.string() }), ctx.params);
	let release = await Releases.latest(ctx.db, name);
	if (release === null) return notFound("Not Found");

	let font = badgeFont();
	if (isFailure(font)) {
		ctx.log.fail(font.error, { table: font.error.table ?? null });
		return internalServerError("The badge font could not be read.");
	}

	let badge = badgeLayout(font.data, "version", release.version);
	let label = `version: ${badge.value}`;
	let svg = await renderToString(
		<svg
			xmlns="http://www.w3.org/2000/svg"
			width={badge.width}
			height={badge.height}
			viewBox={`0 0 ${badge.width} ${badge.height}`}
			role="img"
			aria-label={label}
		>
			<title>{label}</title>
			<rect width={badge.labelWidth} height={badge.height} fill="#52525b" />
			<rect
				x={badge.labelWidth}
				width={badge.width - badge.labelWidth}
				height={badge.height}
				fill="#4f46e5"
			/>
			<path d={badge.labelPath} fill="#fff" />
			<path d={badge.valuePath} fill="#fff" />
		</svg>,
	);

	return new Response(svg, {
		headers: { "Content-Type": SVG, "Cache-Control": "public, max-age=300" },
	});
});

<title> and aria-label carry the text a screen reader announces, since the paths themselves are only shapes. The width and height attributes give the image its size in a README, which sizes images from their intrinsic dimensions. Image proxies such as GitHub's cache the badge per URL, so five minutes is roughly how long a new release takes to show.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.