feat(core): add fitTextFontSize utility for pixel-accurate text measurement (#152)

Add @chenglou/pretext dependency and fitTextFontSize() utility that uses
canvas measureText to compute the largest font size that fits text within
a given width. Replaces character-count heuristics with actual font-aware
measurement.

- New fitTextFontSize() in @hyperframes/core/text, exposed on window.__hyperframes
- Generalized for all text elements (captions, titles, etc.), not just captions
- Unit tests (mocked pretext) + browser integration test (real Chromium canvas)
- Updated captions skill docs with usage, exit guarantee, and self-lint patterns

Co-authored-by: James <james.russo@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-03-30 20:14:13 -07:00
committed by GitHub
co-authored by James Claude Opus 4.6
parent dac304ed9f
commit ef6225da1d
12 changed files with 781 additions and 32 deletions
+4
View File
@@ -154,3 +154,7 @@ export type {
export type { FrameAdapter, FrameAdapterContext } from "./adapters/types";
export type { GSAPTimelineLike, CreateGSAPFrameAdapterOptions } from "./adapters/gsap";
export { createGSAPFrameAdapter } from "./adapters/gsap";
// Text measurement
export { fitTextFontSize } from "./text/index.js";
export type { FitTextOptions, FitTextResult } from "./text/index.js";
+10
View File
@@ -1,13 +1,23 @@
import { initSandboxRuntimeModular } from "./init";
import { fitTextFontSize } from "../text/fitTextFontSize";
type HyperframeWindow = Window & {
__hyperframeRuntimeBootstrapped?: boolean;
__hyperframes?: {
fitTextFontSize: typeof fitTextFontSize;
};
};
// Inline composition scripts can run before DOMContentLoaded.
// Ensure timeline registry exists at script evaluation time.
(window as HyperframeWindow).__timelines = (window as HyperframeWindow).__timelines || {};
// Expose text utilities immediately so composition scripts can use them
// before DOMContentLoaded (font sizing runs during script evaluation).
(window as HyperframeWindow).__hyperframes = {
fitTextFontSize,
};
function bootstrapHyperframeRuntime(): void {
const win = window as HyperframeWindow;
if (win.__hyperframeRuntimeBootstrapped) {
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock @chenglou/pretext since jsdom lacks real canvas measureText accuracy.
vi.mock("@chenglou/pretext", () => ({
prepare: vi.fn((_text: string, _font: string) => ({ __mock: true, font: _font })),
layout: vi.fn(),
}));
import { fitTextFontSize } from "./fitTextFontSize.js";
import { prepare, layout } from "@chenglou/pretext";
const mockLayout = vi.mocked(layout);
const mockPrepare = vi.mocked(prepare);
describe("fitTextFontSize", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns base font size when text fits at base size", () => {
mockLayout.mockReturnValue({ height: 90, lineCount: 1 });
const result = fitTextFontSize("short text");
expect(result).toEqual({ fontSize: 78, fits: true });
expect(mockPrepare).toHaveBeenCalledTimes(1);
expect(mockLayout).toHaveBeenCalledTimes(1);
});
it("shrinks font size when text wraps at base size", () => {
mockLayout
.mockReturnValueOnce({ height: 180, lineCount: 2 })
.mockReturnValueOnce({ height: 180, lineCount: 2 })
.mockReturnValueOnce({ height: 90, lineCount: 1 });
const result = fitTextFontSize("this is a much wider piece of text");
expect(result).toEqual({ fontSize: 74, fits: true });
expect(mockPrepare).toHaveBeenCalledTimes(3);
});
it("returns minFontSize with fits: false when text never fits", () => {
mockLayout.mockReturnValue({ height: 180, lineCount: 2 });
const result = fitTextFontSize("WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW");
expect(result).toEqual({ fontSize: 42, fits: false });
expect(mockPrepare).toHaveBeenCalledTimes(19); // (78 - 42) / 2 + 1
});
it("respects custom options", () => {
mockLayout.mockReturnValue({ height: 60, lineCount: 1 });
const result = fitTextFontSize("hello", {
baseFontSize: 60,
minFontSize: 30,
fontWeight: 700,
fontFamily: "Inter",
maxWidth: 800,
step: 4,
});
expect(result).toEqual({ fontSize: 60, fits: true });
expect(mockPrepare).toHaveBeenCalledWith("hello", "700 60px Inter");
expect(mockLayout).toHaveBeenCalledWith(expect.anything(), 800, 72);
});
it("passes correct font string to prepare for each size step", () => {
mockLayout
.mockReturnValueOnce({ height: 180, lineCount: 2 })
.mockReturnValueOnce({ height: 90, lineCount: 1 });
fitTextFontSize("test", {
baseFontSize: 80,
step: 10,
fontWeight: 900,
fontFamily: "Outfit",
});
expect(mockPrepare).toHaveBeenNthCalledWith(1, "test", "900 80px Outfit");
expect(mockPrepare).toHaveBeenNthCalledWith(2, "test", "900 70px Outfit");
});
});
+48
View File
@@ -0,0 +1,48 @@
import { prepare, layout } from "@chenglou/pretext";
export type FitTextOptions = {
/** Container width in px */
maxWidth: number;
/** Starting font size in px */
baseFontSize: number;
/** Floor font size in px */
minFontSize: number;
/** CSS font-weight */
fontWeight: number;
/** CSS font-family */
fontFamily: string;
/** Decrement step in px */
step: number;
};
export type FitTextResult = {
/** The computed font size that fits */
fontSize: number;
/** True if text fits at >= minFontSize */
fits: boolean;
};
const DEFAULTS: FitTextOptions = {
maxWidth: 1600,
baseFontSize: 78,
minFontSize: 42,
fontWeight: 900,
fontFamily: "Outfit",
step: 2,
};
export function fitTextFontSize(text: string, options?: Partial<FitTextOptions>): FitTextResult {
const opts = { ...DEFAULTS, ...options };
const lineHeightRatio = 1.2;
for (let size = opts.baseFontSize; size >= opts.minFontSize; size -= opts.step) {
const font = `${opts.fontWeight} ${size}px ${opts.fontFamily}`;
const prepared = prepare(text, font);
const { lineCount } = layout(prepared, opts.maxWidth, size * lineHeightRatio);
if (lineCount <= 1) {
return { fontSize: size, fits: true };
}
}
return { fontSize: opts.minFontSize, fits: false };
}
+2
View File
@@ -0,0 +1,2 @@
export { fitTextFontSize } from "./fitTextFontSize.js";
export type { FitTextOptions, FitTextResult } from "./fitTextFontSize.js";