import { getMeasurer, measureToken } from "../engine/measure"; import { minSizeOf, resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles"; import { Txt, type TxtProps } from "./Txt"; export interface FitInfo { size: number; fits: boolean; width: number; } export interface FitTextProps extends Omit { role: RoleName | TypeToken; /** The width the single line must fit, pt. */ width: number; /** Smallest size to shrink to; defaults to the role's minimum size. */ minSize?: number; children: string; /** For tests and audits only. */ onFit?: (info: FitInfo) => void; } const STEP = 0.25; /** Size at which `text` fits `width` on one line, shrinking from the role size in 0.25 pt steps. */ export function fitSize( role: RoleName | TypeToken, text: string, width: number, minSize?: number, opts: { weight?: number; italic?: boolean; family?: string } = {}, ): FitInfo { const token = roleToken(role); const m = getMeasurer(); const floor = Math.min(token.size, minSize ?? minSizeOf(token)); const family = opts.family ?? resolveFace(token.face); let size = token.size; for (;;) { const w = measureToken(m, token, text, { ...opts, size, family }); if (w <= width + 1e-6) return { size, fits: true, width: w }; if (size <= floor + 1e-9) return { size, fits: false, width: w }; size = Math.max(floor, Math.round((size - STEP) * 100) / 100); } } /** The longest prefix of `text` that, followed by an ellipsis, fits `width` at `size`. */ function truncateToFit(token: TypeToken, text: string, width: number, size: number, opts: { weight?: number; italic?: boolean; family: string }): string { const m = getMeasurer(); const chars = Array.from(text); const widthOf = (n: number) => measureToken(m, token, chars.slice(0, n).join("").trimEnd() + "…", { ...opts, size }); let lo = 0; let hi = chars.length; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); if (widthOf(mid) <= width + 1e-6) lo = mid; else hi = mid - 1; } return chars.slice(0, lo).join("").trimEnd() + "…"; } /** * Single-line text that shrinks to fit. If it still does not fit at `minSize` it is cut to the longest * prefix plus an ellipsis at that size. This is done here and not with maxLines: textkit never breaks * inside a word, so a single over-long token stays on its one line, overflows the box and gets no * ellipsis (verified: a 60-character e-mail in a 150 pt box ran to 257 pt). maxLines stays on as a * backstop for multi-word text. */ export function FitText({ role, width, minSize, children, onFit, ...rest }: FitTextProps) { const token = roleToken(role); const family = resolveFace(token.face, rest.faceOverrides); const info = fitSize(token, children, width, minSize, { weight: rest.weight, italic: rest.italic, family }); onFit?.(info); // Leading stays the role's: only the glyph size shrinks. const sized: TypeToken = { ...token, size: info.size }; const text = info.fits ? children : truncateToFit(token, children, width, info.size, { weight: rest.weight, italic: rest.italic, family }); return ( {text} ); }