Add bundled TTF fonts, page geometry, column allocator and render model
Static TTF fonts (IBM Plex Sans/Mono, Noto Devanagari/Kannada) with a fontkit-generated manifest, a verifier and OFL licences. Every family registers a normal and italic source so fallback stacks cannot throw. Adds A4/Letter geometry with margin presets, an exact column allocator, deterministic Indian-grouping money and date formatting, text sanitising and a pure buildRenderModel.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FILL, ColumnsError, cols, colsDetailed, pinned } from "./columns";
|
||||
|
||||
/** mulberry32, so failures reproduce. */
|
||||
function prng(seed: number) {
|
||||
return () => {
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
const sumCents = (w: number[], gap: number) =>
|
||||
Math.round(w.reduce((a, b) => a + b, 0) * 100) + Math.round(gap * 100) * (w.length - 1);
|
||||
|
||||
describe("cols", () => {
|
||||
it("gives the fill column the remainder", () => {
|
||||
expect(cols(500, [{ fixed: 30 }, { fill: true }, { fixed: 60 }, { fixed: 80 }])).toEqual([30, 330, 60, 80]);
|
||||
});
|
||||
|
||||
it("subtracts gaps before allocating", () => {
|
||||
const w = cols(500, [{ fixed: 100 }, { fill: true }, { fixed: 100 }], 10);
|
||||
expect(w).toEqual([100, 280, 100]);
|
||||
});
|
||||
|
||||
it("allocates fractions of the space after gaps and puts the rounding on the fill", () => {
|
||||
const w = cols(523.28, [{ frac: 1 / 3 }, { frac: 1 / 3 }, { fill: true }]);
|
||||
expect(w.reduce((a, b) => a + b, 0)).toBeCloseTo(523.28, 10);
|
||||
expect(w[0]).toBe(174.43);
|
||||
expect(w[1]).toBe(174.43);
|
||||
expect(w[2]).toBe(174.42);
|
||||
});
|
||||
|
||||
it("without a fill column the last fraction absorbs the remainder", () => {
|
||||
const w = cols(100, [{ frac: 0.333 }, { frac: 0.333 }, { frac: 0.334 }]);
|
||||
expect(sumCents(w, 0)).toBe(10000);
|
||||
});
|
||||
|
||||
it("sums exactly for many random inputs", () => {
|
||||
const rnd = prng(20260904);
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const total = Math.round((400 + rnd() * 300) * 100) / 100;
|
||||
const gap = Math.round(rnd() * 12 * 100) / 100;
|
||||
const n = 2 + Math.floor(rnd() * 5);
|
||||
const specs: Parameters<typeof cols>[1] = [];
|
||||
const fillAt = Math.floor(rnd() * n);
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (k === fillAt) specs.push({ fill: true });
|
||||
else if (rnd() < 0.5) specs.push({ fixed: Math.round(rnd() * 30 * 100) / 100 });
|
||||
else specs.push({ frac: Math.round(rnd() * 0.08 * 1000) / 1000 });
|
||||
}
|
||||
const w = cols(total, specs, gap);
|
||||
expect(sumCents(w, gap)).toBe(Math.round(total * 100));
|
||||
expect(w.every((x) => x >= 0)).toBe(true);
|
||||
expect(w.every((x) => Math.abs(x * 100 - Math.round(x * 100)) < 1e-6)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports columns under their min so a Row can stack", () => {
|
||||
const r = colsDetailed(300, [{ fixed: 170 }, { fill: true, min: 160 }], 10);
|
||||
expect(r.widths).toEqual([170, 120]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.short).toEqual([1]);
|
||||
const ok = colsDetailed(400, [{ fixed: 170 }, { fill: true, min: 160 }], 10);
|
||||
expect(ok.ok).toBe(true);
|
||||
expect(ok.short).toEqual([]);
|
||||
expect(colsDetailed(300, [{ frac: 0.2, min: 80 }, { fill: true }]).short).toEqual([0]);
|
||||
});
|
||||
|
||||
it("throws typed errors", () => {
|
||||
const err = (fn: () => unknown) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
};
|
||||
const a = err(() => cols(100, [{ fixed: 80 }, { fixed: 30 }, { fill: true }]));
|
||||
expect(a).toBeInstanceOf(ColumnsError);
|
||||
expect((a as ColumnsError).code).toBe("fixed-exceeds-total");
|
||||
expect((err(() => cols(100, [{ fixed: 95 }, { fill: true }], 10)) as ColumnsError).code).toBe("fixed-exceeds-total");
|
||||
expect((err(() => cols(100, [{ fill: true }, { fill: true }])) as ColumnsError).code).toBe("multiple-fill");
|
||||
expect((err(() => cols(100, [{ frac: 1.5 }])) as ColumnsError).code).toBe("invalid-spec");
|
||||
expect((err(() => cols(-1, [{ fill: true }])) as ColumnsError).code).toBe("invalid-spec");
|
||||
expect((err(() => cols(100, [{ frac: 0.8 }, { frac: 0.8 }, { fill: true }])) as ColumnsError).code).toBe("fixed-exceeds-total");
|
||||
});
|
||||
|
||||
it("handles a lone fill and exact-fit fixed columns", () => {
|
||||
expect(cols(210, [{ fill: true }])).toEqual([210]);
|
||||
expect(cols(100, [{ fixed: 40 }, { fill: true }, { fixed: 60 }])).toEqual([40, 0, 60]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("style helpers", () => {
|
||||
it("pinned fixes the box on every axis", () => {
|
||||
expect(pinned(42)).toEqual({ width: 42, minWidth: 42, maxWidth: 42 });
|
||||
});
|
||||
it("FILL pairs flexGrow with flexBasis 0", () => {
|
||||
expect(FILL).toEqual({ flexGrow: 1, flexBasis: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
export type ColSpec =
|
||||
| { fixed: number }
|
||||
| { fill: true; min?: number }
|
||||
| { frac: number; min?: number };
|
||||
|
||||
export class ColumnsError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: "fixed-exceeds-total" | "multiple-fill" | "invalid-spec",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ColumnsError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ColsResult {
|
||||
/** pt widths in spec order; with gaps they sum exactly to the total (to 0.01). */
|
||||
widths: number[];
|
||||
/** True when every column with a `min` is at least that wide. */
|
||||
ok: boolean;
|
||||
/** Indexes of columns that fall under their `min`. */
|
||||
short: number[];
|
||||
}
|
||||
|
||||
const to2 = (n: number) => Math.round(n * 100) / 100;
|
||||
// Work in hundredths of a point so the sum is exact integer arithmetic.
|
||||
const cents = (n: number) => Math.round(n * 100);
|
||||
|
||||
/**
|
||||
* Allocates `total` pt across the columns. Fixed columns keep their width, fractions take that share
|
||||
* of the total, and the fill column (or, without one, the last fractional column) takes what is left
|
||||
* so the widths (plus gaps) sum exactly to `total`. `gap` is the space between adjacent columns.
|
||||
*/
|
||||
export function colsDetailed(total: number, specs: ColSpec[], gap = 0): ColsResult {
|
||||
if (!Number.isFinite(total) || total < 0) throw new ColumnsError(`invalid total ${total}`, "invalid-spec");
|
||||
if (specs.filter((s) => "fill" in s).length > 1) {
|
||||
throw new ColumnsError("at most one fill column is allowed", "multiple-fill");
|
||||
}
|
||||
const gaps = cents(gap) * Math.max(0, specs.length - 1);
|
||||
const avail = cents(total) - gaps;
|
||||
|
||||
const widths = specs.map((spec) => {
|
||||
if ("fixed" in spec) {
|
||||
if (!(spec.fixed >= 0)) throw new ColumnsError(`invalid fixed width ${spec.fixed}`, "invalid-spec");
|
||||
return cents(spec.fixed);
|
||||
}
|
||||
if ("frac" in spec) {
|
||||
if (!(spec.frac >= 0 && spec.frac <= 1)) throw new ColumnsError(`invalid fraction ${spec.frac}`, "invalid-spec");
|
||||
return Math.round(avail * spec.frac);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const fixedSum = specs.reduce((sum, spec, i) => ("fixed" in spec ? sum + widths[i] : sum), 0);
|
||||
if (fixedSum + gaps > cents(total)) {
|
||||
throw new ColumnsError(
|
||||
`fixed columns (${to2(fixedSum / 100)} pt) plus gaps exceed the total ${total} pt`,
|
||||
"fixed-exceeds-total",
|
||||
);
|
||||
}
|
||||
|
||||
// The remainder goes to the fill column; without one, to the last fractional column. A row of only
|
||||
// fixed columns keeps its widths and may sum to less than the total.
|
||||
let restIdx = specs.findIndex((s) => "fill" in s);
|
||||
if (restIdx < 0) restIdx = specs.reduce((last, s, i) => ("frac" in s ? i : last), -1);
|
||||
if (restIdx >= 0) {
|
||||
const others = widths.reduce((sum, w, i) => (i === restIdx ? sum : sum + w), 0);
|
||||
if (others > avail) throw new ColumnsError("fixed and fractional columns exceed the total", "fixed-exceeds-total");
|
||||
widths[restIdx] = avail - others;
|
||||
}
|
||||
|
||||
const out = widths.map((w) => to2(w / 100));
|
||||
const short: number[] = [];
|
||||
specs.forEach((spec, i) => {
|
||||
const min = "min" in spec ? spec.min : undefined;
|
||||
if (min !== undefined && out[i] + 1e-9 < min) short.push(i);
|
||||
});
|
||||
return { widths: out, ok: short.length === 0, short };
|
||||
}
|
||||
|
||||
/** Just the widths; see colsDetailed for the `ok` flag a Row uses to decide whether to stack. */
|
||||
export function cols(total: number, specs: ColSpec[], gap = 0): number[] {
|
||||
return colsDetailed(total, specs, gap).widths;
|
||||
}
|
||||
|
||||
/** A box that cannot grow or shrink: every flex-row child except the fill column uses this. */
|
||||
export const pinned = (w: number) => ({ width: w, minWidth: w, maxWidth: w });
|
||||
|
||||
/** The verified-safe fill child: flexGrow alone leaves stale line breaks after the flex shrink. */
|
||||
export const FILL = { flexGrow: 1, flexBasis: 0 } as const;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MARGIN_PRESETS,
|
||||
PAGE_SIZES,
|
||||
PRINTER_SAFE_MARGIN,
|
||||
computeFrame,
|
||||
effectiveMargins,
|
||||
type Sides,
|
||||
} from "./geometry";
|
||||
|
||||
const sides = (top: number, right: number, bottom: number, left: number): Sides => ({ top, right, bottom, left });
|
||||
|
||||
describe("PAGE_SIZES", () => {
|
||||
it("has exact A4 and Letter", () => {
|
||||
expect(PAGE_SIZES.A4).toMatchObject({ width: 595.28, height: 841.89, size: { width: "210mm", height: "297mm" } });
|
||||
expect(PAGE_SIZES.LETTER).toMatchObject({ width: 612, height: 792, size: "LETTER" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MARGIN_PRESETS", () => {
|
||||
it("narrow is 0.5 in, normal is 0.75 in, template defers", () => {
|
||||
expect(MARGIN_PRESETS.narrow).toEqual(sides(36, 36, 36, 36));
|
||||
expect(MARGIN_PRESETS.normal).toEqual(sides(54, 54, 54, 54));
|
||||
expect(MARGIN_PRESETS.template).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveMargins", () => {
|
||||
const linea = sides(56, 77.4, 60, 77.4);
|
||||
it("template preset returns the template values", () => {
|
||||
expect(effectiveMargins("template", linea)).toEqual(linea);
|
||||
});
|
||||
it("takes the per-side maximum of choice and template floor", () => {
|
||||
expect(effectiveMargins("narrow", linea)).toEqual(linea);
|
||||
expect(effectiveMargins("normal", linea)).toEqual(sides(56, 77.4, 60, 77.4));
|
||||
expect(effectiveMargins("normal", sides(20, 20, 20, 20))).toEqual(sides(54, 54, 54, 54));
|
||||
expect(effectiveMargins("narrow", sides(20, 50, 20, 20))).toEqual(sides(36, 50, 36, 36));
|
||||
});
|
||||
it("never goes under the printer-safe floor", () => {
|
||||
expect(effectiveMargins(sides(0, 5, 10, 17.9), sides(0, 0, 0, 0))).toEqual(sides(18, 18, 18, 18));
|
||||
expect(PRINTER_SAFE_MARGIN).toBe(18);
|
||||
expect(effectiveMargins("template", sides(10, 10, 10, 10))).toEqual(sides(18, 18, 18, 18));
|
||||
});
|
||||
it("accepts custom sides", () => {
|
||||
expect(effectiveMargins(sides(40, 30, 20, 25), sides(10, 10, 10, 10))).toEqual(sides(40, 30, 20, 25));
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeFrame", () => {
|
||||
const base = { footerReserve: 0, continuationReserve: 0, letterMode: false };
|
||||
|
||||
it("A4 with narrow margins", () => {
|
||||
const f = computeFrame({ ...base, page: PAGE_SIZES.A4, margins: MARGIN_PRESETS.narrow! });
|
||||
expect(f.contentWidth).toBeCloseTo(523.28, 2);
|
||||
expect(f.contentHeight).toBeCloseTo(769.89, 2);
|
||||
expect(f.widthGain).toBe(0);
|
||||
expect(f.heightDeficit).toBe(0);
|
||||
});
|
||||
|
||||
it("A4 with normal margins and reserves", () => {
|
||||
const f = computeFrame({
|
||||
page: PAGE_SIZES.A4,
|
||||
margins: MARGIN_PRESETS.normal!,
|
||||
footerReserve: 20,
|
||||
continuationReserve: 24,
|
||||
letterMode: false,
|
||||
});
|
||||
expect(f.pad).toEqual(sides(54, 54, 74, 54));
|
||||
expect(f.contentWidth).toBeCloseTo(487.28, 2);
|
||||
expect(f.contentHeight).toBeCloseTo(713.89, 2);
|
||||
expect(f.footerReserve).toBe(20);
|
||||
expect(f.continuationReserve).toBe(24);
|
||||
});
|
||||
|
||||
it("Letter keeps the side margins: extra width to content, deficit reported", () => {
|
||||
const margins = MARGIN_PRESETS.normal!;
|
||||
const a4 = computeFrame({ ...base, page: PAGE_SIZES.A4, margins, letterMode: true });
|
||||
const letter = computeFrame({ ...base, page: PAGE_SIZES.LETTER, margins, letterMode: true });
|
||||
expect(letter.pad).toEqual(a4.pad);
|
||||
expect(letter.contentWidth - a4.contentWidth).toBeCloseTo(16.72, 2);
|
||||
expect(letter.widthGain).toBeCloseTo(16.72, 2);
|
||||
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
||||
expect(a4.contentHeight - letter.contentHeight).toBeCloseTo(49.89, 2);
|
||||
});
|
||||
|
||||
it("every preset on both pages yields positive, consistent boxes", () => {
|
||||
for (const page of Object.values(PAGE_SIZES)) {
|
||||
for (const m of [MARGIN_PRESETS.narrow!, MARGIN_PRESETS.normal!, sides(56, 77.4, 60, 77.4), sides(18, 18, 18, 18)]) {
|
||||
const f = computeFrame({ ...base, page, margins: m, footerReserve: 14 });
|
||||
expect(f.contentWidth + f.pad.left + f.pad.right).toBeCloseTo(page.width, 1);
|
||||
expect(f.contentHeight + f.pad.top + f.pad.bottom).toBeCloseTo(page.height, 1);
|
||||
expect(f.contentWidth).toBeGreaterThan(0);
|
||||
expect(f.contentHeight).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not report a deficit when letter mode is off", () => {
|
||||
const f = computeFrame({ ...base, page: PAGE_SIZES.LETTER, margins: MARGIN_PRESETS.narrow! });
|
||||
expect(f.heightDeficit).toBe(0);
|
||||
expect(f.widthGain).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
export type PageId = "A4" | "LETTER";
|
||||
|
||||
export interface PageSpec {
|
||||
id: PageId;
|
||||
/** Physical size in pt. */
|
||||
width: number;
|
||||
height: number;
|
||||
/** Value for react-pdf's Page `size` (mm strings give A4 exactly; verified). */
|
||||
size: { width: string; height: string } | "LETTER";
|
||||
}
|
||||
|
||||
export const PAGE_SIZES: Record<PageId, PageSpec> = {
|
||||
A4: { id: "A4", width: 595.28, height: 841.89, size: { width: "210mm", height: "297mm" } },
|
||||
LETTER: { id: "LETTER", width: 612, height: 792, size: "LETTER" },
|
||||
};
|
||||
|
||||
export interface Sides {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export type MarginPreset = "template" | "narrow" | "normal";
|
||||
|
||||
/** Printer-safe floor: most office printers cannot print closer than this to the edge. */
|
||||
export const PRINTER_SAFE_MARGIN = 18;
|
||||
|
||||
const all = (v: number): Sides => ({ top: v, right: v, bottom: v, left: v });
|
||||
|
||||
/** `template` takes its value from the template, so it has no fixed sides here. */
|
||||
export const MARGIN_PRESETS: Record<MarginPreset, Sides | null> = {
|
||||
template: null,
|
||||
narrow: all(36),
|
||||
normal: all(54),
|
||||
};
|
||||
|
||||
export type MarginChoice = MarginPreset | Sides;
|
||||
|
||||
/** Per-side max(chosen, template floor, printer-safe floor). */
|
||||
export function effectiveMargins(chosen: Sides | MarginPreset, templateFloor: Sides): Sides {
|
||||
const preset = typeof chosen === "string" ? MARGIN_PRESETS[chosen] : chosen;
|
||||
// "template" means: use the template's own margins.
|
||||
const base = preset ?? templateFloor;
|
||||
const side = (k: keyof Sides) => Math.max(base[k], templateFloor[k], PRINTER_SAFE_MARGIN);
|
||||
return { top: side("top"), right: side("right"), bottom: side("bottom"), left: side("left") };
|
||||
}
|
||||
|
||||
export interface FrameInput {
|
||||
page: PageSpec;
|
||||
/** Effective margins (already through effectiveMargins). */
|
||||
margins: Sides;
|
||||
/** Height reserved at the bottom of every page for fixed footer chrome. */
|
||||
footerReserve: number;
|
||||
/** Height reserved at the top of pages 2+ for the continuation band. */
|
||||
continuationReserve: number;
|
||||
letterMode: boolean;
|
||||
}
|
||||
|
||||
export interface PageFrame {
|
||||
page: PageSpec;
|
||||
pad: Sides;
|
||||
contentWidth: number;
|
||||
/** Wrap area height on page 1: page height - top and bottom padding. */
|
||||
contentHeight: number;
|
||||
continuationReserve: number;
|
||||
footerReserve: number;
|
||||
/** Extra content width over A4 with the same side margins (Letter only; 0 for A4). */
|
||||
widthGain: number;
|
||||
/** Height missing against A4 with the same margins (Letter only; 0 for A4). Templates absorb it from elastic gaps first. */
|
||||
heightDeficit: number;
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export function computeFrame(input: FrameInput): PageFrame {
|
||||
const { page, margins, footerReserve, continuationReserve, letterMode } = input;
|
||||
const a4 = PAGE_SIZES.A4;
|
||||
// The padding is the physical margin plus the footer chrome that lives inside the wrap area.
|
||||
const pad: Sides = { ...margins, bottom: margins.bottom + footerReserve };
|
||||
const contentWidth = round2(page.width - pad.left - pad.right);
|
||||
const contentHeight = round2(page.height - pad.top - pad.bottom);
|
||||
const compress = letterMode && page.id === "LETTER";
|
||||
return {
|
||||
page,
|
||||
pad,
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
continuationReserve,
|
||||
footerReserve,
|
||||
widthGain: compress ? round2(page.width - a4.width) : 0,
|
||||
heightDeficit: compress ? round2(a4.height - page.height) : 0,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user