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:
@@ -10,6 +10,11 @@ import { pdfFlags } from "./pdfProps";
|
||||
import { Cell, Row } from "./Row";
|
||||
import { Txt } from "./Txt";
|
||||
|
||||
/** A cell: one string, or several (one Txt each, stacked), for multi-line values. */
|
||||
export type TableCellValue = string | string[];
|
||||
|
||||
const cellParts = (v: TableCellValue): string[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]);
|
||||
|
||||
export interface TableRoles {
|
||||
head: RoleName | TypeToken;
|
||||
body: RoleName | TypeToken;
|
||||
@@ -22,6 +27,8 @@ export interface TableLook {
|
||||
padY?: number;
|
||||
/** Bottom border of each row in pt (counted in the row height). Default 0. */
|
||||
border?: number;
|
||||
/** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */
|
||||
padRight?: number[];
|
||||
faceOverrides?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
@@ -30,8 +37,8 @@ export interface ItemsTablePlanInput extends TableLook {
|
||||
gap: number;
|
||||
/** Table width in pt (the content width of the page, or of the cell holding the table). */
|
||||
width: number;
|
||||
header: string[];
|
||||
rows: string[][];
|
||||
header: TableCellValue[];
|
||||
rows: TableCellValue[][];
|
||||
measurer: Measurer;
|
||||
roles: TableRoles;
|
||||
/** Height of whatever follows the last row and must stay with it (the totals). */
|
||||
@@ -49,20 +56,23 @@ export interface ItemsTablePlan {
|
||||
rowLines: number[];
|
||||
}
|
||||
|
||||
function cellHeight(m: Measurer, role: RoleName | TypeToken, text: string, inner: number, look: TableLook): { lines: number; height: number } {
|
||||
function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellValue, inner: number, look: TableLook): { lines: number; height: number } {
|
||||
const t = roleToken(role);
|
||||
if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan");
|
||||
if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
|
||||
const lines = Math.max(
|
||||
1,
|
||||
m.wrapLines(drawnText(t, text), inner, {
|
||||
face: resolveFace(t.face, look.faceOverrides),
|
||||
weight: t.weight,
|
||||
italic: t.italic ?? false,
|
||||
size: t.size,
|
||||
letterSpacing: (t.tracking ?? 0) * t.size,
|
||||
}).length,
|
||||
);
|
||||
let lines = 0;
|
||||
for (const text of cellParts(value)) {
|
||||
if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
|
||||
lines += Math.max(
|
||||
1,
|
||||
m.wrapLines(drawnText(t, text), inner, {
|
||||
face: resolveFace(t.face, look.faceOverrides),
|
||||
weight: t.weight,
|
||||
italic: t.italic ?? false,
|
||||
size: t.size,
|
||||
letterSpacing: (t.tracking ?? 0) * t.size,
|
||||
}).length,
|
||||
);
|
||||
}
|
||||
return { lines, height: lines * t.leading };
|
||||
}
|
||||
|
||||
@@ -84,8 +94,8 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
|
||||
for (const r of rows) if (r.length !== columns.length) throw new PlanError(`row has ${r.length} cells for ${columns.length} columns`, "invalid-plan");
|
||||
|
||||
const { widths } = colsDetailed(width, columns, gap);
|
||||
const inner = widths.map((w) => Math.max(0, w - 2 * padX));
|
||||
const heightOf = (role: RoleName | TypeToken, cells: string[]) => {
|
||||
const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0)));
|
||||
const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[]) => {
|
||||
const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input));
|
||||
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * padY + border };
|
||||
};
|
||||
@@ -110,8 +120,8 @@ export interface TableRowsProps extends TableLook {
|
||||
columns: ColSpec[];
|
||||
gap: number;
|
||||
width: number;
|
||||
header: string[];
|
||||
rows: string[][];
|
||||
header: TableCellValue[];
|
||||
rows: TableCellValue[][];
|
||||
roles: TableRoles;
|
||||
/** Per column; default left. */
|
||||
align?: Array<"left" | "right" | "center">;
|
||||
@@ -122,6 +132,8 @@ export interface TableRowsProps extends TableLook {
|
||||
headerStyle?: Record<string, unknown>;
|
||||
rowStyle?: Record<string, unknown>;
|
||||
borderColor?: string;
|
||||
/** Text colour of the header cells. */
|
||||
headColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +141,7 @@ export interface TableRowsProps extends TableLook {
|
||||
* `wrap={false}` row per item. The container has no padding or border; those go on the rows, because
|
||||
* the repeated header is positioned against the container's top.
|
||||
*/
|
||||
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, ...look }: TableRowsProps) {
|
||||
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, ...look }: TableRowsProps) {
|
||||
const padX = look.padX ?? 0;
|
||||
const padY = look.padY ?? 4;
|
||||
const border = look.border ?? 0;
|
||||
@@ -138,12 +150,14 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
|
||||
...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
|
||||
...extra,
|
||||
});
|
||||
const cells = (role: RoleName | TypeToken, values: string[]) =>
|
||||
const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string) =>
|
||||
values.map((v, i) => (
|
||||
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX }}>
|
||||
<Txt role={role} width={Math.max(0, widths[i] - 2 * padX)} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||
{v}
|
||||
</Txt>
|
||||
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}>
|
||||
{cellParts(v).map((part, li) => (
|
||||
<Txt key={li} role={role} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||
{part}
|
||||
</Txt>
|
||||
))}
|
||||
</Cell>
|
||||
));
|
||||
|
||||
@@ -151,7 +165,7 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
|
||||
<AvailableWidthProvider width={width}>
|
||||
<View>
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
|
||||
{cells(roles.head, header)}
|
||||
{cells(roles.head, header, headColor)}
|
||||
</Row>
|
||||
{rows.map((r, i) => (
|
||||
<Row
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import { FILL } from "../engine/columns";
|
||||
import { chromeHeight } from "../engine/metrics";
|
||||
import { getMeasurer, measureToken } from "../engine/measure";
|
||||
import type { PageFrame } from "../engine/geometry";
|
||||
@@ -7,12 +8,21 @@ import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
import { PlanError } from "../engine/errors";
|
||||
import type { DecorItem } from "./FirstPageHeader";
|
||||
import { pdfFlags } from "./pdfProps";
|
||||
import { FitText } from "./FitText";
|
||||
import { RenderTxt } from "./RenderTxt";
|
||||
|
||||
export interface PageChromeProps {
|
||||
frame: PageFrame;
|
||||
/** Left footer text (e.g. the invoice number); omit for none. */
|
||||
footerLeft?: string;
|
||||
/**
|
||||
* Pins the left text to this width (pt) and fits it to one line (shrunk, then cut with an ellipsis).
|
||||
* A render-prop Text cannot be clamped, so a long supplier name there would wrap and push the footer
|
||||
* up into the content. Without it the left text takes the remaining width.
|
||||
*/
|
||||
footerLeftWidth?: number;
|
||||
/** Centre text between the left text and the page label. */
|
||||
footerCenter?: string;
|
||||
/** Show "Page n of N" at the right of the footer. Default true. */
|
||||
pageLabel?: boolean;
|
||||
/** Footer and band type; render-prop roles (no leading). Default "footer". */
|
||||
@@ -26,6 +36,16 @@ export interface PageChromeProps {
|
||||
decor?: DecorItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* FitText is static text and needs a leading; the footer role has none (render-prop text), so the
|
||||
* fitted left text borrows 1.4x the size, which stays within the auto-height line that chromeHeight
|
||||
* reserves for the same role.
|
||||
*/
|
||||
function fineFooter(role: RoleName | TypeToken): TypeToken {
|
||||
const t = roleToken(role);
|
||||
return { ...t, leading: Math.round(t.size * 1.4 * 10) / 10 };
|
||||
}
|
||||
|
||||
/** "Page 3 of 5"; before the total is known (first layout pass) just "Page 3". */
|
||||
export function pageLabelText(pageNumber: number, totalPages: number | undefined): string {
|
||||
return typeof totalPages === "number" && totalPages > 0 ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`;
|
||||
@@ -48,6 +68,8 @@ export function pageLabelText(pageNumber: number, totalPages: number | undefined
|
||||
export function PageChrome({
|
||||
frame,
|
||||
footerLeft,
|
||||
footerLeftWidth,
|
||||
footerCenter,
|
||||
pageLabel = true,
|
||||
role = "footer",
|
||||
color,
|
||||
@@ -57,7 +79,7 @@ export function PageChrome({
|
||||
}: PageChromeProps) {
|
||||
const { pad } = frame;
|
||||
const need = chromeHeight(role);
|
||||
const showFooter = pageLabel || footerLeft !== undefined;
|
||||
const showFooter = pageLabel || footerLeft !== undefined || footerCenter !== undefined;
|
||||
if (showFooter && frame.footerReserve < need) {
|
||||
throw new PlanError(`footerReserve ${frame.footerReserve} pt is under the ${need} pt a "${roleToken(role).size} pt" footer line needs`, "chrome-reserve");
|
||||
}
|
||||
@@ -105,7 +127,16 @@ export function PageChrome({
|
||||
style={{ position: "absolute", left: pad.left, bottom: marginBottom, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||
{...pdfFlags({ fixed: true })}
|
||||
>
|
||||
{footerLeft !== undefined && <RenderTxt role={role} color={color} grow render={() => footerLeft} />}
|
||||
{footerLeft !== undefined &&
|
||||
(footerLeftWidth !== undefined ? (
|
||||
<FitText role={fineFooter(role)} width={footerLeftWidth} color={color}>
|
||||
{footerLeft}
|
||||
</FitText>
|
||||
) : (
|
||||
<RenderTxt role={role} color={color} grow render={() => footerLeft} />
|
||||
))}
|
||||
{footerCenter !== undefined && <RenderTxt role={role} color={color} align="center" grow render={() => footerCenter} />}
|
||||
{footerCenter === undefined && footerLeftWidth !== undefined && <View style={FILL as never} />}
|
||||
{pageLabel && (
|
||||
<RenderTxt
|
||||
id={PAGE_LABEL_ID}
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface RowProps extends PdfFlagsInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* A flex row whose children each sit in a cell of explicit width: fixed and fractional cells are pinned
|
||||
* A flex row, pinned to its total width (a row inside a flex-start parent would otherwise shrink to its content and leave its fill cell empty), 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.
|
||||
@@ -38,7 +38,7 @@ export function Row({ id, widths, gap = 0, stackBelow, align = "flex-start", wid
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<View style={{ flexDirection: "column", alignItems: "flex-start", ...style } as never} {...flags}>
|
||||
<View style={{ flexDirection: "column", alignItems: "flex-start", ...pinned(total), ...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>
|
||||
@@ -49,7 +49,7 @@ export function Row({ id, widths, gap = 0, stackBelow, align = "flex-start", wid
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row", alignItems: align, ...style } as never} {...flags}>
|
||||
<View style={{ flexDirection: "row", alignItems: align, ...pinned(total), ...style } as never} {...flags}>
|
||||
{kids.map((child, i) => {
|
||||
const isFill = "fill" in widths[i];
|
||||
const box = isFill ? FILL : pinned(w[i]);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getMeasurer, measureToken } from "../engine/measure";
|
||||
import { resolveFace, roleToken } from "../fonts/roles";
|
||||
import { useAvailableWidth } from "./AvailableWidth";
|
||||
import { IdText } from "./IdText";
|
||||
import { Txt, type TxtProps } from "./Txt";
|
||||
|
||||
export interface SafeTxtProps extends Omit<TxtProps, "children" | "width" | "maxLines" | "grow"> {
|
||||
children: string;
|
||||
/** Width of the box; defaults to the available width of the enclosing cell. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free text (addresses, names, notes) that cannot overflow: normal text wraps at spaces in a box of the
|
||||
* cell's width; a value with a single token wider than the cell (a long URL or an unbroken string) goes
|
||||
* through IdText, which shrinks it or splits it at "@ . / - _". textkit never breaks inside a word,
|
||||
* so without this such a token would run out of its box.
|
||||
*/
|
||||
export function SafeTxt({ children, width, align, ...rest }: SafeTxtProps) {
|
||||
const avail = useAvailableWidth();
|
||||
const w = width ?? avail;
|
||||
const token = roleToken(rest.role);
|
||||
const m = getMeasurer();
|
||||
const family = resolveFace(token.face, rest.faceOverrides);
|
||||
const widest = Math.max(
|
||||
0,
|
||||
...children.split(" ").map((word) => measureToken(m, token, word, { weight: rest.weight, italic: rest.italic, family })),
|
||||
);
|
||||
if (widest > w + 1e-6) {
|
||||
return (
|
||||
<IdText {...rest} width={w}>
|
||||
{children}
|
||||
</IdText>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Txt {...rest} align={align} width={w}>
|
||||
{children}
|
||||
</Txt>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface TxtStyleProps {
|
||||
}
|
||||
|
||||
export interface TxtProps extends TxtStyleProps, PdfFlagsInput {
|
||||
/** react-pdf copies the id into the layout tree (audits and contract tests find nodes by it). */
|
||||
id?: string;
|
||||
role: RoleName | TypeToken;
|
||||
/** Clamp to this many lines and end with an ellipsis. */
|
||||
maxLines?: number;
|
||||
@@ -56,7 +58,7 @@ export function assertNoLineHeight(style: Record<string, unknown> | undefined, w
|
||||
* 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) {
|
||||
export function Txt({ id, 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");
|
||||
@@ -68,5 +70,5 @@ export function Txt({ role, color, align, weight, italic, maxLines, width, grow,
|
||||
...(maxLines !== undefined ? { maxLines, textOverflow: "ellipsis" } : {}),
|
||||
...style,
|
||||
};
|
||||
return <Text style={merged as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })}>{children as ReactNode}</Text>;
|
||||
return <Text {...(id !== undefined ? { id } : {})} style={merged as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })}>{children as ReactNode}</Text>;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,23 @@ describe("PageChrome", () => {
|
||||
expect(label.box.y + label.box.h).toBeLessThanOrEqual(300 - PAD + 0.01);
|
||||
});
|
||||
|
||||
it("pins and fits a long left footer text to one line and centres the middle text", async () => {
|
||||
const long = "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt";
|
||||
const el = doc(
|
||||
[...rows(8), h(PageChrome, { key: "chrome", frame, footerLeft: long, footerLeftWidth: 120, footerCenter: "Centre text" })],
|
||||
{ pad: padding },
|
||||
);
|
||||
const r = await renderCore(el, { frame });
|
||||
expect(r.issues).toEqual([]);
|
||||
const texts = find(r.layout[0], (n) => n.type === "text" && n.fixed === true && n.box.y > 250);
|
||||
const left = texts.find((n) => n.text?.startsWith("Venkatachalam"))!;
|
||||
expect(left.lines).toHaveLength(1);
|
||||
expect(left.lines![0].text.endsWith("…")).toBe(true);
|
||||
expect(left.box.w).toBeCloseTo(120, 1);
|
||||
const centre = texts.find((n) => n.lines?.some((l) => l.text === "Centre text"))!;
|
||||
expect(centre.lines).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a footer reserve smaller than the line", () => {
|
||||
const tight = frameFor({ footerReserve: 4 });
|
||||
expect(() => PageChrome({ frame: tight })).toThrow(PlanError);
|
||||
@@ -130,6 +147,21 @@ describe("planItemsTable / TableRows", () => {
|
||||
body.forEach((_, i) => expect(byId(page, `${TABLE_ROW_PREFIX}${i}`)[0].box.h).toBeCloseTo(p.rowHeights[i], 1));
|
||||
});
|
||||
|
||||
it("multi-line cells (one Txt per line) and per-column right padding match the laid-out heights", async () => {
|
||||
const multi = [
|
||||
[["First line", "Second line", "Third line that is long enough to wrap onto another line in a narrow column"], "1"],
|
||||
["Single", "2"],
|
||||
];
|
||||
const padRight = [6, 0];
|
||||
const p = planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: multi, measurer: getMeasurer(), roles, padY: 4, border: 0.5, padRight });
|
||||
expect(p.rowLines[0]).toBeGreaterThanOrEqual(4);
|
||||
const el = doc(
|
||||
h(AvailableWidthProvider, { width: WIDTH }, h(TableRows, { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: multi, roles, padY: 4, border: 0.5, padRight })),
|
||||
);
|
||||
const page = (await renderCore(el)).layout[0];
|
||||
multi.forEach((_, i) => expect(byId(page, `${TABLE_ROW_PREFIX}${i}`)[0].box.h).toBeCloseTo(p.rowHeights[i], 1));
|
||||
});
|
||||
|
||||
it("throws a typed PlanError when the totals tail can never share a page with the header and last row", () => {
|
||||
const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 };
|
||||
const p = plan();
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Image, View } from "@react-pdf/renderer";
|
||||
import { pinned } from "../engine/columns";
|
||||
import { textLines } from "../engine/textplan";
|
||||
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
import type { RenderModel } from "../model/build";
|
||||
import { useAvailableWidth } from "./AvailableWidth";
|
||||
import { IdText } from "./IdText";
|
||||
import { Row } from "./Row";
|
||||
import { SafeTxt } from "./SafeTxt";
|
||||
import { Txt } from "./Txt";
|
||||
|
||||
/*
|
||||
* Regulated data (GSTIN, place of supply, HSN/SAC, tax rows, amount in words, signatory, reverse
|
||||
* charge) is printed ONLY through these components, so one contract test can prove that every
|
||||
* template carries what a document type requires (model/slots.ts) and nothing it forbids. Each slot
|
||||
* stamps an id (`slot-*`) on its output so the test finds it in the layout tree. Templates must not
|
||||
* read these model fields themselves (slots.lint.test.ts).
|
||||
*/
|
||||
|
||||
export const SLOT_ID = {
|
||||
gstin: "slot-gstin",
|
||||
pos: "slot-pos",
|
||||
taxRows: "slot-tax-rows",
|
||||
amountInWords: "slot-words",
|
||||
signatory: "slot-signatory",
|
||||
reverseCharge: "slot-reverse-charge",
|
||||
} as const;
|
||||
|
||||
type Role = RoleName | TypeToken;
|
||||
|
||||
const INK_SUB = "#393939";
|
||||
const FINE = "#6f6f6f";
|
||||
const SIGNATURE_RULE = "#8d8d8d";
|
||||
|
||||
function Gstin({ model, party = "supplier", role = "body", color }: { model: RenderModel; party?: "supplier" | "client"; role?: Role; color?: string }) {
|
||||
const value = party === "supplier" ? (model.slots.gstin ? model.supplier.gstin : null) : model.client.gstin;
|
||||
if (!value) return null;
|
||||
return (
|
||||
<View id={`${SLOT_ID.gstin}-${party}`}>
|
||||
<IdText role={role} color={color}>{`GSTIN: ${value}`}</IdText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceOfSupply({ model, role = "body", color }: { model: RenderModel; role?: Role; color?: string }) {
|
||||
if (!model.client.posLabel) return null;
|
||||
return (
|
||||
<SafeTxt id={SLOT_ID.pos} role={role} color={color}>
|
||||
{`Place of Supply: ${model.client.posLabel}`}
|
||||
</SafeTxt>
|
||||
);
|
||||
}
|
||||
|
||||
/** The HSN/SAC column: header and one value per item row, or null when no item carries a code. */
|
||||
function hsnSacColumn(model: RenderModel): { header: string; values: string[] } | null {
|
||||
if (!model.slots.hsn) return null;
|
||||
return { header: "HSN/SAC", values: model.items.map((i) => i.hsnSac ?? "—") };
|
||||
}
|
||||
|
||||
export interface TaxRowsProps {
|
||||
model: RenderModel;
|
||||
role?: Role;
|
||||
amountWidth: number;
|
||||
rowStyle?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** CGST/SGST (UTGST) or IGST rows, as label (rate) on the left and the amount on the right. */
|
||||
function TaxRows({ model, role = "body", amountWidth, rowStyle }: TaxRowsProps) {
|
||||
const w = useAvailableWidth();
|
||||
if (model.totals.taxRows.length === 0) return null;
|
||||
return (
|
||||
<View id={SLOT_ID.taxRows}>
|
||||
{model.totals.taxRows.map((r, i) => (
|
||||
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} width={w} style={rowStyle}>
|
||||
<Txt role={role} width={w - amountWidth}>{r.rateText ? `${r.label} (${r.rateText})` : r.label}</Txt>
|
||||
<Txt role={role} width={amountWidth} align="right">{r.amountText}</Txt>
|
||||
</Row>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AmountInWords({ model, role = "body", style }: { model: RenderModel; role?: Role; style?: Record<string, unknown> }) {
|
||||
if (!model.amountInWords) return null;
|
||||
return (
|
||||
<SafeTxt id={SLOT_ID.amountInWords} role={role} italic style={style}>
|
||||
{`Amount in words: ${model.amountInWords}`}
|
||||
</SafeTxt>
|
||||
);
|
||||
}
|
||||
|
||||
function ReverseCharge({ model, role = "body", color, style }: { model: RenderModel; role?: Role; color?: string; style?: Record<string, unknown> }) {
|
||||
if (!model.reverseCharge) return null;
|
||||
return (
|
||||
<SafeTxt id={SLOT_ID.reverseCharge} role={role} color={color} style={style}>
|
||||
{model.reverseCharge}
|
||||
</SafeTxt>
|
||||
);
|
||||
}
|
||||
|
||||
/** Predicted heights of the slots that sit in the totals block, for its presence value. */
|
||||
export const slotHeights = {
|
||||
/** Number of tax rows (each is one body line plus the template's row padding). */
|
||||
taxRowCount: (model: RenderModel): number => model.totals.taxRows.length,
|
||||
amountInWords: (model: RenderModel, width: number): number =>
|
||||
model.amountInWords ? textLines("body", `Amount in words: ${model.amountInWords}`, width, { italic: true }) * (roleToken("body").leading as number) : 0,
|
||||
reverseCharge: (model: RenderModel, width: number): number =>
|
||||
model.reverseCharge ? textLines("small", model.reverseCharge, width) * (roleToken("small").leading as number) : 0,
|
||||
};
|
||||
|
||||
export const SIGNATURE_IMAGE_H = 58;
|
||||
const FINE_LINE = "Computer-generated invoice; no signature is required.";
|
||||
|
||||
/** Height of the signatory block at `width`, for the closing block's presence value. */
|
||||
export function signatoryHeight(model: RenderModel, width: number): number {
|
||||
const leading = (r: Role) => roleToken(r).leading as number;
|
||||
const { name, designation, dataUri } = model.signature;
|
||||
let h = (dataUri ? SIGNATURE_IMAGE_H : 0) + 4 + 0.8 + 3;
|
||||
h += textLines("bodyStrong", name, width) * leading("bodyStrong");
|
||||
if (designation) h += textLines("body", designation, width) * leading("body");
|
||||
if (!dataUri) h += 6 + textLines("fine", FINE_LINE, width) * leading("fine");
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature image (if any), a rule, the signatory's name and designation. Without an image the
|
||||
* "computer-generated" line stands in for the signature.
|
||||
*/
|
||||
function Signatory({ model, width }: { model: RenderModel; width?: number }) {
|
||||
const avail = useAvailableWidth();
|
||||
const w = width ?? avail;
|
||||
const { name, designation, dataUri } = model.signature;
|
||||
return (
|
||||
<View id={SLOT_ID.signatory} style={pinned(w) as never}>
|
||||
{dataUri ? (
|
||||
// Pinned box (an Image may be pinned); the picture sits at its right and bottom edge, just above the rule.
|
||||
<Image
|
||||
src={dataUri}
|
||||
style={{ ...pinned(w), height: SIGNATURE_IMAGE_H, objectFit: "contain", objectPositionX: "100%", objectPositionY: "100%" } as never}
|
||||
/>
|
||||
) : null}
|
||||
<View style={{ marginTop: 4, paddingTop: 3, borderTopWidth: 0.8, borderTopColor: SIGNATURE_RULE, ...pinned(w) } as never}>
|
||||
<SafeTxt role="bodyStrong" width={w} align="right">{name}</SafeTxt>
|
||||
{designation ? (
|
||||
<SafeTxt role="body" width={w} align="right" color={INK_SUB}>{designation}</SafeTxt>
|
||||
) : null}
|
||||
</View>
|
||||
{!dataUri ? (
|
||||
<Txt role="fine" color={FINE} width={w} align="right" style={{ marginTop: 6 }}>
|
||||
{FINE_LINE}
|
||||
</Txt>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const Slot = { Gstin, PlaceOfSupply, HsnSac: hsnSacColumn, TaxRows, AmountInWords, Signatory, ReverseCharge };
|
||||
Reference in New Issue
Block a user