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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user