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:
2026-10-04 16:46:22 +05:30
parent 43afdaf4bf
commit a81aca0ca0
7 changed files with 89 additions and 15 deletions
+19
View File
@@ -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([]);
+9 -5
View File
@@ -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);
+13 -5
View File
@@ -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));
+10 -5
View File
@@ -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);