Files
Voiced/src/pdf/blocks/slots.tsx
T
xavierk 3f7c12b7dd Add the Neutral template family, template picker and text-only logo allowances
Linea (the vendor's original format.pdf design), Monogram and Serenity are
rebuilt from the Canva PDFs as one parametric family on the engine, with
the user's logo in the measured slot, a home for every GST field and
A4/Letter support. Adds static Montserrat, Open Sans and Poppins fonts and
a generated Ms Madi outline for Serenity's vertical word. Text-only logo
placeholders (Monolith, Tangerine) use a doc-derived allowance box. A
Carbon TemplatePicker with committed thumbnails opens from the page
setup controls.
2026-10-04 07:27:12 +05:30

219 lines
9.8 KiB
TypeScript

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, align }: { model: RenderModel; party?: "supplier" | "client"; role?: Role; color?: string; align?: "left" | "right" }) {
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} {...(align ? { align, fill: true } : {})}>{`GSTIN: ${value}`}</IdText>
</View>
);
}
function PlaceOfSupply({ model, role = "body", color, align }: { model: RenderModel; role?: Role; color?: string; align?: "left" | "right" }) {
if (!model.client.posLabel) return null;
return (
<SafeTxt id={SLOT_ID.pos} role={role} color={color} align={align}>
{`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>;
/** Label text; default "CGST (9%)". */
format?: (label: string, rateText: string | undefined) => string;
labelAlign?: "left" | "right";
/** Space between the label and the amount column. */
labelGap?: number;
color?: string;
}
/** 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, format, labelAlign, labelGap = 0, color }: TaxRowsProps) {
const w = useAvailableWidth();
if (model.totals.taxRows.length === 0) return null;
const text = format ?? ((label: string, rate: string | undefined) => (rate ? `${label} (${rate})` : label));
return (
<View id={SLOT_ID.taxRows}>
{model.totals.taxRows.map((r, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} gap={labelGap} width={w} style={rowStyle}>
<Txt role={role} color={color} align={labelAlign} width={w - amountWidth - labelGap}>{text(r.label, r.rateText)}</Txt>
<Txt role={role} color={color} width={amountWidth} align="right">{r.amountText}</Txt>
</Row>
))}
</View>
);
}
export interface AmountInWordsProps {
model: RenderModel;
role?: Role;
style?: Record<string, unknown>;
/** Without it the label and the words are one italic paragraph (Classic); with it the label is its own line in this role. */
labelRole?: Role;
color?: string;
}
function AmountInWords({ model, role = "body", style, labelRole, color }: AmountInWordsProps) {
if (!model.amountInWords) return null;
if (labelRole) {
return (
<View id={SLOT_ID.amountInWords} style={style as never}>
<SafeTxt role={labelRole} color={color} style={{ marginBottom: 2 }}>Amount in words:</SafeTxt>
<SafeTxt role={role} color={color}>{model.amountInWords}</SafeTxt>
</View>
);
}
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 = {
/** Lines the GSTIN slot prints for a party (0 or 1; an identifier that cannot fit may wrap further, which only makes the page longer). */
gstinLines: (model: RenderModel, party: "supplier" | "client"): number =>
(party === "supplier" ? (model.slots.gstin && model.supplier.gstin) : model.client.gstin) ? 1 : 0,
/** Lines the place-of-supply slot prints at `width`. */
placeOfSupplyLines: (model: RenderModel, role: Role, width: number): number =>
model.client.posLabel ? textLines(role, `Place of Supply: ${model.client.posLabel}`, width) : 0,
/** Whether the item has an HSN/SAC value to print (a code, not the "—" placeholder). */
hasHsn: (value: string): boolean => value !== "—",
/** Number of tax rows (each is one body line plus the template's row padding). */
taxRowCount: (model: RenderModel): number => model.totals.taxRows.length,
/** Height of AmountInWords at `width`; pass the same roles the layout prints it in. */
amountInWords: (model: RenderModel, width: number, roles: { role?: Role; labelRole?: Role } = {}): number => {
if (!model.amountInWords) return 0;
const role = roles.role ?? "body";
const leading = (r: Role) => roleToken(r).leading as number;
if (roles.labelRole) {
return textLines(roles.labelRole, "Amount in words:", width) * leading(roles.labelRole) + 2 + textLines(role, model.amountInWords, width) * leading(role);
}
return textLines(role, `Amount in words: ${model.amountInWords}`, width, { italic: true }) * leading(role);
},
reverseCharge: (model: RenderModel, width: number, role: Role = "small"): number =>
model.reverseCharge ? textLines(role, model.reverseCharge, width) * (roleToken(role).leading as number) : 0,
};
export const SIGNATURE_IMAGE_H = 58;
const FINE_LINE = "Computer-generated invoice; no signature is required.";
export interface SignatoryOptions {
roles?: { name: Role; designation: Role; fine: Role };
/** Text and picture alignment; the default is right. */
align?: "left" | "right";
/** Rule between the picture and the name. Default: a 0.8 pt grey one (Classic); a template passes its ink. */
rule?: { width: number; color: string } | null;
color?: string;
imageHeight?: number;
}
const SIGN_ROLES = { name: "bodyStrong", designation: "body", fine: "fine" } as const satisfies NonNullable<SignatoryOptions["roles"]>;
const DEFAULT_RULE = { width: 0.8, color: SIGNATURE_RULE };
/** Height of the signatory block at `width`, for the closing block's presence value. */
export function signatoryHeight(model: RenderModel, width: number, opts: SignatoryOptions = {}): number {
const leading = (r: Role) => roleToken(r).leading as number;
const roles = opts.roles ?? SIGN_ROLES;
const rule = opts.rule === undefined ? DEFAULT_RULE : opts.rule;
const { name, designation, dataUri } = model.signature;
let h = (dataUri ? (opts.imageHeight ?? SIGNATURE_IMAGE_H) : 0) + 4 + (rule?.width ?? 0) + 3;
h += textLines(roles.name, name, width) * leading(roles.name);
if (designation) h += textLines(roles.designation, designation, width) * leading(roles.designation);
if (!dataUri) h += 6 + textLines(roles.fine, FINE_LINE, width) * leading(roles.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, ...opts }: { model: RenderModel; width?: number } & SignatoryOptions) {
const avail = useAvailableWidth();
const w = width ?? avail;
const { name, designation, dataUri } = model.signature;
const roles = opts.roles ?? SIGN_ROLES;
const align = opts.align ?? "right";
const rule = opts.rule === undefined ? DEFAULT_RULE : opts.rule;
const custom = opts.color !== undefined;
return (
<View id={SLOT_ID.signatory} style={pinned(w) as never}>
{dataUri ? (
// Pinned box (an Image may be pinned); the picture sits at its edge and bottom, just above the rule.
<Image
src={dataUri}
style={{ ...pinned(w), height: opts.imageHeight ?? SIGNATURE_IMAGE_H, objectFit: "contain", objectPositionX: align === "right" ? "100%" : "0%", objectPositionY: "100%" } as never}
/>
) : null}
<View
style={{ marginTop: 4, paddingTop: 3, ...(rule ? { borderTopWidth: rule.width, borderTopColor: rule.color } : {}), ...pinned(w) } as never}
>
<SafeTxt role={roles.name} width={w} align={align} color={opts.color}>{name}</SafeTxt>
{designation ? (
<SafeTxt role={roles.designation} width={w} align={align} color={custom ? opts.color : INK_SUB}>{designation}</SafeTxt>
) : null}
</View>
{!dataUri ? (
<Txt role={roles.fine} color={custom ? opts.color : FINE} width={w} align={align} style={{ marginTop: 6 }}>
{FINE_LINE}
</Txt>
) : null}
</View>
);
}
export const Slot = { Gstin, PlaceOfSupply, HsnSac: hsnSacColumn, TaxRows, AmountInWords, Signatory, ReverseCharge };