Add the Monolith and Cobalt Stripe templates
Standalone layouts for reference templates 3 and 8, with the user's logo sized to the placeholder box, a CapRule decor primitive, optional TableRows rule/heading options, and a separate 1..45-row sweep test file.
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
import type { ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { textHeight } from "../../engine/textplan";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
import { innerRulePadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
|
||||
import type { RenderModel } from "../../model/build";
|
||||
import type { TypeToken } from "../../fonts/roles";
|
||||
import type { PlacedLogo } from "../contract";
|
||||
import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
|
||||
import { INK, leading, type CobaltVariant } from "./tokens";
|
||||
|
||||
export const NOTES_FLOW_GAP = 14;
|
||||
const FIT_AIR = 2;
|
||||
/** A GSTIN is always 15 characters: the text the layout prints for the height estimate, which must not read the regulated field. */
|
||||
const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
|
||||
/** Narrowest the footer address column may get before the phone and e-mail shrink to make room. */
|
||||
const MIN_ADDRESS_W = 120;
|
||||
/** Space between a mark and the tagline under a wordmark logo. */
|
||||
const WORDMARK_TAG_GAP = 3;
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
|
||||
/** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */
|
||||
export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`);
|
||||
|
||||
function idLines(role: TypeToken, text: string, width: number): number {
|
||||
const f = fitSize(role, text, Math.max(1, width));
|
||||
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)) + 1);
|
||||
}
|
||||
|
||||
const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(getMeasurer(), role, text)) + 1;
|
||||
|
||||
export interface MetaLine {
|
||||
strong: boolean;
|
||||
text: string;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
export function metaLines(model: RenderModel): MetaLine[] {
|
||||
const out: MetaLine[] = [...(model.cancelled ? [{ strong: true, text: "CANCELLED", cancelled: true }] : []), { strong: true, text: `${model.meta.numberLabel.replace(/\.$/, "")} : ${model.meta.number}` }, { strong: false, text: model.meta.date }];
|
||||
if (model.meta.dueDate) out.push({ strong: false, text: `Due date : ${model.meta.dueDate}` });
|
||||
if (model.client.poNumber) out.push({ strong: false, text: `PO : ${model.client.poNumber}` });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The bank block's lines: "Label : value", the design's "Bank Name : ..." and "Account Number : ...". */
|
||||
export function bankLines(model: RenderModel): string[] {
|
||||
const bank = model.bank;
|
||||
if (!bank) return [];
|
||||
const rename: Record<string, string> = { Bank: "Bank Name", "Account No.": "Account Number" };
|
||||
const out = bank.lines.map((l) => `${rename[l.label] ?? l.label} : ${l.value}`);
|
||||
const at = bank.lines.findIndex((l) => l.label === "Bank") + 1;
|
||||
out.splice(at, 0, `Account Name : ${bank.accountName}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function signatureOptions(v: CobaltVariant): SignatoryOptions {
|
||||
const r = v.roles;
|
||||
return { roles: { name: r.signName, designation: r.signTitle, fine: r.fine }, align: "right", rule: null, color: INK, imageHeight: v.closing.signatureImageH };
|
||||
}
|
||||
|
||||
/** The table's no., description (+ HSN/SAC sub-line), qty, price and amount cells. */
|
||||
export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number): TableCellValue[][] {
|
||||
const r = v.roles;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, rateW, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [String(i + 1), desc, item.qtyText, rateParts, item.amountText];
|
||||
});
|
||||
}
|
||||
|
||||
export interface ContactItem {
|
||||
icon: "phone" | "email" | "location";
|
||||
lines: string[];
|
||||
/** Identifiers (phone, e-mail) shrink instead of wrapping; the address wraps. */
|
||||
id: boolean;
|
||||
/** Width of the text box beside the icon. */
|
||||
textW: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CobaltPlan {
|
||||
v: CobaltVariant;
|
||||
W: number;
|
||||
head: {
|
||||
titleSize: number;
|
||||
titleW: number;
|
||||
leftW: number;
|
||||
mode: "name" | "wordmark" | "none";
|
||||
showName: boolean;
|
||||
markW: number;
|
||||
nameW: number;
|
||||
nameH: number;
|
||||
tagline: boolean;
|
||||
taglineH: number;
|
||||
leftH: number;
|
||||
h: number;
|
||||
ruleGap: number;
|
||||
ruleText: string;
|
||||
ruleTextW: number;
|
||||
ruleW: number;
|
||||
ruleH: number;
|
||||
};
|
||||
info: { metaW: number; clientW: number; lines: MetaLine[]; metaH: number; clientH: number; h: number };
|
||||
columns: ColSpec[];
|
||||
headerCells: string[];
|
||||
rows: TableCellValue[][];
|
||||
align: Array<"left" | "center" | "right">;
|
||||
headAlign: Array<"left" | "center" | "right">;
|
||||
padLeft: number[];
|
||||
padRight: number[];
|
||||
padY: number;
|
||||
headPadY: number;
|
||||
cw: { no: number; qty: number; price: number; amount: number; priceInset: number };
|
||||
table: ItemsTablePlan;
|
||||
guardH: number;
|
||||
tail: {
|
||||
stack: Array<{ label: string; amount: string }>;
|
||||
slotRows: number;
|
||||
stackH: number;
|
||||
rightW: number;
|
||||
valueW: number;
|
||||
leftW: number;
|
||||
leftTop: number;
|
||||
/** The PAYMENT METHOD bar is level with the GRAND TOTAL bar (the design) rather than beside the first totals row. */
|
||||
aligned: boolean;
|
||||
bank: string[];
|
||||
bankH: number;
|
||||
wordsH: number;
|
||||
reverseH: number;
|
||||
rightH: number;
|
||||
leftH: number;
|
||||
body: number;
|
||||
};
|
||||
closing: {
|
||||
leftW: number;
|
||||
signCol: number;
|
||||
terms: string[];
|
||||
termsBeside: boolean;
|
||||
termsH: number;
|
||||
flowW: number;
|
||||
flowH: number;
|
||||
leftH: number;
|
||||
signH: number;
|
||||
rowH: number;
|
||||
contacts: ContactItem[];
|
||||
contactGap: number;
|
||||
contactsW: number;
|
||||
contactsH: number;
|
||||
footerH: number;
|
||||
h: number;
|
||||
};
|
||||
gaps: { client: number; table: number; tail: number; terms: number; footer: number };
|
||||
tailH: number;
|
||||
closingH: number;
|
||||
}
|
||||
|
||||
function planContacts(model: RenderModel, v: CobaltVariant, W: number): { items: ContactItem[]; gap: number; width: number; h: number } {
|
||||
const s = model.supplier;
|
||||
const r = v.roles;
|
||||
const c = v.closing;
|
||||
const raw: Array<Pick<ContactItem, "icon" | "lines" | "id">> = [];
|
||||
if (s.phone) raw.push({ icon: "phone", lines: [s.phone], id: true });
|
||||
if (s.email) raw.push({ icon: "email", lines: [s.email], id: true });
|
||||
if (s.addressLines.length) raw.push({ icon: "location", lines: s.addressLines, id: false });
|
||||
const n = raw.length;
|
||||
if (n === 0) return { items: [], gap: 0, width: 0, h: 0 };
|
||||
const natural = raw.map((x) => Math.max(...x.lines.map((l) => widthOf(r.contact, l))));
|
||||
const iconBlock = c.icon.size + c.icon.gap;
|
||||
const budget = W - n * iconBlock - (n - 1) * c.minContactGap;
|
||||
let texts = natural;
|
||||
if (natural.reduce((a, b) => a + b, 0) > budget) {
|
||||
// Identifiers keep their natural width while the address (which wraps) keeps a usable column; otherwise they shrink
|
||||
// (to their minimum size, then chunk) to a share of the row and the address wraps in what is left.
|
||||
const ids = raw.reduce((a, x, i) => a + (x.id ? natural[i] : 0), 0);
|
||||
const free = raw.filter((x) => !x.id).length;
|
||||
const room = free ? (budget - ids) / free : 0;
|
||||
const cap = free === 0 ? Math.floor(budget / n) : room >= MIN_ADDRESS_W ? Infinity : Math.floor(budget * 0.3);
|
||||
const idW = raw.map((x, i) => (x.id ? Math.min(natural[i], cap) : 0));
|
||||
const rest = budget - idW.reduce((a, b) => a + b, 0);
|
||||
texts = raw.map((x, i) => (x.id ? idW[i] : Math.max(60, Math.floor(rest / Math.max(1, free)))));
|
||||
}
|
||||
const used = texts.reduce((a, b) => a + b, 0) + n * iconBlock;
|
||||
const gap = n > 1 ? Math.max(c.minContactGap, Math.min(c.contactGap, (W - used) / (n - 1))) : 0;
|
||||
const items = raw.map((x, i): ContactItem => {
|
||||
const textW = texts[i];
|
||||
const h = x.id ? idLines(r.contact, x.lines[0], textW) * leading(r.contact) : x.lines.reduce((a, l) => a + textHeight(r.contact, l, textW), 0);
|
||||
return { ...x, textW, h };
|
||||
});
|
||||
const width = used + (n - 1) * gap;
|
||||
return { items, gap, width, h: Math.max(c.icon.size, ...items.map((x) => x.h + Math.max(0, (c.icon.size - leading(r.contact)) / 2))) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the layout must know before rendering: the header (the INVOICE title's size and the left block's mode),
|
||||
* the info row, the five table columns, the totals' two columns, the closing block and the footer's contact row, and
|
||||
* the heights of the tail and the closing block (their minPresenceAhead values), each a hair high rather than low.
|
||||
*/
|
||||
export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVariant, logo: PlacedLogo | null, includesName: boolean): CobaltPlan {
|
||||
const m = getMeasurer();
|
||||
const r = v.roles;
|
||||
const W = frame.contentWidth;
|
||||
const s = model.supplier;
|
||||
const c = model.client;
|
||||
const h = v.head;
|
||||
|
||||
// --- header ------------------------------------------------------------------------------------------------------------
|
||||
const tFit = fitSize(r.title, model.docTitle, Math.floor(W * 0.62));
|
||||
const titleW = Math.ceil(tFit.width) + 2;
|
||||
const leftW = W - titleW - h.titleGap;
|
||||
const showName = !(logo && includesName) && s.name.trim() !== "";
|
||||
const mode: "name" | "wordmark" | "none" = showName ? "name" : logo ? "wordmark" : "none";
|
||||
const markW = logo && showName ? logo.w : 0;
|
||||
const nameW = showName ? Math.max(h.minNameW, leftW - (markW ? markW + h.markGap : 0)) : 0;
|
||||
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
|
||||
const tagline = slotHeights.gstinLines(model, "supplier") > 0;
|
||||
const tagW = mode === "name" ? nameW : leftW;
|
||||
const taglineH = tagline ? idLines(r.tagline, GSTIN_SAMPLE, tagW) * leading(r.tagline) : 0;
|
||||
let headLeftH = 0;
|
||||
if (mode === "name") headLeftH = Math.max(h.nameTop + nameH + (tagline ? h.taglineGap + taglineH : 0), logo ? h.logoTop + logo.h : 0);
|
||||
else if (mode === "wordmark") headLeftH = h.logoTop + (logo?.h ?? 0) + (tagline ? WORDMARK_TAG_GAP + taglineH : 0);
|
||||
const headH = Math.max(headLeftH, leading(r.title));
|
||||
const ruleText = s.pan ? `PAN: ${s.pan}` : "";
|
||||
const ruleH = leading(r.ruleText);
|
||||
const ruleTextW = ruleText ? Math.min(widthOf(r.ruleText, ruleText), Math.floor(W * 0.38)) : 0;
|
||||
const ruleW = W - (ruleTextW ? ruleTextW + h.ruleTextGap : 0);
|
||||
const ruleGap = Math.max(h.ruleMinGap, h.ruleTop - headH);
|
||||
|
||||
// --- info row: Billed to at the left, the invoice numbers at the right --------------------------------------------------
|
||||
const lines = metaLines(model);
|
||||
const metaW = Math.min(Math.floor(W * v.info.metaMaxShare), Math.max(v.info.metaMinW, ...lines.map((l) => widthOf(l.strong ? r.metaStrong : r.metaLine, l.text))));
|
||||
const clientW = W - metaW - v.info.clientGap;
|
||||
let clientH = leading(r.label) + textHeight(r.clientName, c.name, clientW);
|
||||
const clientLines = c.addressLines.length + slotHeights.gstinLines(model, "client") + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW);
|
||||
if (clientLines > 0) clientH += v.info.nameGap;
|
||||
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, clientW);
|
||||
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW) * leading(r.clientLine);
|
||||
const metaH = lines.reduce((a, l) => a + idLines(l.strong ? r.metaStrong : r.metaLine, l.text, metaW) * leading(r.metaLine), 0);
|
||||
const infoH = Math.max(clientH, metaH);
|
||||
|
||||
// --- table: NO | DESCRIPTION | QTY | PRICE | TOTAL ---------------------------------------------------------------------------
|
||||
const t = v.table;
|
||||
const rule = t.separator;
|
||||
const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x))));
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
const amountTexts = model.items.map((i) => i.amountText);
|
||||
const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12);
|
||||
const qty = Math.max(t.cols.qty, widest(r.tableBody, model.items.map((i) => i.qtyText)) + 12 + rule);
|
||||
const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6;
|
||||
const price = Math.max(t.cols.price, priceNeed);
|
||||
const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4));
|
||||
const amount = Math.max(t.cols.amount, Math.max(widest(r.tableBody, amountTexts), widest(r.tableHead, ["Total"])) + t.amountInset + 8);
|
||||
const columns: ColSpec[] = [{ fixed: no }, { fill: true }, { fixed: qty }, { fixed: price }, { fixed: amount }];
|
||||
const headerCells = ["No", "Description", "Qty", "Price", "Total"];
|
||||
const align: CobaltPlan["align"] = ["center", "left", "center", "right", "right"];
|
||||
const headAlign: CobaltPlan["headAlign"] = ["center", "center", "center", "right", "center"];
|
||||
const padLeft = [0, t.descInset, 0, 0, 0];
|
||||
// The inner rule counts toward the inset, so the price ends `priceInset` short of its column edge as in the design.
|
||||
const padRight = [0, 6, 0, priceInset - rule, t.amountInset];
|
||||
const planPadRight = padRight.map((p, i) => p + innerRulePadRight(5, rule)[i]);
|
||||
const headPadY = (t.headH - leading(r.tableHead)) / 2;
|
||||
const rows = tableRows(model, v, price - rule - padRight[3] - 2);
|
||||
const n = rows.length;
|
||||
const padY = t.padY;
|
||||
const planTable = (tailH: number) =>
|
||||
planItemsTable({ columns, gap: 0, width: W, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight: planPadRight, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight });
|
||||
|
||||
// --- totals: Sub Total rows and the GRAND TOTAL bar at the right, the PAYMENT METHOD bar and the bank lines at the left ------
|
||||
const tt = model.totals;
|
||||
const o = v.totals;
|
||||
const stack: Array<{ label: string; amount: string }> = [{ label: labelWithColon("Sub Total"), amount: tt.subtotalText }];
|
||||
if (tt.discountText) stack.push({ label: labelWithColon("Discount"), amount: `- ${tt.discountText}` });
|
||||
if (tt.taxableText) stack.push({ label: labelWithColon("Taxable Value"), amount: tt.taxableText });
|
||||
const slotRows = slotHeights.taxRowCount(model);
|
||||
const stackH = (stack.length + slotRows) * leading(r.totalLabel);
|
||||
const valueW = o.rightW - o.labelW;
|
||||
const wordsH = slotHeights.amountInWords(model, o.rightW, { role: r.words, labelRole: r.wordsLabel });
|
||||
const reverseH = slotHeights.reverseCharge(model, o.rightW, r.words);
|
||||
const rightH = stackH + o.barGap + o.barH + (wordsH ? o.wordsGap + wordsH : 0) + (reverseH ? o.reverseGap + reverseH : 0);
|
||||
const tailLeftW = W - o.rightW - o.leftGap;
|
||||
const bank = bankLines(model);
|
||||
const bankH = bank.reduce((a, l) => a + textHeight(r.bank, l, tailLeftW - o.barInset), 0);
|
||||
const leftBlockH = o.barH + o.bankGap + bankH;
|
||||
const leftHFor = (top: number) => (model.bank ? top + leftBlockH : 0);
|
||||
|
||||
// --- closing ------------------------------------------------------------------------------------------------------------------
|
||||
const cl = v.closing;
|
||||
const signCol = cl.signW + cl.signInset;
|
||||
const cLeftW = W - signCol - 8;
|
||||
const terms = model.notesLines;
|
||||
const termsH = terms.length ? leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, cLeftW), 0) : 0;
|
||||
const termsBeside = terms.length === 0 || termsH <= cl.notesBesideMax;
|
||||
const flowW = W - 2;
|
||||
const flowH = termsBeside ? 0 : leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, flowW), 0);
|
||||
const signH = signatoryHeight(model, cl.signW, signatureOptions(v));
|
||||
const contacts = planContacts(model, v, W);
|
||||
const footerRuleH = h.rule.accent;
|
||||
const footerH = footerRuleH + (contacts.items.length ? cl.ruleToContacts + contacts.h : 0);
|
||||
const leftBase = cl.divider.thickness + cl.dividerGap + cl.thanksH;
|
||||
const cancelledH = model.cancelled ? 10 + textHeight(r.fine, CANCELLED_SENTENCE, W) : 0;
|
||||
const closingFor = (g: { terms: number; footer: number }) => {
|
||||
const leftHh = leftBase + (termsBeside && terms.length ? g.terms + termsH : 0);
|
||||
const rowH = Math.max(leftHh, signH);
|
||||
return { leftH: leftHh, rowH, h: rowH + g.footer + footerH + cancelledH };
|
||||
};
|
||||
|
||||
// --- gaps -----------------------------------------------------------------------------------------------------------------------
|
||||
const ranges = { client: v.gaps.client, table: v.gaps.table, tail: v.gaps.tail, terms: termsBeside && terms.length ? v.gaps.terms : { nominal: 0, min: 0 }, footer: v.gaps.footer };
|
||||
const nominalClosing = closingFor({ terms: ranges.terms.nominal, footer: ranges.footer.nominal });
|
||||
const table0 = planTable(0);
|
||||
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
|
||||
const notesBlock = termsBeside ? 0 : NOTES_FLOW_GAP + flowH;
|
||||
const nominalFor = (tailBody: number) => headH + ruleGap + ruleH + ranges.client.nominal + infoH + ranges.table.nominal + tableTotal + ranges.tail.nominal + tailBody + notesBlock + nominalClosing.h;
|
||||
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
// The PAYMENT METHOD bar sits level with the GRAND TOTAL bar, as in the design, whenever the invoice still fits its page
|
||||
// that way; with more totals rows than the design's two it moves up beside the first row, which saves up to the stack's height.
|
||||
const alignedTop = stackH + o.barGap;
|
||||
const aligned = nominalFor(Math.max(rightH, leftHFor(alignedTop))) - slack <= frame.contentHeight || !model.bank;
|
||||
const leftTop = aligned ? alignedTop : 0;
|
||||
const leftH = leftHFor(leftTop);
|
||||
const tailBody = Math.max(rightH, leftH);
|
||||
const nominalTotal = nominalFor(tailBody);
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
|
||||
// page 1 and let only the closing block move to the next page.
|
||||
const overflowBeforeClosing = nominalTotal - nominalClosing.h - frame.contentHeight;
|
||||
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
|
||||
const air = (over: number) => Math.min(slack, over + FIT_AIR);
|
||||
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
|
||||
const deficit = Math.max(frame.heightDeficit, fits);
|
||||
const g = elastic(ranges, deficit);
|
||||
const closing = closingFor(g);
|
||||
const tailH = g.tail + tailBody;
|
||||
const table = planTable(tailH);
|
||||
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailH : 0);
|
||||
|
||||
return {
|
||||
v,
|
||||
W,
|
||||
head: { titleSize: tFit.size, titleW, leftW, mode, showName, markW, nameW, nameH, tagline, taglineH, leftH: headLeftH, h: headH, ruleGap, ruleText, ruleTextW, ruleW, ruleH },
|
||||
info: { metaW, clientW, lines, metaH, clientH, h: infoH },
|
||||
columns,
|
||||
headerCells,
|
||||
rows,
|
||||
align,
|
||||
headAlign,
|
||||
padLeft,
|
||||
padRight,
|
||||
padY,
|
||||
headPadY,
|
||||
cw: { no, qty, price, amount, priceInset },
|
||||
table,
|
||||
guardH,
|
||||
tail: { stack, slotRows, stackH, rightW: o.rightW, valueW, leftW: tailLeftW, leftTop, aligned, bank, bankH, wordsH, reverseH, rightH, leftH, body: tailBody },
|
||||
closing: { leftW: cLeftW, signCol, terms, termsBeside, termsH, flowW, flowH, leftH: closing.leftH, signH, rowH: closing.rowH, contacts: contacts.items, contactGap: contacts.gap, contactsW: contacts.width, contactsH: contacts.h, footerH, h: closing.h },
|
||||
gaps: { client: g.client, table: g.table, tail: g.tail, terms: g.terms, footer: g.footer },
|
||||
tailH,
|
||||
closingH: closing.h,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user