Fix Monolith and Cobalt Stripe closing-block squeeze; guard Monolith flow notes
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.
This commit is contained in:
Vendored
+2
@@ -156,3 +156,5 @@ Built as `src/pdf/templates/monolith/`: a standalone template (`tokens.ts` the t
|
||||
- **Elastic gaps.** The 88 pt band under the client (down to 24), the row padding (down to 6), the gaps above BILLED TO and under the client block (down to 14) and the two gaps in the footer (down to 8) give back Letter's missing 50 pt, and the overflow of an invoice that is a little too long for one page. A 2-item tax invoice with a bank block fits one A4 page that way; with 3 rows and a signature image the closing block moves to page 2 (the items and totals stay together on page 1).
|
||||
- **Amount in words and reverse charge** are the totals' left column (Medium 10.5, x 35..329). **Notes** and the **signature** (an image up to 150 x 45, or the signatory name and designation and the computer-generated line) form the band above the footer's top rule; notes taller than 110 pt flow as their own block before the closing.
|
||||
- **Footer position.** The closing block ends 24 pt above the "Generated with Voiced" line, so its bottom rule is at y 804 where Canva's is at 812.
|
||||
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (band, info gaps, row padding) alone, as in Marble: the footer's own two gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 4 or 5 rows, Letter with 4).
|
||||
- **Flow notes guard.** The flow-notes block is preceded by a zero-height `TableGuard` asking for 84 pt (14 gap + 70 hold), so it never starts with less than its label and first line of room and collapses into zero-height lines at a page foot; it starts on the next page instead (Marble's guard).
|
||||
|
||||
Vendored
+1
@@ -155,4 +155,5 @@ Built as `src/pdf/templates/cobalt/`: a standalone template (`tokens.ts` the typ
|
||||
- **Totals.** "Sub Total :", Discount, Taxable Value and the CGST / SGST / IGST rows (labelled "CGST 9% :") end at the label column's right edge (x 417.4 of the design); the amount in words and the reverse-charge line sit under the GRAND TOTAL bar, right-aligned in 9.5 pt grey. The PAYMENT METHOD bar is level with the GRAND TOTAL bar as in the design whenever the invoice still fits its page that way; with a longer stack (a GST invoice has up to five rows against the design's two) it moves up beside the first row, which gives back up to the stack's height (104 pt for five rows), and the bank lines follow it.
|
||||
- **Closing.** The divider, "Thank you for business with us!" and the terms (the notes; taller than 110 pt they flow as a block of their own before the closing) at the left, the signature at the right, then the icon footer: the footer rule with accents at both ends, and phone, e-mail and address with Carbon icons (26 pt boxes, sky). The footer is part of the closing block, so it is on the last page only, not on every page, and everything in it is audited. The contacts keep their natural width at 32.5 pt apart; when they do not fit, the address wraps into what is left and the phone and e-mail shrink only if the address would get under 120 pt.
|
||||
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): header to client (42 to 26 pt), client to table (30.5 to 16), last row to the totals (9.8 to 4), thank-you to terms (26 to 14) and terms to the footer rule (16 to 8). Type is never scaled.
|
||||
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (header to client, client to table, last row to totals) alone, as in Marble: the terms and footer gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 7 rows).
|
||||
- **Margins.** 54.2 pt at the top (the INVOICE line box starts there so its baseline lands at 87.2), 59.55 at the sides and 35.5 plus the 24 pt footer reserve at the foot, which puts the icons' bottom 59.5 pt above the page foot as in the design.
|
||||
|
||||
@@ -339,6 +339,25 @@ describe("closing and footer", () => {
|
||||
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
|
||||
});
|
||||
|
||||
it("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => {
|
||||
// Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter).
|
||||
const cases: Array<["A4" | "LETTER", number]> = [["A4", 7]];
|
||||
for (const [size, n] of cases) {
|
||||
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "cobalt-stripe" }));
|
||||
const where = (id: string) => {
|
||||
const pages: number[] = [];
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i)));
|
||||
return pages;
|
||||
};
|
||||
const rowPages = new Set<number>();
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i)));
|
||||
expect([...rowPages], `${size} n=${n} rows`).toEqual([0]);
|
||||
expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]);
|
||||
expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]);
|
||||
expect(r.result.pages).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("an address, e-mail and phone that are too long for one row shrink and wrap inside the footer", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
|
||||
@@ -325,13 +325,17 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian
|
||||
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.
|
||||
// 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) => 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 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);
|
||||
|
||||
@@ -21,6 +21,9 @@ import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
|
||||
import { atRate, LABEL_GAP, NOTES_FLOW_GAP, paymentRows, planMonolith, REVERSE_GAP, roleOf, signatureOptions, type MonolithPlan } from "./plan";
|
||||
import type { MonolithVariant } from "./tokens";
|
||||
|
||||
/** Room that must remain under the notes' label and first line, so the next lines are not squeezed into the page foot. */
|
||||
const NOTES_HOLD = 70;
|
||||
|
||||
/** Layout-tree ids of the blocks that are Monolith's own (the harness ids are in classic/ids.ts). */
|
||||
export const BRAND_ID = "monolith-brand";
|
||||
export const IDS_ID = "monolith-ids";
|
||||
@@ -348,11 +351,16 @@ export function MonolithBody({ model, frame, logo, prefs, variant: v }: Template
|
||||
const plan = planMonolith(model, frame, v, logo, includesName({ model, prefs }));
|
||||
const ctx: Ctx = { model, plan, v, logo };
|
||||
const r = v.roles;
|
||||
const notesFlow: ReactNode = plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
|
||||
<View style={{ marginTop: NOTES_FLOW_GAP }}>
|
||||
<Notes ctx={ctx} width={plan.W} />
|
||||
</View>
|
||||
);
|
||||
const notesFlow: ReactNode =
|
||||
plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
|
||||
<>
|
||||
{/* A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */}
|
||||
<TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} />
|
||||
<View style={{ marginTop: NOTES_FLOW_GAP }}>
|
||||
<Notes ctx={ctx} width={plan.W} />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FirstPageHeader pad={frame.pad}>
|
||||
|
||||
@@ -286,6 +286,41 @@ describe("long text", () => {
|
||||
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
|
||||
});
|
||||
|
||||
it("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => {
|
||||
// Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter).
|
||||
const cases: Array<["A4" | "LETTER", number]> = [["A4", 4], ["A4", 5], ["LETTER", 4]];
|
||||
for (const [size, n] of cases) {
|
||||
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
|
||||
const where = (id: string) => {
|
||||
const pages: number[] = [];
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i)));
|
||||
return pages;
|
||||
};
|
||||
const rowPages = new Set<number>();
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i)));
|
||||
expect([...rowPages], `${size} n=${n} rows`).toEqual([0]);
|
||||
expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]);
|
||||
expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]);
|
||||
expect(r.result.pages).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("flow notes never start with less than a line of room: the block starts on the next page instead of collapsing into zero-height lines", async () => {
|
||||
const base = FIXTURES.find((f) => f.name === "long-notes")!;
|
||||
// Row counts where the notes' label used to land in the last few points of a page (A4 / Letter).
|
||||
const cases: Array<["A4" | "LETTER", number]> = [["A4", 3], ["A4", 14], ["LETTER", 13]];
|
||||
for (const [size, n] of cases) {
|
||||
const r = await renderFixture({ ...base, items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error"), `${size} n=${n}`).toEqual([]);
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const label = nodes(r.result.core.layout).find((x) => x.type === "text" && x.lines?.some((l) => /^notes/i.test(l.text)))!;
|
||||
let page = -1;
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x === label && (page = i)));
|
||||
expect(label.box.y + 70, `${size} n=${n} label room`).toBeLessThanOrEqual(frame.page.height - frame.pad.bottom);
|
||||
expect(page).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("an over-long vendor name wraps right-aligned in its column and never reaches the title", async () => {
|
||||
const r = await renderFixture({ ...FIXTURES.find((f) => f.name === "id-tokens")!, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
|
||||
const brand = textLines(byId(r, BRAND_ID));
|
||||
|
||||
@@ -235,15 +235,20 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa
|
||||
const notesBlock = notesBeside ? 0 : NOTES_FLOW_GAP + flowH;
|
||||
const nominalTotal = headH + v.headRuleGap + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailH + notesBlock + closingNominal;
|
||||
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
// The closing block's own gaps cannot help page 1 once it has moved to page 2.
|
||||
const { closeBand: closeBandRange, closeBottom: closeBottomRange, ...beforeRanges } = ranges;
|
||||
const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
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.
|
||||
// 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 - closingNominal - 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 air = (over: number, room: number) => Math.min(room, over + FIT_AIR);
|
||||
let g: Record<"band" | "infoTop" | "infoBottom" | "closeBand" | "closeBottom" | "rowPad", 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))), closeBand: closeBandRange.nominal, closeBottom: closeBottomRange.nominal };
|
||||
else g = elastic(ranges, frame.heightDeficit);
|
||||
const closingH = closingFor(g);
|
||||
const padY = n ? g.rowPad / (2 * n) : padYn;
|
||||
const table = planTable(padY, tailH);
|
||||
|
||||
Reference in New Issue
Block a user