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 bf4b85563b
commit 3818b1eeed
22 changed files with 1375 additions and 34 deletions
+64
View File
@@ -0,0 +1,64 @@
import { View } from "@react-pdf/renderer";
import { getMeasurer, measureToken } from "../engine/measure";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import { chunkIdentifier } from "../model/text";
import { useAvailableWidth } from "./AvailableWidth";
import { fitSize, type FitInfo } from "./FitText";
import { Txt, type TxtProps } from "./Txt";
export interface IdTextInfo extends FitInfo {
mode: "single" | "chunked";
chunks: string[];
}
export interface IdTextProps extends Omit<TxtProps, "children" | "role" | "maxLines" | "width" | "grow"> {
role: RoleName | TypeToken;
children: string;
/** Width to fit; defaults to the available width of the enclosing cell. */
width?: number;
minSize?: number;
/** For tests and audits only. */
onFit?: (info: IdTextInfo) => void;
}
/**
* Text for identifiers (e-mail, URL, GSTIN, phone). textkit never breaks inside a word, so a long
* value would overflow silently. This shrinks it to the role's minimum size; if it is still too wide
* it is split at "@ . / - _" into a flex-wrap row of adjacent Text chunks (no gaps, so extraction
* joins them back into the original string).
*/
export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTextProps) {
const avail = useAvailableWidth();
const target = width ?? avail;
const token = roleToken(role);
const fit = fitSize(token, children, target, minSize, { weight: rest.weight, italic: rest.italic });
if (fit.fits) {
onFit?.({ ...fit, mode: "single", chunks: [children] });
return <Txt {...rest} role={{ ...token, size: fit.size }}>{children}</Txt>;
}
const sized: TypeToken = { ...token, size: fit.size };
const m = getMeasurer();
const family = resolveFace(token.face, rest.faceOverrides);
const widthOf = (s: string) =>
measureToken(m, sized, s, { weight: rest.weight, italic: rest.italic, family });
let chunks = chunkIdentifier(children, 1);
for (let max = children.length; max >= 1; max--) {
const candidate = chunkIdentifier(children, max);
if (candidate.every((c) => widthOf(c) <= target + 1e-6)) {
chunks = candidate;
break;
}
}
onFit?.({ ...fit, mode: "chunked", chunks });
return (
<View style={{ flexDirection: "row", flexWrap: "wrap", width: target, minWidth: target, maxWidth: target } as never}>
{chunks.map((c, i) => (
<Txt key={i} {...rest} align={undefined} role={sized}>
{c}
</Txt>
))}
</View>
);
}