Skip to content

Commit 1aa0954

Browse files
authored
feat(templates): keep ModernProposal section headings with their content across page breaks (#433)
Each flowing heading in ModernProposal (the per-section titles, Timeline, Investment, and Acceptance terms) was a bare paragraph sibling to its body, so a heading could strand alone at a page bottom while its content flowed to the next page. Wrap each in its own keepWithNext() section so the title relocates with the first slice of the block it introduces. The wrapper is zero-padding/zero-margin with the title as its single child, so placement is unchanged unless a heading would otherwise strand. No engine change.
1 parent 95f6ae0 commit 1aa0954

3 files changed

Lines changed: 189 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,10 @@ for this cycle.
316316
now stays with the summary if a long profile splits the card. Multi-column presets
317317
place their sections in fixed columns that do not paginate, so no heading can strand
318318
there.
319+
- **The Modern Proposal template no longer orphans a section heading.** Its flowing
320+
section bodies, the Timeline and Investment tables, and the Acceptance terms each keep
321+
their title with the first line of the block it introduces across a page break — each
322+
title now renders in its own keep-with-next section rather than a bare paragraph.
319323
- **Reproducible PDF output** (`@Beta`). `PdfFixedLayoutBackend.builder().deterministic(true)`
320324
(or `.deterministic(Instant)` for an explicit timestamp) pins the document
321325
CreationDate / ModDate and derives the PDF `/ID` from the document metadata instead
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package com.demcha.compose.document.templates.proposal.presets;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentPageSize;
5+
import com.demcha.compose.document.api.DocumentSession;
6+
import com.demcha.compose.document.layout.LayoutGraph;
7+
import com.demcha.compose.document.layout.PlacedNode;
8+
import com.demcha.compose.document.node.DocumentNode;
9+
import com.demcha.compose.document.style.DocumentColor;
10+
import com.demcha.compose.document.style.DocumentInsets;
11+
import com.demcha.compose.document.templates.api.DocumentTemplate;
12+
import com.demcha.compose.document.templates.data.proposal.ProposalData;
13+
import com.demcha.compose.document.templates.data.proposal.ProposalDocumentSpec;
14+
import org.junit.jupiter.api.Test;
15+
16+
import java.util.ArrayList;
17+
import java.util.List;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
/**
22+
* Verifies the proposal preset-level keep-with-next wiring: every flowing
23+
* section heading in {@link ModernProposal} — the body sections, the Timeline
24+
* and Investment tables, and the Acceptance terms — keeps its title with the
25+
* first line of the block it introduces across a page break, by marking the
26+
* title SECTION keep-with-next and (crucially) NOT the body section, which
27+
* would wrongly bind the heading to the next block.
28+
*
29+
* <p>The pagination mechanism itself is covered by {@code
30+
* SectionKeepWithNextTest}; this test locks the wiring so a refactor cannot
31+
* silently drop the flag or move it to the wrong node.</p>
32+
*/
33+
class ProposalHeaderKeepWithNextTest {
34+
35+
private static final DocumentColor GREY = DocumentColor.rgb(220, 220, 220);
36+
private static final DocumentColor INK = DocumentColor.rgb(20, 80, 95);
37+
38+
private static final List<String> TITLE_SECTIONS = List.of(
39+
"ProposalSectionTitle",
40+
"ProposalTimelineTitle",
41+
"ProposalPricingTitle",
42+
"ProposalAcceptanceTitle");
43+
44+
private static final List<String> BODY_SECTIONS = List.of(
45+
"ProposalSectionBody",
46+
"ProposalAcceptanceBody");
47+
48+
private static ProposalDocumentSpec sampleSpec() {
49+
return ProposalDocumentSpec.from(ProposalData.builder()
50+
.title("Proposal")
51+
.proposalNumber("GC-P-2026-014")
52+
.preparedDate("02 Apr 2026")
53+
.validUntil("30 Apr 2026")
54+
.projectTitle("Document platform consolidation")
55+
.executiveSummary("A phased engagement to retire per-team PDF scripts.")
56+
.sender(from -> from.name("GraphCompose Studio"))
57+
.recipient(to -> to.name("Northwind Systems"))
58+
.section("Scope", "Discovery, architecture, and a reference rollout.")
59+
.section("Approach", "Iterative delivery with weekly checkpoints.")
60+
.timelineItem("Discovery", "2 weeks", "Stakeholder interviews + audit")
61+
.timelineItem("Build", "6 weeks", "Engine + template migration")
62+
.pricingRow("Discovery", "Workshops + audit", "GBP 6,000")
63+
.emphasizedPricingRow("Total", "", "GBP 30,000")
64+
.acceptanceTerm("50% on signature, 50% on delivery.")
65+
.acceptanceTerm("Valid for 30 days from the prepared date.")
66+
.build());
67+
}
68+
69+
private static List<DocumentNode> nodesOf(DocumentTemplate<ProposalDocumentSpec> template) {
70+
try (DocumentSession document = GraphCompose.document()
71+
.pageSize(DocumentPageSize.A4).margin(DocumentInsets.of(28)).create()) {
72+
template.compose(document, sampleSpec());
73+
List<DocumentNode> out = new ArrayList<>();
74+
collect(document.roots(), out);
75+
return out;
76+
} catch (Exception e) {
77+
throw new RuntimeException(e);
78+
}
79+
}
80+
81+
private static void collect(List<DocumentNode> nodes, List<DocumentNode> out) {
82+
for (DocumentNode node : nodes) {
83+
out.add(node);
84+
collect(node.children(), out);
85+
}
86+
}
87+
88+
/** Every flowing section/table heading binds to the block it introduces. */
89+
@Test
90+
void keepsEveryFlowingTitleWithItsBody() {
91+
List<DocumentNode> nodes = nodesOf(ModernProposal.create());
92+
for (String titleName : TITLE_SECTIONS) {
93+
assertThat(nodes.stream().filter(n -> titleName.equals(n.name())).toList())
94+
.as("title section %s should exist and be keep-with-next", titleName)
95+
.isNotEmpty().allMatch(DocumentNode::keepWithNext);
96+
}
97+
}
98+
99+
/** The body sections are NOT kept-with-next — that would bind a heading to the next block. */
100+
@Test
101+
void doesNotKeepBodySectionsWithNext() {
102+
List<DocumentNode> nodes = nodesOf(ModernProposal.create());
103+
for (String bodyName : BODY_SECTIONS) {
104+
assertThat(nodes.stream().filter(n -> bodyName.equals(n.name())).toList())
105+
.as("body section %s should exist and not be keep-with-next", bodyName)
106+
.isNotEmpty().noneMatch(DocumentNode::keepWithNext);
107+
}
108+
}
109+
110+
/**
111+
* End-to-end for the proposal shape: the template emits each section as its own
112+
* {@code pageFlow().build()} group, so this reproduces that structure — a filler
113+
* group that nearly fills the page, then a separate title + body group — and proves
114+
* the title relocates to the body's page instead of stranding at the page bottom.
115+
* Guards that keep-with-next fires across the separate-flow-group boundary, not only
116+
* within a single continuous flow.
117+
*/
118+
@Test
119+
void titleRelocatesWithBodyAcrossSeparatePageFlowGroups() {
120+
assertThat(titleMarkPage(true)).isEqualTo(bodyMarkPage(true));
121+
122+
// Control: without the opt-in, the title strands on the earlier page.
123+
assertThat(titleMarkPage(false)).isLessThan(bodyMarkPage(false));
124+
}
125+
126+
private static int titleMarkPage(boolean keepWithNext) {
127+
return markPage(keepWithNext, "TitleMark");
128+
}
129+
130+
private static int bodyMarkPage(boolean keepWithNext) {
131+
return markPage(keepWithNext, "BodyMark");
132+
}
133+
134+
private static int markPage(boolean keepWithNext, String mark) {
135+
try (DocumentSession document = GraphCompose.document()
136+
.pageSize(300, 400).margin(DocumentInsets.of(20)).create()) {
137+
// Group 1: a separate flow group that nearly fills the page.
138+
document.dsl().pageFlow().name("Filler")
139+
.addSection("FillerBody", s -> s.addShape(260, 250, GREY))
140+
.build();
141+
// Group 2: a separate flow group in the proposal's title + body shape.
142+
document.dsl().pageFlow().name("SectionGroup").spacing(12)
143+
.addSection("Title", s -> {
144+
if (keepWithNext) {
145+
s.keepWithNext();
146+
}
147+
s.addShape(shape -> shape.name("TitleMark").size(260, 40).fillColor(INK));
148+
})
149+
.addSection("Body", s -> s
150+
.addShape(shape -> shape.name("BodyMark").size(260, 80).fillColor(INK)))
151+
.build();
152+
LayoutGraph graph = document.layoutGraph();
153+
PlacedNode node = graph.nodes().stream()
154+
.filter(n -> mark.equals(n.semanticName()))
155+
.findFirst().orElseThrow();
156+
return node.startPage();
157+
} catch (Exception e) {
158+
throw new RuntimeException(e);
159+
}
160+
}
161+
}

templates/src/main/java/com/demcha/compose/document/templates/proposal/presets/ModernProposal.java

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,12 @@ public void compose(DocumentSession document, ProposalDocumentSpec spec) {
220220
document.dsl().pageFlow()
221221
.name("ProposalSectionGroup")
222222
.spacing(4)
223-
.addParagraph(p -> p
224-
.text(section.title())
225-
.textStyle(sectionTitleStyle)
226-
.margin(new DocumentInsets(12, 0, 4, 0)))
223+
.addSection("ProposalSectionTitle", s -> s
224+
.keepWithNext()
225+
.addParagraph(p -> p
226+
.text(section.title())
227+
.textStyle(sectionTitleStyle)
228+
.margin(new DocumentInsets(12, 0, 4, 0))))
227229
.addSection("ProposalSectionBody", col -> {
228230
for (String paragraph : section.paragraphs()) {
229231
col.addParagraph(p -> p
@@ -240,10 +242,12 @@ public void compose(DocumentSession document, ProposalDocumentSpec spec) {
240242
document.dsl().pageFlow()
241243
.name("ProposalTimelineGroup")
242244
.spacing(4)
243-
.addParagraph(p -> p
244-
.text("Timeline")
245-
.textStyle(sectionTitleStyle)
246-
.margin(new DocumentInsets(12, 0, 4, 0)))
245+
.addSection("ProposalTimelineTitle", s -> s
246+
.keepWithNext()
247+
.addParagraph(p -> p
248+
.text("Timeline")
249+
.textStyle(sectionTitleStyle)
250+
.margin(new DocumentInsets(12, 0, 4, 0))))
247251
.addTable(table -> {
248252
// The last column is auto-sized to its content (matching the
249253
// cinematic builtin). Very long details/descriptions can push
@@ -271,10 +275,12 @@ public void compose(DocumentSession document, ProposalDocumentSpec spec) {
271275
document.dsl().pageFlow()
272276
.name("ProposalPricingGroup")
273277
.spacing(4)
274-
.addParagraph(p -> p
275-
.text("Investment")
276-
.textStyle(sectionTitleStyle)
277-
.margin(new DocumentInsets(12, 0, 4, 0)))
278+
.addSection("ProposalPricingTitle", s -> s
279+
.keepWithNext()
280+
.addParagraph(p -> p
281+
.text("Investment")
282+
.textStyle(sectionTitleStyle)
283+
.margin(new DocumentInsets(12, 0, 4, 0))))
278284
.addTable(table -> {
279285
TableBuilder configured = table
280286
.name("ProposalPricing")
@@ -308,10 +314,12 @@ public void compose(DocumentSession document, ProposalDocumentSpec spec) {
308314
document.dsl().pageFlow()
309315
.name("ProposalAcceptanceGroup")
310316
.spacing(4)
311-
.addParagraph(p -> p
312-
.text("Acceptance terms")
313-
.textStyle(sectionTitleStyle)
314-
.margin(new DocumentInsets(12, 0, 4, 0)))
317+
.addSection("ProposalAcceptanceTitle", s -> s
318+
.keepWithNext()
319+
.addParagraph(p -> p
320+
.text("Acceptance terms")
321+
.textStyle(sectionTitleStyle)
322+
.margin(new DocumentInsets(12, 0, 4, 0))))
315323
.addSection("ProposalAcceptanceBody", col -> col
316324
.accentLeft(ACCENT, 3)
317325
.padding(0, 0, 0, 8)

0 commit comments

Comments
 (0)