Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.
- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.
Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
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, wrapAwareTexts } 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 = (item: { priceText: string | null }) => item.priceText ?? "—";
|
|
|
|
/** 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, 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));
|
|
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, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody)) + 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,
|
|
};
|
|
}
|