Add typography roles, text primitives, fontkit measurer and layout lint

Txt always sets an absolute line height; RenderTxt never does. Row pins
every cell and stacks when the fill column would be too narrow. IdText and
FitText shrink then chunk or truncate. The fontkit measurer matches react-pdf
text widths exactly. A TypeScript-AST lint test enforces the layout rules
(no lineHeight outside Txt, no non-literal fixed/wrap/minPresenceAhead,
flexGrow needs flexBasis, no stray absolute positioning).
This commit is contained in:
2026-10-04 04:48:19 +05:30
parent 92ba3d9590
commit 40aa3e05d9
22 changed files with 1375 additions and 34 deletions
+80
View File
@@ -0,0 +1,80 @@
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<TxtProps, "children" | "role" | "maxLines" | "width"> {
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 (
<Txt {...rest} role={sized} width={width} maxLines={info.fits ? undefined : 1}>
{text}
</Txt>
);
}