mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
James
Claude Opus 4.6
parent
dac304ed9f
commit
ef6225da1d
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { fitTextFontSize } from "./fitTextFontSize.js";
|
||||
export type { FitTextOptions, FitTextResult } from "./fitTextFontSize.js";
|
||||
Reference in New Issue
Block a user