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:
@@ -17,6 +17,8 @@ export interface IdTextProps extends Omit<TxtProps, "children" | "role" | "maxLi
|
||||
/** Width to fit; defaults to the available width of the enclosing cell. */
|
||||
width?: number;
|
||||
minSize?: number;
|
||||
/** Pin the single-line box to the target width, so `align` (right, centre) works. Chunked mode aligns the chunk row as a whole. */
|
||||
fill?: boolean;
|
||||
/** For tests and audits only. */
|
||||
onFit?: (info: IdTextInfo) => void;
|
||||
}
|
||||
@@ -27,7 +29,7 @@ export interface IdTextProps extends Omit<TxtProps, "children" | "role" | "maxLi
|
||||
* it is split at "@ . / - _" into a flex-wrap row of adjacent Text chunks (no gaps, so extraction
|
||||
* joins them back into the original string).
|
||||
*/
|
||||
export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTextProps) {
|
||||
export function IdText({ role, children, width, minSize, fill, onFit, ...rest }: IdTextProps) {
|
||||
const avail = useAvailableWidth();
|
||||
const target = width ?? avail;
|
||||
const token = roleToken(role);
|
||||
@@ -35,7 +37,11 @@ export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTex
|
||||
|
||||
if (fit.fits) {
|
||||
onFit?.({ ...fit, mode: "single", chunks: [children] });
|
||||
return <Txt {...rest} role={{ ...token, size: fit.size }}>{children}</Txt>;
|
||||
return (
|
||||
<Txt {...rest} role={{ ...token, size: fit.size }} {...(fill ? { width: target } : {})}>
|
||||
{children}
|
||||
</Txt>
|
||||
);
|
||||
}
|
||||
|
||||
const sized: TypeToken = { ...token, size: fit.size };
|
||||
@@ -53,7 +59,16 @@ export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTex
|
||||
}
|
||||
onFit?.({ ...fit, mode: "chunked", chunks });
|
||||
return (
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", width: target, minWidth: target, maxWidth: target } as never}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
width: target,
|
||||
minWidth: target,
|
||||
maxWidth: target,
|
||||
...(rest.align === "right" ? { justifyContent: "flex-end" } : rest.align === "center" ? { justifyContent: "center" } : {}),
|
||||
} as never}
|
||||
>
|
||||
{chunks.map((c, i) => (
|
||||
<Txt key={i} {...rest} align={undefined} role={sized}>
|
||||
{c}
|
||||
|
||||
@@ -10,10 +10,14 @@ 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[];
|
||||
/** One line of a cell: text in the table's role, or in a role of its own (a small sub-line under a description). */
|
||||
export type CellPart = string | { text: string; role: RoleName | TypeToken };
|
||||
/** A cell: one string, or several parts (one Txt each, stacked), for multi-line values. */
|
||||
export type TableCellValue = string | CellPart[];
|
||||
|
||||
const cellParts = (v: TableCellValue): string[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]);
|
||||
const cellParts = (v: TableCellValue): CellPart[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]);
|
||||
const partText = (p: CellPart) => (typeof p === "string" ? p : p.text);
|
||||
const partRole = (p: CellPart, role: RoleName | TypeToken) => (typeof p === "string" ? role : p.role);
|
||||
|
||||
export interface TableRoles {
|
||||
head: RoleName | TypeToken;
|
||||
@@ -30,6 +34,8 @@ export interface TableLook {
|
||||
/** 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>>;
|
||||
/** Height of the `headerTail` TableRows draws under the header row (a rule and its spacing); counted in the header height. */
|
||||
headerTailH?: number;
|
||||
}
|
||||
|
||||
export interface ItemsTablePlanInput extends TableLook {
|
||||
@@ -57,12 +63,14 @@ export interface ItemsTablePlan {
|
||||
}
|
||||
|
||||
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");
|
||||
let lines = 0;
|
||||
for (const text of cellParts(value)) {
|
||||
let height = 0;
|
||||
for (const part of cellParts(value)) {
|
||||
const t = roleToken(partRole(part, role));
|
||||
if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan");
|
||||
const text = partText(part);
|
||||
if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
|
||||
lines += Math.max(
|
||||
const n = Math.max(
|
||||
1,
|
||||
m.wrapLines(drawnText(t, text), inner, {
|
||||
face: resolveFace(t.face, look.faceOverrides),
|
||||
@@ -72,8 +80,10 @@ function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellVal
|
||||
letterSpacing: (t.tracking ?? 0) * t.size,
|
||||
}).length,
|
||||
);
|
||||
lines += n;
|
||||
height += n * t.leading;
|
||||
}
|
||||
return { lines, height: lines * t.leading };
|
||||
return { lines, height };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +110,7 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
|
||||
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * padY + border };
|
||||
};
|
||||
|
||||
const headH = heightOf(roles.head, header).height;
|
||||
const headH = heightOf(roles.head, header).height + (input.headerTailH ?? 0);
|
||||
const measured = rows.map((r) => heightOf(roles.body, r));
|
||||
const rowHeights = measured.map((r) => r.height);
|
||||
|
||||
@@ -134,6 +144,12 @@ export interface TableRowsProps extends TableLook {
|
||||
borderColor?: string;
|
||||
/** Text colour of the header cells. */
|
||||
headColor?: string;
|
||||
/**
|
||||
* Drawn under the header row inside the same fixed group, so it repeats with the header on every page
|
||||
* (a rule and the gap below it: nothing under a repeated header may depend on a first-page-only margin).
|
||||
* Pass the same height as `headerTailH` to planItemsTable.
|
||||
*/
|
||||
headerTail?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,7 +157,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, headColor, ...look }: TableRowsProps) {
|
||||
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, headerTail, ...look }: TableRowsProps) {
|
||||
const padX = look.padX ?? 0;
|
||||
const padY = look.padY ?? 4;
|
||||
const border = look.border ?? 0;
|
||||
@@ -154,8 +170,8 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
|
||||
values.map((v, i) => (
|
||||
<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 key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||
{partText(part)}
|
||||
</Txt>
|
||||
))}
|
||||
</Cell>
|
||||
@@ -164,9 +180,18 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
|
||||
return (
|
||||
<AvailableWidthProvider width={width}>
|
||||
<View>
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
|
||||
{cells(roles.head, header, headColor)}
|
||||
</Row>
|
||||
{headerTail ? (
|
||||
<View {...pdfFlags({ fixed: true })}>
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} style={cellStyle(headerStyle)}>
|
||||
{cells(roles.head, header, headColor)}
|
||||
</Row>
|
||||
{headerTail}
|
||||
</View>
|
||||
) : (
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
|
||||
{cells(roles.head, header, headColor)}
|
||||
</Row>
|
||||
)}
|
||||
{rows.map((r, i) => (
|
||||
<Row
|
||||
key={i}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function SafeTxt({ children, width, align, ...rest }: SafeTxtProps) {
|
||||
);
|
||||
if (widest > w + 1e-6) {
|
||||
return (
|
||||
<IdText {...rest} width={w}>
|
||||
<IdText {...rest} width={w} {...(align ? { align, fill: true } : {})}>
|
||||
{children}
|
||||
</IdText>
|
||||
);
|
||||
|
||||
+86
-25
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user