When only the closing block moved to page 2, the page-1 deficit was shared across every gap, including the closing block's own gaps, which cannot help page 1. Page 1 then came out a few points short and the totals spilled over with the closing block. Monolith and Cobalt Stripe now squeeze only the page-1 gaps in that case, as Marble and Highlighter already do. Monolith's flow notes block could start in the last few points of a page and be cut into zero-height lines. It now holds 70 pt of room under its label first, as Marble does. Adds tests for both cases (they failed before the fix) and notes in the template specs. Fingerprint goldens and thumbnails are unchanged.
368 lines
20 KiB
TypeScript
368 lines
20 KiB
TypeScript
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 (its own gaps stay nominal: squeezing them would take
|
|
// a share of the deficit away from the gaps that do help page 1).
|
|
const overflowBeforeClosing = nominalTotal - nominalClosing.h - frame.contentHeight;
|
|
const { terms: termsRange, footer: footerRange, ...beforeRanges } = ranges;
|
|
const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
|
// 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, room: number) => Math.min(room, over + FIT_AIR);
|
|
let g: Record<"client" | "table" | "tail" | "terms" | "footer", number>;
|
|
if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack)));
|
|
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), terms: termsRange.nominal, footer: footerRange.nominal };
|
|
else g = elastic(ranges, frame.heightDeficit);
|
|
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,
|
|
};
|
|
}
|