Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,10 @@ for this cycle.
now stays with the summary if a long profile splits the card. Multi-column presets
place their sections in fixed columns that do not paginate, so no heading can strand
there.
- **The Modern Proposal template no longer orphans a section heading.** Its flowing
section bodies, the Timeline and Investment tables, and the Acceptance terms each keep
their title with the first line of the block it introduces across a page break — each
title now renders in its own keep-with-next section rather than a bare paragraph.
- **Reproducible PDF output** (`@Beta`). `PdfFixedLayoutBackend.builder().deterministic(true)`
(or `.deterministic(Instant)` for an explicit timestamp) pins the document
CreationDate / ModDate and derives the PDF `/ID` from the document metadata instead
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package com.demcha.compose.document.templates.proposal.presets;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.layout.LayoutGraph;
import com.demcha.compose.document.layout.PlacedNode;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.data.proposal.ProposalData;
import com.demcha.compose.document.templates.data.proposal.ProposalDocumentSpec;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Verifies the proposal preset-level keep-with-next wiring: every flowing
* section heading in {@link ModernProposal} — the body sections, the Timeline
* and Investment tables, and the Acceptance terms — keeps its title with the
* first line of the block it introduces across a page break, by marking the
* title SECTION keep-with-next and (crucially) NOT the body section, which
* would wrongly bind the heading to the next block.
*
* <p>The pagination mechanism itself is covered by {@code
* SectionKeepWithNextTest}; this test locks the wiring so a refactor cannot
* silently drop the flag or move it to the wrong node.</p>
*/
class ProposalHeaderKeepWithNextTest {

private static final DocumentColor GREY = DocumentColor.rgb(220, 220, 220);
private static final DocumentColor INK = DocumentColor.rgb(20, 80, 95);

private static final List<String> TITLE_SECTIONS = List.of(
"ProposalSectionTitle",
"ProposalTimelineTitle",
"ProposalPricingTitle",
"ProposalAcceptanceTitle");

private static final List<String> BODY_SECTIONS = List.of(
"ProposalSectionBody",
"ProposalAcceptanceBody");

private static ProposalDocumentSpec sampleSpec() {
return ProposalDocumentSpec.from(ProposalData.builder()
.title("Proposal")
.proposalNumber("GC-P-2026-014")
.preparedDate("02 Apr 2026")
.validUntil("30 Apr 2026")
.projectTitle("Document platform consolidation")
.executiveSummary("A phased engagement to retire per-team PDF scripts.")
.sender(from -> from.name("GraphCompose Studio"))
.recipient(to -> to.name("Northwind Systems"))
.section("Scope", "Discovery, architecture, and a reference rollout.")
.section("Approach", "Iterative delivery with weekly checkpoints.")
.timelineItem("Discovery", "2 weeks", "Stakeholder interviews + audit")
.timelineItem("Build", "6 weeks", "Engine + template migration")
.pricingRow("Discovery", "Workshops + audit", "GBP 6,000")
.emphasizedPricingRow("Total", "", "GBP 30,000")
.acceptanceTerm("50% on signature, 50% on delivery.")
.acceptanceTerm("Valid for 30 days from the prepared date.")
.build());
}

private static List<DocumentNode> nodesOf(DocumentTemplate<ProposalDocumentSpec> template) {
try (DocumentSession document = GraphCompose.document()
.pageSize(DocumentPageSize.A4).margin(DocumentInsets.of(28)).create()) {
template.compose(document, sampleSpec());
List<DocumentNode> out = new ArrayList<>();
collect(document.roots(), out);
return out;
} catch (Exception e) {
throw new RuntimeException(e);
}
}

private static void collect(List<DocumentNode> nodes, List<DocumentNode> out) {
for (DocumentNode node : nodes) {
out.add(node);
collect(node.children(), out);
}
}

/** Every flowing section/table heading binds to the block it introduces. */
@Test
void keepsEveryFlowingTitleWithItsBody() {
List<DocumentNode> nodes = nodesOf(ModernProposal.create());
for (String titleName : TITLE_SECTIONS) {
assertThat(nodes.stream().filter(n -> titleName.equals(n.name())).toList())
.as("title section %s should exist and be keep-with-next", titleName)
.isNotEmpty().allMatch(DocumentNode::keepWithNext);
}
}

/** The body sections are NOT kept-with-next — that would bind a heading to the next block. */
@Test
void doesNotKeepBodySectionsWithNext() {
List<DocumentNode> nodes = nodesOf(ModernProposal.create());
for (String bodyName : BODY_SECTIONS) {
assertThat(nodes.stream().filter(n -> bodyName.equals(n.name())).toList())
.as("body section %s should exist and not be keep-with-next", bodyName)
.isNotEmpty().noneMatch(DocumentNode::keepWithNext);
}
}

/**
* End-to-end for the proposal shape: the template emits each section as its own
* {@code pageFlow().build()} group, so this reproduces that structure — a filler
* group that nearly fills the page, then a separate title + body group — and proves
* the title relocates to the body's page instead of stranding at the page bottom.
* Guards that keep-with-next fires across the separate-flow-group boundary, not only
* within a single continuous flow.
*/
@Test
void titleRelocatesWithBodyAcrossSeparatePageFlowGroups() {
assertThat(titleMarkPage(true)).isEqualTo(bodyMarkPage(true));

// Control: without the opt-in, the title strands on the earlier page.
assertThat(titleMarkPage(false)).isLessThan(bodyMarkPage(false));
}

private static int titleMarkPage(boolean keepWithNext) {
return markPage(keepWithNext, "TitleMark");
}

private static int bodyMarkPage(boolean keepWithNext) {
return markPage(keepWithNext, "BodyMark");
}

private static int markPage(boolean keepWithNext, String mark) {
try (DocumentSession document = GraphCompose.document()
.pageSize(300, 400).margin(DocumentInsets.of(20)).create()) {
// Group 1: a separate flow group that nearly fills the page.
document.dsl().pageFlow().name("Filler")
.addSection("FillerBody", s -> s.addShape(260, 250, GREY))
.build();
// Group 2: a separate flow group in the proposal's title + body shape.
document.dsl().pageFlow().name("SectionGroup").spacing(12)
.addSection("Title", s -> {
if (keepWithNext) {
s.keepWithNext();
}
s.addShape(shape -> shape.name("TitleMark").size(260, 40).fillColor(INK));
})
.addSection("Body", s -> s
.addShape(shape -> shape.name("BodyMark").size(260, 80).fillColor(INK)))
.build();
LayoutGraph graph = document.layoutGraph();
PlacedNode node = graph.nodes().stream()
.filter(n -> mark.equals(n.semanticName()))
.findFirst().orElseThrow();
return node.startPage();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,12 @@ public void compose(DocumentSession document, ProposalDocumentSpec spec) {
document.dsl().pageFlow()
.name("ProposalSectionGroup")
.spacing(4)
.addParagraph(p -> p
.text(section.title())
.textStyle(sectionTitleStyle)
.margin(new DocumentInsets(12, 0, 4, 0)))
.addSection("ProposalSectionTitle", s -> s
.keepWithNext()
.addParagraph(p -> p
.text(section.title())
.textStyle(sectionTitleStyle)
.margin(new DocumentInsets(12, 0, 4, 0))))
.addSection("ProposalSectionBody", col -> {
for (String paragraph : section.paragraphs()) {
col.addParagraph(p -> p
Expand All @@ -240,10 +242,12 @@ public void compose(DocumentSession document, ProposalDocumentSpec spec) {
document.dsl().pageFlow()
.name("ProposalTimelineGroup")
.spacing(4)
.addParagraph(p -> p
.text("Timeline")
.textStyle(sectionTitleStyle)
.margin(new DocumentInsets(12, 0, 4, 0)))
.addSection("ProposalTimelineTitle", s -> s
.keepWithNext()
.addParagraph(p -> p
.text("Timeline")
.textStyle(sectionTitleStyle)
.margin(new DocumentInsets(12, 0, 4, 0))))
.addTable(table -> {
// The last column is auto-sized to its content (matching the
// cinematic builtin). Very long details/descriptions can push
Expand Down Expand Up @@ -271,10 +275,12 @@ public void compose(DocumentSession document, ProposalDocumentSpec spec) {
document.dsl().pageFlow()
.name("ProposalPricingGroup")
.spacing(4)
.addParagraph(p -> p
.text("Investment")
.textStyle(sectionTitleStyle)
.margin(new DocumentInsets(12, 0, 4, 0)))
.addSection("ProposalPricingTitle", s -> s
.keepWithNext()
.addParagraph(p -> p
.text("Investment")
.textStyle(sectionTitleStyle)
.margin(new DocumentInsets(12, 0, 4, 0))))
.addTable(table -> {
TableBuilder configured = table
.name("ProposalPricing")
Expand Down Expand Up @@ -308,10 +314,12 @@ public void compose(DocumentSession document, ProposalDocumentSpec spec) {
document.dsl().pageFlow()
.name("ProposalAcceptanceGroup")
.spacing(4)
.addParagraph(p -> p
.text("Acceptance terms")
.textStyle(sectionTitleStyle)
.margin(new DocumentInsets(12, 0, 4, 0)))
.addSection("ProposalAcceptanceTitle", s -> s
.keepWithNext()
.addParagraph(p -> p
.text("Acceptance terms")
.textStyle(sectionTitleStyle)
.margin(new DocumentInsets(12, 0, 4, 0))))
.addSection("ProposalAcceptanceBody", col -> col
.accentLeft(ACCENT, 3)
.padding(0, 0, 0, 8)
Expand Down
Loading