Rebuild the Classic template on the engine with A4/Letter and a template harness
Classic now renders through buildRenderModel, computeFrame, the template and renderCore, with S.No and HSN/SAC columns, GST slots only through checked Slot components, taxable value, reverse-charge line, cancelled tag, continuation band and Page n of N. Letter absorbs its height deficit in elastic gaps. A harness runs 15 fixtures on A4 and Letter plus an item sweep and asserts zero audit errors, repeated headers, no split blocks, rupee extraction and deterministic output. The legacy InvoiceDocument is removed.
This commit is contained in:
@@ -17,3 +17,9 @@ export const TABLE_HEAD_ID = "tbl-head";
|
||||
|
||||
/** Body rows are `tbl-row-<index>`: unique, so a row on two pages is visible as a split. */
|
||||
export const TABLE_ROW_PREFIX = "tbl-row-";
|
||||
|
||||
/** The closing block (bank + signatory): the audit expects its bottom on the wrap-area bottom of the last page. */
|
||||
export const CLOSING_ID = "closing";
|
||||
|
||||
/** The totals block (subtotal ... amount in words): never split across pages. */
|
||||
export const TOTALS_ID = "totals";
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeLogoPlacement } from "./logo";
|
||||
|
||||
const BOX = { w: 170, h: 54 };
|
||||
const base = { box: BOX, availableWidth: 299, minTextWidth: 160 };
|
||||
|
||||
describe("computeLogoPlacement", () => {
|
||||
it("the Test Vendor trimmed 4.6:1 logo is width-limited and stacks above the text", () => {
|
||||
const p = computeLogoPlacement({ ...base, aspect: 4.6 });
|
||||
expect(p.w).toBeCloseTo(170, 6);
|
||||
expect(p.h).toBeCloseTo(170 / 4.6, 6);
|
||||
expect(p.stacked).toBe(true);
|
||||
});
|
||||
|
||||
it("an untrimmed 3:1 logo is height-limited (54 pt) and still stacks", () => {
|
||||
const p = computeLogoPlacement({ ...base, aspect: 3 });
|
||||
expect(p.h).toBe(54);
|
||||
expect(p.w).toBeCloseTo(162, 6);
|
||||
expect(p.stacked).toBe(true);
|
||||
});
|
||||
|
||||
it("a square logo is 54 x 54 and sits beside the text", () => {
|
||||
const p = computeLogoPlacement({ ...base, aspect: 1 });
|
||||
expect(p).toEqual({ w: 54, h: 54, stacked: false });
|
||||
});
|
||||
|
||||
it("stacks exactly when the text beside the logo would drop under the minimum", () => {
|
||||
// 299 - 54 - 14 = 231 beside a square; a 2:1 logo (108 wide) leaves 177; a 2.5:1 one (135) leaves 150.
|
||||
expect(computeLogoPlacement({ ...base, aspect: 2 }).stacked).toBe(false);
|
||||
expect(computeLogoPlacement({ ...base, aspect: 2.5 }).stacked).toBe(true);
|
||||
// The boundary: leaves exactly 160.
|
||||
const w = 299 - 14 - 160;
|
||||
expect(computeLogoPlacement({ ...base, aspect: w / 54 }).stacked).toBe(false);
|
||||
expect(computeLogoPlacement({ ...base, aspect: (w + 0.01) / 54 }).stacked).toBe(true);
|
||||
});
|
||||
|
||||
it("gives the logo more room on a wider column (Letter)", () => {
|
||||
const a4 = computeLogoPlacement({ ...base, aspect: 2.4, availableWidth: 299.28 });
|
||||
const letter = computeLogoPlacement({ ...base, aspect: 2.4, availableWidth: 316 });
|
||||
expect(a4.stacked).toBe(true);
|
||||
expect(letter.stacked).toBe(false);
|
||||
});
|
||||
|
||||
it("never exceeds the box, whatever the aspect", () => {
|
||||
for (const aspect of [0.2, 0.5, 1, 1.5, 3, 4.6, 10, 40]) {
|
||||
const p = computeLogoPlacement({ ...base, aspect });
|
||||
expect(p.w).toBeLessThanOrEqual(170 + 1e-9);
|
||||
expect(p.h).toBeLessThanOrEqual(54 + 1e-9);
|
||||
expect(p.w / p.h).toBeCloseTo(aspect, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the box aspect for a bad aspect", () => {
|
||||
const p = computeLogoPlacement({ ...base, aspect: Number.NaN });
|
||||
expect(p.w).toBeCloseTo(170, 6);
|
||||
expect(p.h).toBeCloseTo(54, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface LogoPlacementInput {
|
||||
/** Width / height of the image (use the trimmed ink aspect once the logo pipeline provides it). */
|
||||
aspect: number;
|
||||
/** The largest box the logo may fill, pt. */
|
||||
box: { w: number; h: number };
|
||||
/** Width of the column the logo and the text beside it share. */
|
||||
availableWidth: number;
|
||||
/** Narrowest the text may get beside the logo before the logo moves above it. */
|
||||
minTextWidth: number;
|
||||
/** Space between logo and text when side by side. Default 14. */
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
export interface LogoPlacement {
|
||||
w: number;
|
||||
h: number;
|
||||
/** True when the logo goes above the text instead of beside it. */
|
||||
stacked: boolean;
|
||||
}
|
||||
|
||||
export const LOGO_GAP = 14;
|
||||
|
||||
/**
|
||||
* Fits the logo inside `box` by its aspect (contain), then decides whether it still leaves the text
|
||||
* `minTextWidth` beside it. Same maths as the Phase 0 planLogo.
|
||||
*/
|
||||
export function computeLogoPlacement({ aspect, box, availableWidth, minTextWidth, gap = LOGO_GAP }: LogoPlacementInput): LogoPlacement {
|
||||
const a = Number.isFinite(aspect) && aspect > 0 ? aspect : box.w / box.h;
|
||||
const h = Math.min(box.h, box.w / a);
|
||||
const w = h * a;
|
||||
return { w, h, stacked: availableWidth - w - gap < minTextWidth };
|
||||
}
|
||||
|
||||
/*
|
||||
* Seam for reference templates: slots.generated.ts (measured placeholder boxes, see
|
||||
* scripts/logo/measure-placeholders.mjs) will supply a template's `logo.box` and may refine the fit.
|
||||
* Until it exists, every template declares its box by hand in its TemplateDefinition.
|
||||
*/
|
||||
@@ -0,0 +1,30 @@
|
||||
import { drawnText, getMeasurer } from "./measure";
|
||||
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
|
||||
export interface TextPlanOpts {
|
||||
weight?: number;
|
||||
italic?: boolean;
|
||||
faceOverrides?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** Predicted line count of `text` in a box `width` pt wide (greedy estimate; see Measurer.wrapLines). */
|
||||
export function textLines(role: RoleName | TypeToken, text: string, width: number, opts: TextPlanOpts = {}): number {
|
||||
const t = roleToken(role);
|
||||
return Math.max(
|
||||
1,
|
||||
getMeasurer().wrapLines(drawnText(t, text), Math.max(1, width), {
|
||||
face: resolveFace(t.face, opts.faceOverrides),
|
||||
weight: opts.weight ?? t.weight,
|
||||
italic: opts.italic ?? t.italic ?? false,
|
||||
size: t.size,
|
||||
letterSpacing: (t.tracking ?? 0) * t.size,
|
||||
}).length,
|
||||
);
|
||||
}
|
||||
|
||||
/** Predicted height in pt of one static Txt: lines times the role's leading. */
|
||||
export function textHeight(role: RoleName | TypeToken, text: string, width: number, opts: TextPlanOpts = {}): number {
|
||||
const t = roleToken(role);
|
||||
if (t.leading === undefined) throw new Error("textHeight needs a role with a leading");
|
||||
return textLines(role, text, width, opts) * t.leading;
|
||||
}
|
||||
Reference in New Issue
Block a user