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.
This commit is contained in:
2026-10-04 07:27:12 +05:30
parent 490c5b9009
commit 3f7c12b7dd
65 changed files with 3278 additions and 110 deletions
+86 -25
View File
@@ -32,20 +32,20 @@ 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 }) {
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}>{`GSTIN: ${value}`}</IdText>
<IdText role={role} color={color} {...(align ? { align, fill: true } : {})}>{`GSTIN: ${value}`}</IdText>
</View>
);
}
function PlaceOfSupply({ model, role = "body", color }: { model: RenderModel; role?: Role; color?: string }) {
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}>
<SafeTxt id={SLOT_ID.pos} role={role} color={color} align={align}>
{`Place of Supply: ${model.client.posLabel}`}
</SafeTxt>
);
@@ -62,26 +62,50 @@ export interface TaxRowsProps {
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 }: TaxRowsProps) {
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 }]} 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 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>
);
}
function AmountInWords({ model, role = "body", style }: { model: RenderModel; role?: Role; style?: Record<string, unknown> }) {
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}`}
@@ -100,25 +124,56 @@ function ReverseCharge({ model, role = "body", color, style }: { model: RenderMo
/** 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,
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,
/** 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): number {
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 ? 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");
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;
}
@@ -126,27 +181,33 @@ export function signatoryHeight(model: RenderModel, width: number): number {
* 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 }) {
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 right and bottom edge, just above the rule.
// 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: SIGNATURE_IMAGE_H, objectFit: "contain", objectPositionX: "100%", objectPositionY: "100%" } as never}
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, borderTopWidth: 0.8, borderTopColor: SIGNATURE_RULE, ...pinned(w) } as never}>
<SafeTxt role="bodyStrong" width={w} align="right">{name}</SafeTxt>
<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="body" width={w} align="right" color={INK_SUB}>{designation}</SafeTxt>
<SafeTxt role={roles.designation} width={w} align={align} color={custom ? opts.color : INK_SUB}>{designation}</SafeTxt>
) : null}
</View>
{!dataUri ? (
<Txt role="fine" color={FINE} width={w} align="right" style={{ marginTop: 6 }}>
<Txt role={roles.fine} color={custom ? opts.color : FINE} width={w} align={align} style={{ marginTop: 6 }}>
{FINE_LINE}
</Txt>
) : null}