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:
@@ -4,7 +4,8 @@ import path from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { InvoiceDocument, type InvoicePdfProps } from "./InvoiceDocument";
|
||||
import { registerPdfFonts } from "./fonts";
|
||||
import { describeBox, findOverlaps, renderPdfText, type RenderedPdf, type TextBox } from "./testing/pdfText";
|
||||
import { assertNoOverlap } from "./testing/asserts";
|
||||
import { describeBox, renderPdfText, type RenderedPdf, type TextBox } from "./testing/pdfText";
|
||||
import type { BankAccount, InvoiceItem, Settings } from "../lib/types";
|
||||
|
||||
registerPdfFonts(path.resolve(process.cwd(), "public/fonts"));
|
||||
@@ -146,12 +147,10 @@ const textOf = (items: TextBox[]) => items.map((t) => t.str).join(" ");
|
||||
function checkCommon(pdf: RenderedPdf) {
|
||||
const n = pdf.pages.length;
|
||||
expect(pdf.raw).not.toContain("/BaseFont /Helvetica");
|
||||
assertNoOverlap(pdf.pages);
|
||||
|
||||
pdf.pages.forEach((page, idx) => {
|
||||
const k = idx + 1;
|
||||
const overlaps = findOverlaps(page).map(([a, b]) => `${describeBox(a)} x ${describeBox(b)}`);
|
||||
expect(overlaps, `page ${k} overlaps`).toEqual([]);
|
||||
|
||||
for (const t of page.items) {
|
||||
const isFooter = t.top > 780;
|
||||
expect(t.x0, `page ${k} ${describeBox(t)} left`).toBeGreaterThanOrEqual(MARGIN_L);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
const Ctx = createContext<number | null>(null);
|
||||
|
||||
/** Publishes the content width in pt of the box its children are laid out in. */
|
||||
export function AvailableWidthProvider({ width, children }: { width: number; children?: ReactNode }) {
|
||||
return <Ctx.Provider value={width}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
/** Content width (box width minus its padding and border) available to the calling component. */
|
||||
export function useAvailableWidth(): number {
|
||||
const w = useContext(Ctx);
|
||||
if (w === null) throw new Error("useAvailableWidth outside an AvailableWidthProvider (the page frame provides the root)");
|
||||
return w;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Text } from "@react-pdf/renderer";
|
||||
import { FILL, pinned } from "../engine/columns";
|
||||
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
|
||||
import { assertNoLineHeight, baseTextStyle, type TxtStyleProps } from "./Txt";
|
||||
|
||||
export interface RenderTxtProps extends TxtStyleProps, PdfFlagsInput {
|
||||
/** Receives { pageNumber, totalPages, subPageNumber, ... } from react-pdf. */
|
||||
render: (info: { pageNumber: number; totalPages: number; subPageNumber: number; subPageTotalPages: number }) => string;
|
||||
role: RoleName | TypeToken;
|
||||
width?: number;
|
||||
grow?: boolean;
|
||||
style?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text drawn from a render prop (page numbers, continuation band). It sets NO lineHeight, and no
|
||||
* ancestor may set one either: react-pdf multiplies a render-prop lineHeight again on every relayout
|
||||
* ('10pt' ends up as 5120 and nothing draws). The box is auto-height; reserve its space from font
|
||||
* metrics, never a fixed height. No maxLines: a clamped render-prop Text draws nothing.
|
||||
* `totalPages` is undefined on the first layout pass, so the render function must cope with that.
|
||||
*/
|
||||
export function RenderTxt({ role, render, color, align, weight, italic, width, grow, style, faceOverrides, fixed, wrap, minPresenceAhead }: RenderTxtProps) {
|
||||
const token = roleToken(role);
|
||||
assertNoLineHeight(style, "RenderTxt");
|
||||
const merged = {
|
||||
...baseTextStyle(token, { color, align, weight, italic, faceOverrides }),
|
||||
...(width !== undefined ? pinned(width) : {}),
|
||||
...(grow ? FILL : {}),
|
||||
...style,
|
||||
};
|
||||
return <Text style={merged as never} render={render as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })} />;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import { Children, type ReactNode } from "react";
|
||||
import { colsDetailed, FILL, pinned, type ColSpec } from "../engine/columns";
|
||||
import { AvailableWidthProvider, useAvailableWidth } from "./AvailableWidth";
|
||||
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
|
||||
|
||||
export interface RowProps extends PdfFlagsInput {
|
||||
/** One spec per child, in order. */
|
||||
widths: ColSpec[];
|
||||
gap?: number;
|
||||
/** Stack the children as a column when the row is narrower than this. */
|
||||
stackBelow?: number;
|
||||
/** Cross-axis alignment of the cells; never flex-end around multi-line text. */
|
||||
align?: "flex-start" | "center" | "stretch";
|
||||
/** Overrides the width from AvailableWidth. */
|
||||
width?: number;
|
||||
style?: Record<string, unknown>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A flex row whose children each sit in a cell of explicit width: fixed and fractional cells are pinned
|
||||
* (width = minWidth = maxWidth) and the fill cell uses {flexGrow: 1, flexBasis: 0}. Descendants read the
|
||||
* cell's content width through useAvailableWidth(). When a column falls under its `min` (or the row is
|
||||
* narrower than `stackBelow`), the children are stacked at full width instead.
|
||||
*/
|
||||
export function Row({ widths, gap = 0, stackBelow, align = "flex-start", width, style, children, fixed, wrap, minPresenceAhead }: RowProps) {
|
||||
const inherited = useAvailableWidth();
|
||||
const total = width ?? inherited;
|
||||
const kids = Children.toArray(children);
|
||||
if (kids.length !== widths.length) throw new Error(`Row: ${kids.length} children for ${widths.length} column specs`);
|
||||
|
||||
const { widths: w, ok } = colsDetailed(total, widths, gap);
|
||||
const stacked = !ok || (stackBelow !== undefined && total < stackBelow);
|
||||
const flags = pdfFlags({ fixed, wrap, minPresenceAhead });
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<View style={{ flexDirection: "column", alignItems: "flex-start", ...style } as never} {...flags}>
|
||||
{kids.map((child, i) => (
|
||||
<View key={i} style={{ ...pinned(total), alignItems: "flex-start", ...(gap && i > 0 ? { marginTop: gap } : {}) } as never}>
|
||||
<AvailableWidthProvider width={total}>{child}</AvailableWidthProvider>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row", alignItems: align, ...style } as never} {...flags}>
|
||||
{kids.map((child, i) => {
|
||||
const isFill = "fill" in widths[i];
|
||||
const box = isFill ? FILL : pinned(w[i]);
|
||||
return (
|
||||
<View
|
||||
key={i}
|
||||
style={{ ...box, alignItems: "flex-start", ...(i > 0 && gap ? { marginLeft: gap } : {}) } as never}
|
||||
>
|
||||
<AvailableWidthProvider width={w[i]}>{child}</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CellProps {
|
||||
/** Padding in pt: one number for all sides, or per side. */
|
||||
padding?: number | { top?: number; right?: number; bottom?: number; left?: number };
|
||||
borderLeft?: number;
|
||||
borderRight?: number;
|
||||
style?: Record<string, unknown>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A padded/bordered box inside a Row column. It fills the column's width and republishes the content
|
||||
* width (column width minus its own padding and border) to its descendants.
|
||||
*/
|
||||
export function Cell({ padding = 0, borderLeft = 0, borderRight = 0, style, children }: CellProps) {
|
||||
const full = useAvailableWidth();
|
||||
const p = typeof padding === "number" ? { top: padding, right: padding, bottom: padding, left: padding } : padding;
|
||||
const inner = Math.max(0, full - (p.left ?? 0) - (p.right ?? 0) - borderLeft - borderRight);
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
{
|
||||
...pinned(full),
|
||||
alignItems: "flex-start",
|
||||
paddingTop: p.top ?? 0,
|
||||
paddingRight: p.right ?? 0,
|
||||
paddingBottom: p.bottom ?? 0,
|
||||
paddingLeft: p.left ?? 0,
|
||||
...(borderLeft ? { borderLeftWidth: borderLeft } : {}),
|
||||
...(borderRight ? { borderRightWidth: borderRight } : {}),
|
||||
...style,
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<AvailableWidthProvider width={inner}>{children}</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { roleToken } from "../fonts/roles";
|
||||
import { Txt, type TxtProps } from "./Txt";
|
||||
|
||||
export interface TrackedProps extends Omit<TxtProps, "align"> {
|
||||
align: "right" | "center";
|
||||
}
|
||||
|
||||
/**
|
||||
* textkit adds letterSpacing after every glyph, the last one included, so right- and centre-aligned
|
||||
* tracked text sits one tracking step too far left. This cancels it with a negative margin: the full
|
||||
* step on the right edge for `right`, half a step each side for `center` (shifting the box right by
|
||||
* half a step).
|
||||
*
|
||||
* HAZARD: the Text box now overhangs its parent by up to one tracking step. Overflow and safe-zone
|
||||
* audits must measure ink (glyph extents), not boxes, or they flag it.
|
||||
*/
|
||||
export function Tracked({ align, style, ...rest }: TrackedProps) {
|
||||
const token = roleToken(rest.role);
|
||||
const step = (token.tracking ?? 0) * token.size;
|
||||
const margin =
|
||||
align === "right"
|
||||
? { marginRight: -step }
|
||||
: { marginLeft: step / 2, marginRight: -step / 2 };
|
||||
return <Txt {...rest} align={align} style={{ ...margin, ...style }} />;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Text } from "@react-pdf/renderer";
|
||||
import type { ReactNode } from "react";
|
||||
import { FILL, pinned } from "../engine/columns";
|
||||
import { stack } from "../fonts/register";
|
||||
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
|
||||
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
|
||||
export type TxtChild = string | number | Array<string | number>;
|
||||
type Align = "left" | "right" | "center" | "justify";
|
||||
|
||||
export interface TxtStyleProps {
|
||||
color?: string;
|
||||
align?: Align;
|
||||
weight?: TypeToken["weight"];
|
||||
italic?: boolean;
|
||||
/** Font family overrides (e.g. a user-imported font replacing a template face). */
|
||||
faceOverrides?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface TxtProps extends TxtStyleProps, PdfFlagsInput {
|
||||
role: RoleName | TypeToken;
|
||||
/** Clamp to this many lines and end with an ellipsis. */
|
||||
maxLines?: number;
|
||||
/** Pin the box to this width in pt (width = minWidth = maxWidth). */
|
||||
width?: number;
|
||||
/** Take the remaining row width ({flexGrow: 1, flexBasis: 0}). */
|
||||
grow?: boolean;
|
||||
/** Extra style. Must not contain lineHeight: leading comes from the role. */
|
||||
style?: Record<string, unknown>;
|
||||
children?: TxtChild;
|
||||
}
|
||||
|
||||
/** Style fields shared by Txt and RenderTxt: everything except lineHeight. */
|
||||
export function baseTextStyle(token: TypeToken, o: TxtStyleProps) {
|
||||
const size = token.size;
|
||||
const weight = o.weight ?? token.weight;
|
||||
return {
|
||||
fontFamily: stack(resolveFace(token.face, o.faceOverrides)),
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
fontStyle: (o.italic ?? token.italic) ? "italic" : "normal",
|
||||
...(token.tracking ? { letterSpacing: token.tracking * size } : {}),
|
||||
...(token.caps ? { textTransform: "uppercase" } : {}),
|
||||
...(o.color ? { color: o.color } : {}),
|
||||
...(o.align ? { textAlign: o.align } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function assertNoLineHeight(style: Record<string, unknown> | undefined, who: string): void {
|
||||
if (style && "lineHeight" in style && process.env.NODE_ENV !== "production") {
|
||||
throw new Error(`${who}: style must not contain lineHeight; the role sets the leading`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static text. Always sets family, size, weight, style and an absolute `lineHeight: "Npt"` on the
|
||||
* same node; never put a lineHeight on an ancestor View.
|
||||
*/
|
||||
export function Txt({ role, color, align, weight, italic, maxLines, width, grow, style, faceOverrides, children, fixed, wrap, minPresenceAhead }: TxtProps) {
|
||||
const token = roleToken(role);
|
||||
if (token.leading === undefined) throw new Error("Txt needs a role with a leading; use RenderTxt for render-prop text");
|
||||
assertNoLineHeight(style, "Txt");
|
||||
const merged = {
|
||||
...baseTextStyle(token, { color, align, weight, italic, faceOverrides }),
|
||||
lineHeight: `${token.leading}pt`,
|
||||
...(width !== undefined ? pinned(width) : {}),
|
||||
...(grow ? FILL : {}),
|
||||
...(maxLines !== undefined ? { maxLines, textOverflow: "ellipsis" } : {}),
|
||||
...style,
|
||||
};
|
||||
return <Text style={merged as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })}>{children as ReactNode}</Text>;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { createElement as h, type ReactElement, type ReactNode } from "react";
|
||||
import { Document, Page, View } from "@react-pdf/renderer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stack } from "../fonts/register";
|
||||
import { TYPE_ROLES } from "../fonts/roles";
|
||||
import { assertNoOverlap } from "../testing/asserts";
|
||||
import { renderPdfText } from "../testing/pdfText";
|
||||
import { setupPdfTest } from "../testing/setup";
|
||||
import { AvailableWidthProvider } from "./AvailableWidth";
|
||||
import { FitText, type FitInfo } from "./FitText";
|
||||
import { IdText, type IdTextInfo } from "./IdText";
|
||||
import { pdfFlags } from "./pdfProps";
|
||||
import { RenderTxt } from "./RenderTxt";
|
||||
import { Cell, Row } from "./Row";
|
||||
import { Tracked } from "./Tracked";
|
||||
import { Txt } from "./Txt";
|
||||
|
||||
setupPdfTest();
|
||||
|
||||
const PAD = 20;
|
||||
const PAGE_W = 400;
|
||||
|
||||
/** A page whose content box is `width` pt wide and published through AvailableWidth. */
|
||||
function frame(children: ReactNode, width = PAGE_W - 2 * PAD): ReactElement {
|
||||
return h(
|
||||
Document,
|
||||
null,
|
||||
h(
|
||||
Page,
|
||||
{ size: { width: PAGE_W, height: 600 }, style: { padding: PAD } },
|
||||
h(AvailableWidthProvider, { width }, children),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type TxtLike = { props: { style: Record<string, unknown> } };
|
||||
const styleOf = (el: unknown) => (el as TxtLike).props.style;
|
||||
|
||||
describe("pdfFlags", () => {
|
||||
it("spreads only meaningful keys", () => {
|
||||
expect(pdfFlags()).toEqual({});
|
||||
expect(pdfFlags({ fixed: false, wrap: undefined, minPresenceAhead: undefined })).toEqual({});
|
||||
expect(pdfFlags({ fixed: true })).toEqual({ fixed: true });
|
||||
expect(pdfFlags({ wrap: true })).toEqual({});
|
||||
expect(pdfFlags({ wrap: false })).toEqual({ wrap: false });
|
||||
expect(pdfFlags({ minPresenceAhead: 0 })).toEqual({});
|
||||
expect(pdfFlags({ minPresenceAhead: -3 })).toEqual({});
|
||||
expect(pdfFlags({ minPresenceAhead: NaN })).toEqual({});
|
||||
expect(pdfFlags({ minPresenceAhead: Infinity })).toEqual({});
|
||||
expect(pdfFlags({ minPresenceAhead: 12.5 })).toEqual({ minPresenceAhead: 12.5 });
|
||||
expect(pdfFlags({ fixed: true, wrap: false, minPresenceAhead: 9 })).toEqual({
|
||||
fixed: true,
|
||||
wrap: false,
|
||||
minPresenceAhead: 9,
|
||||
});
|
||||
// Keys must be absent, not undefined: react-pdf checks presence.
|
||||
expect(Object.keys(pdfFlags({ fixed: false }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("reaches the element only when set", () => {
|
||||
const off = Txt({ role: "body", children: "x", fixed: false }) as unknown as { props: Record<string, unknown> };
|
||||
expect("fixed" in off.props).toBe(false);
|
||||
const on = Txt({ role: "body", children: "x", fixed: true }) as unknown as { props: Record<string, unknown> };
|
||||
expect(on.props.fixed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Txt", () => {
|
||||
it("sets lineHeight in pt, the family stack and the role metrics", () => {
|
||||
const s = styleOf(Txt({ role: "body", children: "x" }));
|
||||
expect(s.lineHeight).toBe("13.8pt");
|
||||
expect(s.fontFamily).toEqual(stack("IBM Plex Sans"));
|
||||
expect(s.fontSize).toBe(9.5);
|
||||
expect(s.fontWeight).toBe(400);
|
||||
expect(s.fontStyle).toBe("normal");
|
||||
expect("letterSpacing" in s).toBe(false);
|
||||
expect("textTransform" in s).toBe(false);
|
||||
});
|
||||
|
||||
it("applies tracking, caps and overrides", () => {
|
||||
const s = styleOf(Txt({ role: "title", weight: 700, italic: true, color: "#111", align: "right", children: "x" }));
|
||||
expect(s.letterSpacing).toBeCloseTo(1.5, 10);
|
||||
expect(s.textTransform).toBe("uppercase");
|
||||
expect(s.fontWeight).toBe(700);
|
||||
expect(s.fontStyle).toBe("italic");
|
||||
expect(s.color).toBe("#111");
|
||||
expect(s.textAlign).toBe("right");
|
||||
expect(s.lineHeight).toBe("22pt");
|
||||
});
|
||||
|
||||
it("pins width, grows and clamps lines", () => {
|
||||
const pinnedStyle = styleOf(Txt({ role: "body", width: 80, children: "x" }));
|
||||
expect([pinnedStyle.width, pinnedStyle.minWidth, pinnedStyle.maxWidth]).toEqual([80, 80, 80]);
|
||||
const grown = styleOf(Txt({ role: "body", grow: true, children: "x" }));
|
||||
expect([grown.flexGrow, grown.flexBasis]).toEqual([1, 0]);
|
||||
const clamped = styleOf(Txt({ role: "body", maxLines: 2, children: "x" }));
|
||||
expect([clamped.maxLines, clamped.textOverflow]).toEqual([2, "ellipsis"]);
|
||||
});
|
||||
|
||||
it("throws on a lineHeight in the extra style and on a role without leading", () => {
|
||||
expect(() => Txt({ role: "body", style: { lineHeight: 2 }, children: "x" })).toThrow(/lineHeight/);
|
||||
expect(() => Txt({ role: "footer", children: "x" })).toThrow(/leading/);
|
||||
});
|
||||
|
||||
it("every leaded role has a positive leading and footer has none", () => {
|
||||
for (const [name, t] of Object.entries(TYPE_ROLES)) {
|
||||
if (name === "footer") expect("leading" in t).toBe(false);
|
||||
else expect((t as { leading: number }).leading).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("RenderTxt", () => {
|
||||
it("never sets lineHeight and rejects one", () => {
|
||||
const el = RenderTxt({ role: "footer", render: () => "x" }) as unknown as {
|
||||
props: { style: Record<string, unknown>; render: unknown };
|
||||
};
|
||||
expect("lineHeight" in el.props.style).toBe(false);
|
||||
expect(typeof el.props.render).toBe("function");
|
||||
expect(() => RenderTxt({ role: "footer", render: () => "x", style: { lineHeight: 1 } })).toThrow(/lineHeight/);
|
||||
});
|
||||
|
||||
it("draws page numbers on every page", async () => {
|
||||
const pdf = await renderPdfText(
|
||||
h(
|
||||
Document,
|
||||
null,
|
||||
h(
|
||||
Page,
|
||||
{ size: { width: 300, height: 120 }, style: { padding: 20 } },
|
||||
h(View, { fixed: true }, h(RenderTxt, { role: "footer", render: ({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages ?? "?"}` })),
|
||||
h(View, { style: { height: 200 } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(pdf.pages.length).toBeGreaterThan(1);
|
||||
pdf.pages.forEach((p, i) => {
|
||||
expect(p.items.map((t) => t.str)).toEqual([`Page ${i + 1} of ${pdf.pages.length}`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Row", () => {
|
||||
const tops = (pdf: Awaited<ReturnType<typeof renderPdfText>>, str: string) =>
|
||||
pdf.pages[0].items.find((t) => t.str.trim() === str)!;
|
||||
|
||||
it("lays out side by side when the fill column keeps its minimum", async () => {
|
||||
const pdf = await renderPdfText(
|
||||
frame(
|
||||
h(Row, { widths: [{ fixed: 100 }, { fill: true, min: 100 }], gap: 10 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
||||
),
|
||||
);
|
||||
const l = tops(pdf, "Left");
|
||||
const r = tops(pdf, "Right");
|
||||
expect(l.x0).toBeCloseTo(PAD, 1);
|
||||
expect(r.x0).toBeCloseTo(PAD + 110, 1);
|
||||
expect(Math.abs(l.top - r.top)).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it("stacks when the fill column would drop under its min", async () => {
|
||||
const pdf = await renderPdfText(
|
||||
frame(
|
||||
h(Row, { widths: [{ fixed: 250 }, { fill: true, min: 160 }], gap: 10 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
||||
),
|
||||
);
|
||||
const l = tops(pdf, "Left");
|
||||
const r = tops(pdf, "Right");
|
||||
expect(r.x0).toBeCloseTo(PAD, 1);
|
||||
expect(r.top).toBeGreaterThan(l.bottom - 0.5);
|
||||
});
|
||||
|
||||
it("stacks below stackBelow", async () => {
|
||||
const pdf = await renderPdfText(
|
||||
frame(
|
||||
h(Row, { widths: [{ fixed: 50 }, { fill: true }], stackBelow: 500 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
||||
),
|
||||
);
|
||||
expect(tops(pdf, "Right").top).toBeGreaterThan(tops(pdf, "Left").bottom - 0.5);
|
||||
});
|
||||
|
||||
it("rejects a children/spec mismatch", async () => {
|
||||
await expect(
|
||||
renderPdfText(frame(<Row widths={[{ fixed: 10 }, { fill: true }]}><Txt role="body">x</Txt></Row>)),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("keeps a 300-character address inside its cell and clear of its neighbours", async () => {
|
||||
const address = Array.from({ length: 30 }, (_, i) => `Block${i} Road`).join(", ").slice(0, 300);
|
||||
expect(address.length).toBe(300);
|
||||
const pdf = await renderPdfText(
|
||||
frame(
|
||||
<Row widths={[{ fill: true, min: 100 }, { fixed: 120 }]} gap={16}>
|
||||
<Cell padding={{ right: 6 }}>
|
||||
<Txt role="body">{address}</Txt>
|
||||
</Cell>
|
||||
<Txt role="body">INVOICE No. 42</Txt>
|
||||
</Row>,
|
||||
),
|
||||
);
|
||||
assertNoOverlap(pdf.pages);
|
||||
const fillWidth = PAGE_W - 2 * PAD - 120 - 16;
|
||||
const cellRight = PAD + fillWidth - 6;
|
||||
const items = pdf.pages[0].items;
|
||||
for (const it of items) {
|
||||
if (!it.str.includes("INVOICE")) expect(it.x1, it.str).toBeLessThanOrEqual(cellRight + 0.5);
|
||||
}
|
||||
expect(items.find((t) => t.str.includes("INVOICE"))!.x0).toBeGreaterThanOrEqual(PAD + fillWidth + 16 - 0.5);
|
||||
expect(items.length).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FitText and IdText", () => {
|
||||
const EMAIL60 = "Sample.longsurname.voiceover.studio.department@examplemedia.co.in".slice(0, 60);
|
||||
|
||||
it("shrinks a medium e-mail to fit and keeps it on one line", async () => {
|
||||
const email = "vendor.name@examplestudios.co.in";
|
||||
let info!: FitInfo;
|
||||
const pdf = await renderPdfText(
|
||||
frame(
|
||||
<FitText role="body" width={215} onFit={(i) => (info = i)}>
|
||||
{email}
|
||||
</FitText>,
|
||||
),
|
||||
);
|
||||
expect(info.fits).toBe(true);
|
||||
expect(info.size).toBeLessThan(9.5);
|
||||
expect(info.size).toBeGreaterThanOrEqual(8.5);
|
||||
const items = pdf.pages[0].items;
|
||||
expect(items.map((t) => t.str).join("")).toBe(email);
|
||||
expect(Math.max(...items.map((t) => t.x1))).toBeLessThanOrEqual(PAD + 215 + 0.5);
|
||||
});
|
||||
|
||||
it("clips with an ellipsis when it cannot fit at the minimum size", async () => {
|
||||
let info!: FitInfo;
|
||||
const pdf = await renderPdfText(
|
||||
frame(
|
||||
<FitText role="body" width={150} onFit={(i) => (info = i)}>
|
||||
{EMAIL60}
|
||||
</FitText>,
|
||||
),
|
||||
);
|
||||
expect(info.fits).toBe(false);
|
||||
expect(info.size).toBeCloseTo(8.55, 5);
|
||||
const items = pdf.pages[0].items;
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].str.endsWith("…")).toBe(true);
|
||||
expect(EMAIL60.startsWith(items[0].str.slice(0, -1))).toBe(true);
|
||||
expect(items[0].x1).toBeLessThanOrEqual(PAD + 150 + 0.5);
|
||||
});
|
||||
|
||||
it("splits a 60-character e-mail into chunks inside a 150 pt cell", async () => {
|
||||
let info!: IdTextInfo;
|
||||
const pdf = await renderPdfText(
|
||||
frame(
|
||||
<Row widths={[{ fixed: 150 }, { fill: true }]}>
|
||||
<IdText role="body" onFit={(i) => (info = i)}>
|
||||
{EMAIL60}
|
||||
</IdText>
|
||||
<Txt role="body">Next</Txt>
|
||||
</Row>,
|
||||
),
|
||||
);
|
||||
expect(info.mode).toBe("chunked");
|
||||
expect(info.chunks.join("")).toBe(EMAIL60);
|
||||
const idItems = pdf.pages[0].items.filter((t) => t.str !== "Next");
|
||||
// Extraction joins the chunks with no inserted characters.
|
||||
expect(idItems.map((t) => t.str).join("")).toBe(EMAIL60);
|
||||
for (const t of idItems) expect(t.x1, t.str).toBeLessThanOrEqual(PAD + 150 + 0.5);
|
||||
assertNoOverlap(pdf.pages);
|
||||
const next = pdf.pages[0].items.find((t) => t.str === "Next")!;
|
||||
expect(next.x0).toBeGreaterThanOrEqual(PAD + 150 - 0.5);
|
||||
});
|
||||
|
||||
it("keeps an identifier on one line when it already fits", async () => {
|
||||
let info!: IdTextInfo;
|
||||
await renderPdfText(
|
||||
frame(
|
||||
<Row widths={[{ fixed: 150 }, { fill: true }]}>
|
||||
<IdText role="body" onFit={(i) => (info = i)}>
|
||||
29ABCDE1234F1Z5
|
||||
</IdText>
|
||||
<Txt role="body">x</Txt>
|
||||
</Row>,
|
||||
),
|
||||
);
|
||||
expect(info.mode).toBe("single");
|
||||
expect(info.size).toBe(9.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tracked", () => {
|
||||
const inkRight = async (el: ReactElement) => {
|
||||
const pdf = await renderPdfText(frame(h(View, { style: { width: 200 } }, el)));
|
||||
return pdf.pages[0].items[0];
|
||||
};
|
||||
|
||||
it("puts the right ink edge on the untracked edge", async () => {
|
||||
const plain = await inkRight(h(Txt, { role: { ...TYPE_ROLES.title, tracking: 0 }, align: "right" }, "Invoice"));
|
||||
const tracked = await inkRight(h(Tracked, { role: "title", align: "right" }, "Invoice"));
|
||||
const naive = await inkRight(h(Txt, { role: "title", align: "right" }, "Invoice"));
|
||||
// pdf.js x1 is the end of the last glyph, without the trailing letter spacing.
|
||||
expect(plain.x1).toBeCloseTo(PAD + 200, 1);
|
||||
expect(Math.abs(tracked.x1 - plain.x1)).toBeLessThanOrEqual(0.5);
|
||||
// Without compensation the ink ends one tracking step early; the test would otherwise prove nothing.
|
||||
expect(plain.x1 - naive.x1).toBeGreaterThan(1.4);
|
||||
});
|
||||
|
||||
it("centres the ink", async () => {
|
||||
const t = await inkRight(h(Tracked, { role: "title", align: "center" }, "Invoice"));
|
||||
expect((t.x0 + t.x1) / 2).toBeCloseTo(PAD + 100, 0);
|
||||
const naive = await inkRight(h(Txt, { role: "title", align: "center" }, "Invoice"));
|
||||
expect(Math.abs((t.x0 + t.x1) / 2 - (PAD + 100))).toBeLessThan(Math.abs((naive.x0 + naive.x1) / 2 - (PAD + 100)) + 1e-6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface PdfFlagsInput {
|
||||
fixed?: boolean;
|
||||
wrap?: boolean;
|
||||
minPresenceAhead?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* react-pdf checks `fixed`, `wrap` and `minPresenceAhead` by key presence: `fixed={false}` still
|
||||
* disables breaking and `minPresenceAhead={undefined}` changes behaviour too. Every primitive spreads
|
||||
* this result instead of passing the props directly (lint enforces it).
|
||||
*/
|
||||
export function pdfFlags({ fixed, wrap, minPresenceAhead }: PdfFlagsInput = {}): {
|
||||
fixed?: true;
|
||||
wrap?: false;
|
||||
minPresenceAhead?: number;
|
||||
} {
|
||||
return {
|
||||
...(fixed ? { fixed: true as const } : {}),
|
||||
...(wrap === false ? { wrap: false as const } : {}),
|
||||
...(typeof minPresenceAhead === "number" && Number.isFinite(minPresenceAhead) && minPresenceAhead > 0
|
||||
? { minPresenceAhead }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
// Minimal typings for the parts of fontkit 2.0.4 the measurer uses (the package ships none).
|
||||
declare module "fontkit" {
|
||||
export interface GlyphPosition {
|
||||
xAdvance: number;
|
||||
}
|
||||
export interface GlyphRun {
|
||||
glyphs: unknown[];
|
||||
positions: GlyphPosition[];
|
||||
}
|
||||
export interface Font {
|
||||
unitsPerEm: number;
|
||||
hasGlyphForCodePoint(codePoint: number): boolean;
|
||||
layout(text: string, features?: unknown, script?: string, language?: string, direction?: string): GlyphRun;
|
||||
}
|
||||
export function create(data: Uint8Array): Font;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createElement as h } from "react";
|
||||
import { Document, Page, Text } from "@react-pdf/renderer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stack } from "../fonts/register";
|
||||
import { renderPdfText } from "../testing/pdfText";
|
||||
import { setupPdfTest } from "../testing/setup";
|
||||
import { chunkIdentifier } from "../model/text";
|
||||
|
||||
const m = setupPdfTest();
|
||||
|
||||
const SAMPLES: string[] = [
|
||||
"Voice-over session 12 - narration for module 3",
|
||||
"Total ₹1,23,456.78",
|
||||
"AVATAR WAVE To Ty Yo",
|
||||
"vendor.name@examplestudios.co.in",
|
||||
"29ABCDE1234F1Z5",
|
||||
"office fi fl ffi final",
|
||||
"BILLED TO",
|
||||
"Amount: ₹5,00,000.00 (Rupees Five Lakh Only) नमस्ते",
|
||||
"नमस्ते दुनिया, यह एक परीक्षण है",
|
||||
"ಕನ್ನಡ ಭಾಷೆಯ ಪರೀಕ್ಷೆ ಇನ್ವಾಯ್ಸ್",
|
||||
"Invoice क्रमांक INV-0042 ಇನ್ವಾಯ್ಸ್",
|
||||
"1234567890 0.00 111 000",
|
||||
];
|
||||
|
||||
interface Case {
|
||||
text: string;
|
||||
size: number;
|
||||
weight: number;
|
||||
italic: boolean;
|
||||
spacing: number;
|
||||
caps: boolean;
|
||||
}
|
||||
|
||||
const isIndic = (t: string) => /[\u0900-\u0DFF]/.test(t);
|
||||
|
||||
const cases: Case[] = [];
|
||||
for (const text of SAMPLES) {
|
||||
for (const size of [7.5, 9.5, 18]) cases.push({ text, size, weight: 400, italic: false, spacing: 0, caps: false });
|
||||
}
|
||||
cases.push(
|
||||
{ text: "Bold semibold text ₹99", size: 9.5, weight: 600, italic: false, spacing: 0, caps: false },
|
||||
{ text: "Italic amount in words only", size: 9.5, weight: 400, italic: true, spacing: 0, caps: false },
|
||||
{ text: "Light weight 300", size: 13, weight: 300, italic: false, spacing: 0, caps: false },
|
||||
{ text: "Heavy 800 falls back to bold", size: 13, weight: 800, italic: false, spacing: 0, caps: false },
|
||||
{ text: "tax invoice", size: 18, weight: 600, italic: false, spacing: 1.5, caps: true },
|
||||
{ text: "description", size: 7.5, weight: 400, italic: false, spacing: 0.8, caps: true },
|
||||
{ text: "billed to नमस्ते", size: 7.5, weight: 400, italic: false, spacing: 1, caps: true },
|
||||
);
|
||||
|
||||
describe("measurer parity with react-pdf", () => {
|
||||
it("matches the width pdf.js reads from rendered text", async () => {
|
||||
const doc = h(
|
||||
Document,
|
||||
null,
|
||||
...cases.map((c, i) =>
|
||||
h(
|
||||
Page,
|
||||
{ key: i, size: { width: 900, height: 80 }, style: { padding: 20 } },
|
||||
h(
|
||||
Text,
|
||||
{
|
||||
style: {
|
||||
fontFamily: stack("IBM Plex Sans"),
|
||||
fontSize: c.size,
|
||||
fontWeight: c.weight,
|
||||
fontStyle: c.italic ? "italic" : "normal",
|
||||
letterSpacing: c.spacing,
|
||||
...(c.caps ? { textTransform: "uppercase" } : {}),
|
||||
} as never,
|
||||
},
|
||||
// pdf.js sizes shaped Indic items from raw glyph widths (it reports 12.9 pt for a run that
|
||||
// advances 15.8 pt), so those cases end in a Latin "I" and the width is read from where
|
||||
// that glyph starts instead.
|
||||
isIndic(c.text) ? `${c.text}I` : c.text,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const pdf = await renderPdfText(doc);
|
||||
expect(pdf.pages.length).toBe(cases.length);
|
||||
|
||||
let maxDev = 0;
|
||||
let worst = "";
|
||||
pdf.pages.forEach((page, i) => {
|
||||
const c = cases[i];
|
||||
const x0 = Math.min(...page.items.map((t) => t.x0));
|
||||
// pdf.js leaves the trailing letter spacing of the last glyph out of an item's width.
|
||||
let x1 = Math.max(...page.items.map((t) => t.x1)) + c.spacing;
|
||||
if (isIndic(c.text)) {
|
||||
const last = page.items[page.items.length - 1];
|
||||
expect(last.str, `${c.text}: sentinel item`).toBe("I");
|
||||
x1 = last.x0;
|
||||
}
|
||||
const measured = m.measureWidth({
|
||||
text: c.caps ? c.text.toUpperCase() : c.text,
|
||||
face: "IBM Plex Sans",
|
||||
weight: c.weight,
|
||||
italic: c.italic,
|
||||
size: c.size,
|
||||
letterSpacing: c.spacing,
|
||||
});
|
||||
const dev = Math.abs(x1 - x0 - measured);
|
||||
if (dev > maxDev) {
|
||||
maxDev = dev;
|
||||
worst = `${c.text} @${c.size} (pdf ${(x1 - x0).toFixed(3)} vs ${measured.toFixed(3)})`;
|
||||
}
|
||||
expect(dev, `${c.text} @${c.size} w${c.weight}: pdf ${(x1 - x0).toFixed(3)} measured ${measured.toFixed(3)}`).toBeLessThan(0.05);
|
||||
});
|
||||
console.log(`measurer parity: ${cases.length} cases, max deviation ${maxDev.toFixed(4)} pt (${worst})`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("measurer helpers", () => {
|
||||
it("reports uncovered code points", () => {
|
||||
expect(m.missing("Hello ₹ नमस्ते ಕನ್ನಡ")).toEqual([]);
|
||||
expect(m.missing("A 中 B")).toEqual([0x4e2d]);
|
||||
});
|
||||
|
||||
it("wraps greedily at spaces and hyphens", () => {
|
||||
const input = { face: "IBM Plex Sans", weight: 400, size: 9.5 };
|
||||
const text = "one two three four five six seven eight nine ten";
|
||||
const full = m.measureWidth({ ...input, text });
|
||||
const lines = m.wrapLines(text, full * 0.6, input);
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines.join(" ")).toBe(text);
|
||||
for (const l of lines) expect(m.measureWidth({ ...input, text: l })).toBeLessThanOrEqual(full * 0.6);
|
||||
expect(m.wrapLines("abcdefghijkl", 5, input)).toEqual(["abcdefghijkl"]);
|
||||
expect(m.wrapLines("", 100, input)).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips chunkIdentifier output", () => {
|
||||
const id = "vendor.name@examplestudios.co.in";
|
||||
expect(chunkIdentifier(id, 12).join("")).toBe(id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { create, type Font } from "fontkit";
|
||||
import { FONT_MANIFEST } from "../fonts/manifest";
|
||||
import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack";
|
||||
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
|
||||
export interface MeasureInput {
|
||||
text: string;
|
||||
/** A registered family name (after resolveFace). */
|
||||
face: string;
|
||||
weight: number;
|
||||
italic?: boolean;
|
||||
size: number;
|
||||
/** pt added after every glyph, including the last one (textkit does the same). */
|
||||
letterSpacing?: number;
|
||||
}
|
||||
|
||||
export type WrapInput = Omit<MeasureInput, "text">;
|
||||
|
||||
export interface Measurer {
|
||||
/** Advance width of a single line in pt, with kerning and font fallback as textkit applies them. */
|
||||
measureWidth(input: MeasureInput): number;
|
||||
/**
|
||||
* Greedy word-wrap that predicts textkit's line breaks. Estimate for planning only: textkit's
|
||||
* Knuth-Plass breaker lets spaces shrink on an overfull line, so it can fit a word this routine
|
||||
* pushes to the next line (and the other way round for very narrow boxes).
|
||||
*/
|
||||
wrapLines(text: string, width: number, input: WrapInput): string[];
|
||||
/** Code points that no family in the stack covers (they would render as .notdef). */
|
||||
missing(text: string, face?: string): number[];
|
||||
}
|
||||
|
||||
/** react-pdf's FontFamily.resolve, so the measurer reads the same file the renderer embeds. */
|
||||
function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined {
|
||||
const styled = sources.filter((s) => s.fontStyle === style);
|
||||
const exact = styled.find((s) => s.fontWeight === weight);
|
||||
if (exact) return exact;
|
||||
const byWeight = (a: FamilySource, b: FamilySource) => a.fontWeight - b.fontWeight;
|
||||
const lt = styled.filter((s) => s.fontWeight < weight).sort(byWeight);
|
||||
const gt = styled.filter((s) => s.fontWeight > weight).sort(byWeight);
|
||||
if (weight >= 400 && weight <= 500) {
|
||||
const left = styled.filter((s) => s.fontWeight <= weight);
|
||||
const right = styled.filter((s) => s.fontWeight > 500);
|
||||
const fit = styled.filter((s) => s.fontWeight >= weight && s.fontWeight <= 500);
|
||||
return fit[0] ?? left[left.length - 1] ?? right[0];
|
||||
}
|
||||
if (weight < 400) return lt[lt.length - 1] ?? gt[0];
|
||||
return gt[0] ?? lt[lt.length - 1];
|
||||
}
|
||||
|
||||
const SOFT_HYPHEN = 0xad;
|
||||
|
||||
/**
|
||||
* `loadBytes` receives a path relative to the fonts folder ("ibm-plex-sans/IBMPlexSans-Regular.ttf")
|
||||
* and must be synchronous; in a worker, preload the files first and serve them from memory.
|
||||
*/
|
||||
export function createMeasurer(loadBytes: (file: string) => Uint8Array): Measurer {
|
||||
const fonts = new Map<string, Font>();
|
||||
|
||||
function load(familyName: string, weight: number, italic: boolean): Font | null {
|
||||
const fam = FONT_MANIFEST.families.find((f) => f.family === familyName);
|
||||
if (!fam) return null;
|
||||
const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal");
|
||||
if (!src) return null;
|
||||
const file = `${fam.folder}/${src.file}`;
|
||||
let font = fonts.get(file);
|
||||
if (!font) {
|
||||
font = create(loadBytes(file));
|
||||
fonts.set(file, font);
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
function stackFonts(face: string, weight: number, italic: boolean): Font[] {
|
||||
return stack(face)
|
||||
.map((f) => load(f, weight, italic))
|
||||
.filter((f): f is Font => f !== null);
|
||||
}
|
||||
|
||||
// Mirrors textkit's fontSubstitution: the first stack font with the glyph wins, and the last stack
|
||||
// font takes code points nobody covers.
|
||||
function runsOf(text: string, fontStack: Font[]): Array<{ font: Font; text: string }> {
|
||||
const runs: Array<{ font: Font; text: string }> = [];
|
||||
let last: Font | null = null;
|
||||
for (const ch of text) {
|
||||
const cp = ch.codePointAt(0) as number;
|
||||
let font: Font | undefined;
|
||||
if (cp === SOFT_HYPHEN && last) font = last;
|
||||
else font = [...fontStack, ...(last ? [last] : [])].find((f) => f.hasGlyphForCodePoint(cp));
|
||||
font ??= fontStack[fontStack.length - 1];
|
||||
const tail = runs[runs.length - 1];
|
||||
if (tail && tail.font === font) tail.text += ch;
|
||||
else runs.push({ font, text: ch });
|
||||
last = font;
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function measureWidth({ text, face, weight, italic = false, size, letterSpacing = 0 }: MeasureInput): number {
|
||||
if (!text) return 0;
|
||||
const fontStack = stackFonts(face, weight, italic);
|
||||
let width = 0;
|
||||
for (const run of runsOf(text, fontStack)) {
|
||||
// 'ltr' as textkit passes it, so fontkit does not reverse anything.
|
||||
const laid = run.font.layout(run.text, undefined, undefined, undefined, "ltr");
|
||||
const scale = size / run.font.unitsPerEm;
|
||||
for (const pos of laid.positions) width += pos.xAdvance * scale + letterSpacing;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
function wrapLines(text: string, width: number, input: WrapInput): string[] {
|
||||
const lines: string[] = [];
|
||||
// Break opportunities: after a space run, and after a hyphen that is followed by more text.
|
||||
const tokens = text.match(/[^ -]*-(?=[^ ])|[^ ]+ *|^ +/g) ?? [];
|
||||
let line = "";
|
||||
for (const token of tokens) {
|
||||
const candidate = line + token;
|
||||
if (!line || measureWidth({ ...input, text: candidate.trimEnd() }) <= width + 1e-6) {
|
||||
line = candidate;
|
||||
} else {
|
||||
lines.push(line.trimEnd());
|
||||
line = token;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line.trimEnd());
|
||||
return lines;
|
||||
}
|
||||
|
||||
function missing(text: string, face = FONT_STACK[0]): number[] {
|
||||
const fontStack = stackFonts(face, 400, false);
|
||||
const out = new Set<number>();
|
||||
for (const ch of text) {
|
||||
const cp = ch.codePointAt(0) as number;
|
||||
if (cp === SOFT_HYPHEN || cp === 0x200c || cp === 0x200d) continue;
|
||||
if (!fontStack.some((f) => f.hasGlyphForCodePoint(cp))) out.add(cp);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
return { measureWidth, wrapLines, missing };
|
||||
}
|
||||
|
||||
let defaultMeasurer: Measurer | null = null;
|
||||
|
||||
/** The render core installs one measurer (after preloading font bytes); primitives read it. */
|
||||
export function setMeasurer(m: Measurer | null): void {
|
||||
defaultMeasurer = m;
|
||||
}
|
||||
|
||||
export function getMeasurer(): Measurer {
|
||||
if (!defaultMeasurer) throw new Error("No text measurer installed; call setMeasurer(createMeasurer(...)) first");
|
||||
return defaultMeasurer;
|
||||
}
|
||||
|
||||
/** The text as it will be drawn: caps applied. */
|
||||
export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text);
|
||||
|
||||
/** Width of `text` set in a role/token at an optional size override, tracking and caps included. */
|
||||
export function measureToken(
|
||||
m: Measurer,
|
||||
role: RoleName | TypeToken,
|
||||
text: string,
|
||||
opts: { size?: number; weight?: number; italic?: boolean; family?: string } = {},
|
||||
): number {
|
||||
const t = roleToken(role);
|
||||
const size = opts.size ?? t.size;
|
||||
return m.measureWidth({
|
||||
text: drawnText(t, text),
|
||||
face: opts.family ?? resolveFace(t.face),
|
||||
weight: opts.weight ?? t.weight,
|
||||
italic: opts.italic ?? t.italic ?? false,
|
||||
size,
|
||||
letterSpacing: (t.tracking ?? 0) * size,
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
import { Font } from "@react-pdf/renderer";
|
||||
import { FONT_MANIFEST } from "./manifest";
|
||||
import { familySources } from "./stack";
|
||||
|
||||
/** Every stack ends here: Latin and the rupee sign from Plex, then the Indic scripts. */
|
||||
export const FONT_STACK: readonly string[] = [
|
||||
"IBM Plex Sans",
|
||||
"Noto Sans Devanagari",
|
||||
"Noto Sans Kannada",
|
||||
];
|
||||
|
||||
/** `[primary, ...tail]` without duplicates, for a style's fontFamily. */
|
||||
export function stack(primary: string): string[] {
|
||||
return [primary, ...FONT_STACK.filter((f) => f !== primary)];
|
||||
}
|
||||
export { FONT_STACK, familySources, stack, type FamilySource } from "./stack";
|
||||
|
||||
let registered = false;
|
||||
|
||||
@@ -28,23 +19,11 @@ export function registerBundledFonts(base: string): void {
|
||||
registered = true;
|
||||
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
const sources: { src: string; fontWeight: number; fontStyle: "normal" | "italic" }[] = [];
|
||||
const weights = new Set<number>();
|
||||
for (const font of fam.fonts) {
|
||||
weights.add(font.weight);
|
||||
sources.push({
|
||||
src: `${base}/${fam.folder}/${font.file}`,
|
||||
fontWeight: font.weight,
|
||||
fontStyle: font.style,
|
||||
});
|
||||
}
|
||||
// react-pdf resolves every family in a fallback stack with the node's own style and throws for
|
||||
// a family that has no italic, so any weight without a real italic reuses its upright file.
|
||||
for (const weight of weights) {
|
||||
if (sources.some((s) => s.fontWeight === weight && s.fontStyle === "italic")) continue;
|
||||
const upright = sources.find((s) => s.fontWeight === weight && s.fontStyle === "normal");
|
||||
if (upright) sources.push({ ...upright, fontStyle: "italic" });
|
||||
}
|
||||
const sources = familySources(fam).map((s) => ({
|
||||
src: `${base}/${fam.folder}/${s.file}`,
|
||||
fontWeight: s.fontWeight,
|
||||
fontStyle: s.fontStyle,
|
||||
}));
|
||||
Font.register({ family: fam.family, fonts: sources });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Typography roles. `leading` is an absolute line height in pt: Txt turns it into `lineHeight: "Npt"`
|
||||
* on the same node as fontSize. Roles used by render-prop text (page numbers) have no leading, because
|
||||
* react-pdf multiplies a render-prop lineHeight again on every relayout.
|
||||
*
|
||||
* These values are the Classic template's; other templates will pass their own token objects.
|
||||
*/
|
||||
export type FaceKey = "IBM Plex Sans" | "IBM Plex Mono";
|
||||
|
||||
export interface TypeToken {
|
||||
face: FaceKey;
|
||||
weight: 300 | 400 | 500 | 600 | 700 | 800;
|
||||
italic?: boolean;
|
||||
size: number;
|
||||
/** pt, absolute. Optional only for tokens used by RenderTxt. */
|
||||
leading?: number;
|
||||
/** em; applied as letterSpacing = tracking * size. */
|
||||
tracking?: number;
|
||||
caps?: boolean;
|
||||
/** Intent only: textkit exposes no OpenType feature switch, so this does not change rendering yet. */
|
||||
tnum?: boolean;
|
||||
/** Smallest size IdText/FitText may shrink to. */
|
||||
minSize?: number;
|
||||
}
|
||||
|
||||
const SANS: FaceKey = "IBM Plex Sans";
|
||||
|
||||
export const TYPE_ROLES = {
|
||||
display: { face: SANS, weight: 600, size: 26, leading: 30 },
|
||||
title: { face: SANS, weight: 600, size: 18, leading: 22, tracking: 1.5 / 18, caps: true },
|
||||
h1: { face: SANS, weight: 600, size: 18, leading: 22 },
|
||||
h2: { face: SANS, weight: 600, size: 13, leading: 17 },
|
||||
body: { face: SANS, weight: 400, size: 9.5, leading: 13.8 },
|
||||
bodyStrong: { face: SANS, weight: 600, size: 9.5, leading: 13.8 },
|
||||
small: { face: SANS, weight: 400, size: 8.5, leading: 12 },
|
||||
fine: { face: SANS, weight: 400, size: 7.5, leading: 10.5 },
|
||||
tableHead: { face: SANS, weight: 400, size: 7.5, leading: 10, tracking: 0.8 / 7.5, caps: true },
|
||||
tableBody: { face: SANS, weight: 400, size: 9.5, leading: 13.8 },
|
||||
money: { face: SANS, weight: 400, size: 9.5, leading: 13.8, tnum: true },
|
||||
moneyStrong: { face: SANS, weight: 600, size: 9.5, leading: 13.8, tnum: true },
|
||||
footer: { face: SANS, weight: 400, size: 7.5 },
|
||||
label: { face: SANS, weight: 400, size: 7.5, leading: 10, tracking: 1 / 7.5, caps: true },
|
||||
} as const satisfies Record<string, TypeToken>;
|
||||
|
||||
export type RoleName = keyof typeof TYPE_ROLES;
|
||||
|
||||
/**
|
||||
* Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is
|
||||
* actually bundled. Empty until those families are registered.
|
||||
*/
|
||||
export const SUBSTITUTES: Readonly<Record<string, string>> = {};
|
||||
|
||||
/** The family name to register/look up for a face: user override first, then substitute, then itself. */
|
||||
export function resolveFace(face: string, overrides?: Readonly<Record<string, string>>): string {
|
||||
return overrides?.[face] ?? SUBSTITUTES[face] ?? face;
|
||||
}
|
||||
|
||||
export function roleToken(role: RoleName | TypeToken): TypeToken {
|
||||
return typeof role === "string" ? TYPE_ROLES[role] : role;
|
||||
}
|
||||
|
||||
/** Smallest shrink size for a token: its minSize, else 0.9x the size but never below 7 pt (or the size itself). */
|
||||
export function minSizeOf(token: TypeToken): number {
|
||||
return token.minSize ?? Math.min(token.size, Math.max(7, token.size * 0.9));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FontFamilyEntry } from "./manifest";
|
||||
|
||||
/** Every stack ends here: Latin and the rupee sign from Plex, then the Indic scripts. */
|
||||
export const FONT_STACK: readonly string[] = [
|
||||
"IBM Plex Sans",
|
||||
"Noto Sans Devanagari",
|
||||
"Noto Sans Kannada",
|
||||
];
|
||||
|
||||
/** `[primary, ...tail]` without duplicates, for a style's fontFamily. */
|
||||
export function stack(primary: string): string[] {
|
||||
return [primary, ...FONT_STACK.filter((f) => f !== primary)];
|
||||
}
|
||||
|
||||
export interface FamilySource {
|
||||
file: string;
|
||||
fontWeight: number;
|
||||
fontStyle: "normal" | "italic";
|
||||
}
|
||||
|
||||
/**
|
||||
* The sources registered for a family: every manifest file, plus the upright file reused as italic for
|
||||
* any weight without a real italic. react-pdf resolves every family in a fallback stack with the node's
|
||||
* own style and throws for a family that has no italic. The measurer reads the same list.
|
||||
*/
|
||||
export function familySources(fam: FontFamilyEntry): FamilySource[] {
|
||||
const sources: FamilySource[] = fam.fonts.map((f) => ({ file: f.file, fontWeight: f.weight, fontStyle: f.style }));
|
||||
for (const weight of new Set(sources.map((s) => s.fontWeight))) {
|
||||
if (sources.some((s) => s.fontWeight === weight && s.fontStyle === "italic")) continue;
|
||||
const upright = sources.find((s) => s.fontWeight === weight && s.fontStyle === "normal");
|
||||
if (upright) sources.push({ ...upright, fontStyle: "italic" });
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatViolation, lintSource } from "./testing/layoutLint";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const PDF_DIR = path.join(ROOT, "src/pdf");
|
||||
|
||||
// TODO: remove this exclusion when the legacy InvoiceDocument.tsx is deleted (it predates the primitives).
|
||||
const EXCLUDED = new Set(["src/pdf/InvoiceDocument.tsx"]);
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const full = path.join(dir, name);
|
||||
if (statSync(full).isDirectory()) return sources(full);
|
||||
const rel = path.relative(ROOT, full).split(path.sep).join("/");
|
||||
if (!/\.(ts|tsx)$/.test(name) || /\.test\.|\.d\.ts$|\.generated\./.test(name) || EXCLUDED.has(rel)) return [];
|
||||
return [rel];
|
||||
});
|
||||
}
|
||||
|
||||
describe("react-pdf layout rules (src/pdf)", () => {
|
||||
it("scans a non-trivial set of files", () => {
|
||||
expect(sources(PDF_DIR).length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("has no violations", () => {
|
||||
const violations = sources(PDF_DIR).flatMap((f) => lintSource(f, readFileSync(path.join(ROOT, f), "utf8")));
|
||||
expect(violations.map(formatViolation)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lint rules detect what they should", () => {
|
||||
const rules = (file: string, code: string) => lintSource(file, code).map((v) => v.rule);
|
||||
|
||||
it("line-height", () => {
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { lineHeight: 1.2 };")).toEqual(["line-height"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { 'lineHeight': '10pt' };")).toEqual(["line-height"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const lineHeight = 1; const s = { lineHeight };")).toEqual(["line-height"]);
|
||||
expect(rules("src/pdf/blocks/Txt.tsx", "const s = { lineHeight: '10pt' };")).toEqual([]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { leading: 10 };")).toEqual([]);
|
||||
});
|
||||
|
||||
it("pdf flags", () => {
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const a = <View fixed={fixed} />;")).toEqual(["pdf-flag"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const a = <View wrap={w} />;")).toEqual(["pdf-flag"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const a = <View minPresenceAhead={undefined} />;")).toEqual(["pdf-flag"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const a = <View fixed={false} />;")).toEqual(["pdf-flag"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const a = <View minPresenceAhead={0} />;")).toEqual(["pdf-flag"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const a = <View fixed wrap={false} minPresenceAhead={12} />;")).toEqual([]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const a = <View fixed={true} />;")).toEqual([]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const a = <View fixed={fixed} {...pdfFlags({ fixed })} />;")).toEqual([]);
|
||||
});
|
||||
|
||||
it("flex grow", () => {
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: 1 };")).toEqual(["flex-grow"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: g };")).toEqual(["flex-grow"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: 1, flexBasis: 0 };")).toEqual([]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: 1, flex: 1 };")).toEqual([]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: 0 };")).toEqual([]);
|
||||
});
|
||||
|
||||
it("absolute", () => {
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { position: 'absolute' };")).toEqual(["absolute"]);
|
||||
expect(rules("src/pdf/decor/Band.tsx", "const s = { position: 'absolute' };")).toEqual([]);
|
||||
expect(rules("src/pdf/blocks/PageChrome.tsx", "const s = { position: 'absolute' };")).toEqual([]);
|
||||
expect(rules("src/pdf/blocks/FirstPageHeader.tsx", "const s = { position: 'absolute' };")).toEqual([]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { position: 'relative' };")).toEqual([]);
|
||||
});
|
||||
|
||||
it("align flex-end", () => {
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { alignItems: 'flex-end' };")).toEqual(["align-flex-end"]);
|
||||
expect(rules("src/pdf/blocks/Foo.tsx", "const s = { alignItems: 'flex-start' };")).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports file:line", () => {
|
||||
const [v] = lintSource("src/pdf/blocks/Foo.tsx", "\n\nconst s = { alignItems: 'flex-end' };");
|
||||
expect(formatViolation(v)).toMatch(/^src\/pdf\/blocks\/Foo\.tsx:3 \[align-flex-end\]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect } from "vitest";
|
||||
import { describeBox, findOverlaps, type PageText } from "./pdfText";
|
||||
|
||||
/** Fails (listing every colliding pair) when any two text boxes on any page overlap. */
|
||||
export function assertNoOverlap(pages: PageText[]): void {
|
||||
pages.forEach((page, idx) => {
|
||||
const overlaps = findOverlaps(page).map(([a, b]) => `${describeBox(a)} x ${describeBox(b)}`);
|
||||
expect(overlaps, `page ${idx + 1} overlaps`).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import ts from "typescript";
|
||||
|
||||
export interface LintViolation {
|
||||
file: string;
|
||||
line: number;
|
||||
rule: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Paths (relative to the repo root, posix) where each otherwise-banned construct may appear. */
|
||||
export const LINT_ALLOW = {
|
||||
leadingProp: ["src/pdf/blocks/Txt.tsx", "src/pdf/fonts/roles.ts"],
|
||||
absolute: ["src/pdf/decor/", "src/pdf/blocks/PageChrome.tsx", "src/pdf/blocks/FirstPageHeader.tsx"],
|
||||
rawFlags: ["src/pdf/blocks/pdfProps.ts"],
|
||||
};
|
||||
|
||||
const FLAG_PROPS = new Set(["fixed", "wrap", "minPresenceAhead"]);
|
||||
|
||||
const propName = (n: ts.PropertyName): string | undefined =>
|
||||
ts.isIdentifier(n) || ts.isStringLiteral(n) ? n.text : undefined;
|
||||
|
||||
const stringValue = (e: ts.Expression): string | undefined =>
|
||||
ts.isStringLiteralLike(e) ? e.text : undefined;
|
||||
|
||||
const allowed = (file: string, list: string[]) => list.some((p) => (p.endsWith("/") ? file.startsWith(p) : file === p));
|
||||
|
||||
/**
|
||||
* Static layout-rule checks for react-pdf code. `file` is the repo-relative posix path.
|
||||
*
|
||||
* Not checked: a pinned numeric `height` on an element that holds text. That cannot be decided from
|
||||
* the syntax (the text may be several components away), so it stays a review rule: textkit cuts
|
||||
* lines at the box height and appends "…" even without maxLines. Use minHeight for text containers.
|
||||
*/
|
||||
export function lintSource(file: string, text: string): LintViolation[] {
|
||||
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
||||
const out: LintViolation[] = [];
|
||||
const lineOf = (n: ts.Node) => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1;
|
||||
const add = (n: ts.Node, rule: string, message: string) => out.push({ file, line: lineOf(n), rule, message });
|
||||
const usesPdfFlags = /\bpdfFlags\b/.test(text);
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) {
|
||||
const name = ts.isPropertyAssignment(node) ? propName(node.name) : node.name.text;
|
||||
|
||||
if (name === "lineHeight" && !allowed(file, LINT_ALLOW.leadingProp)) {
|
||||
add(node, "line-height", "lineHeight is set only by Txt and the roles; use a Txt role (an ancestor lineHeight breaks render-prop text)");
|
||||
}
|
||||
if (ts.isPropertyAssignment(node)) {
|
||||
const v = stringValue(node.initializer);
|
||||
if (name === "position" && v === "absolute" && !allowed(file, LINT_ALLOW.absolute)) {
|
||||
add(node, "absolute", "position: 'absolute' is allowed only in src/pdf/decor/**, PageChrome and FirstPageHeader");
|
||||
}
|
||||
if (name === "alignItems" && v === "flex-end") {
|
||||
add(node, "align-flex-end", "alignItems: 'flex-end' misplaces multi-line text; use flex-start or align inside the cell");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isObjectLiteralExpression(node)) {
|
||||
const props = new Map<string, ts.PropertyAssignment>();
|
||||
for (const p of node.properties) {
|
||||
if (ts.isPropertyAssignment(p)) {
|
||||
const n = propName(p.name);
|
||||
if (n) props.set(n, p);
|
||||
}
|
||||
}
|
||||
const grow = props.get("flexGrow");
|
||||
if (grow) {
|
||||
const init = grow.initializer;
|
||||
// A non-literal value may be positive, so it counts.
|
||||
const positive = ts.isNumericLiteral(init) ? Number(init.text) > 0 : true;
|
||||
if (positive && !props.has("flexBasis") && !props.has("flex")) {
|
||||
add(grow, "flex-grow", "flexGrow > 0 needs flexBasis: 0 (or flex:); use FILL from engine/columns");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isJsxAttribute(node)) {
|
||||
const name = ts.isIdentifier(node.name) ? node.name.text : undefined;
|
||||
if (name === "lineHeight" && !allowed(file, LINT_ALLOW.leadingProp)) {
|
||||
add(node, "line-height", "lineHeight is set only by Txt and the roles");
|
||||
}
|
||||
if (name && FLAG_PROPS.has(name) && !allowed(file, LINT_ALLOW.rawFlags)) {
|
||||
const init = node.initializer;
|
||||
const expr = init && ts.isJsxExpression(init) ? init.expression : undefined;
|
||||
let ok: boolean;
|
||||
if (!init) ok = name === "fixed"; // bare `fixed`
|
||||
else if (!expr) ok = false;
|
||||
else if (name === "fixed") ok = expr.kind === ts.SyntaxKind.TrueKeyword;
|
||||
else if (name === "wrap") ok = expr.kind === ts.SyntaxKind.FalseKeyword;
|
||||
else ok = ts.isNumericLiteral(expr) && Number(expr.text) > 0;
|
||||
if (!ok && !usesPdfFlags) {
|
||||
add(node, "pdf-flag", `${name} is checked by key presence in react-pdf; pass only a literal (${name === "fixed" ? "true" : name === "wrap" ? "false" : "a positive number"}) or spread pdfFlags()`);
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const formatViolation = (v: LintViolation) => `${v.file}:${v.line} [${v.rule}] ${v.message}`;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createMeasurer, setMeasurer, type Measurer } from "../engine/measure";
|
||||
import { registerBundledFonts } from "../fonts/register";
|
||||
|
||||
export const FONTS_DIR = path.resolve(process.cwd(), "public/fonts");
|
||||
|
||||
/** Registers the bundled fonts for react-pdf and installs a Node-backed measurer. */
|
||||
export function setupPdfTest(): Measurer {
|
||||
registerBundledFonts(FONTS_DIR);
|
||||
const m = createMeasurer((file) => new Uint8Array(readFileSync(path.join(FONTS_DIR, file))));
|
||||
setMeasurer(m);
|
||||
return m;
|
||||
}
|
||||
Reference in New Issue
Block a user