Skip to content

Commit ec08fc7

Browse files
committed
feat(templates): keep preset section headers with their bodies
CV and cover-letter presets emit a section header as its own page-flow block(s), so when the first line of the body below does not fit, the header strands at a page bottom apart from its content. Wire the shared header widgets to the keep-with-next primitive so every preset built on them stops orphaning titles. - SectionHeader: every single-section variant (banner, fullWidthBanner, underlined, flat, flatSpacedCaps, tickLabel, upperRule; spacedCapsRule via flatSpacedCaps) marks its host keepWithNext(), so the title relocates with the first line of its body. - FlowSectionHeader: the banner/label variants emit the header as three siblings (top rule + banner + bottom rule); all three are marked so the whole title run relocates as a unit. - LineNode.keepWithNext() (new component plus back-compat constructor) and LineBuilder.keepWithNext() — the line counterpart of the section flag, so a header rule joins the banner's run. Default off, byte-identical for lines that do not opt in.
1 parent afa50bf commit ec08fc7

6 files changed

Lines changed: 199 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ for this cycle.
5656
page-spanning body. Inert when nothing follows (a trailing heading is never moved)
5757
and best-effort when the heading plus one line cannot share a page. Default off, so
5858
layouts that do not opt in are unchanged.
59+
- `LineBuilder.keepWithNext()` — the line counterpart of
60+
`SectionBuilder.keepWithNext()`, so a full-width header rule joins its banner's
61+
keep-with-next run and the whole title block (rule + banner + rule) relocates
62+
together instead of the banner stranding apart from its rules or its body.
63+
- **CV and cover-letter presets no longer orphan a section title.** The shared
64+
header widgets (`SectionHeader` — boxed, underlined, flat, tick, banner variants —
65+
and `FlowSectionHeader`) opt their titles into keep-with-next, so every preset
66+
built on them keeps a section heading with the first line of its body across a
67+
page break rather than stranding the heading at a page bottom.
5968
- **Reproducible PDF output** (`@Beta`). `PdfFixedLayoutBackend.builder().deterministic(true)`
6069
(or `.deterministic(Instant)` for an explicit timestamp) pins the document
6170
CreationDate / ModDate and derives the PDF `/ID` from the document metadata instead

‎core/src/main/java/com/demcha/compose/document/dsl/LineBuilder.java‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public final class LineBuilder implements Transformable<LineBuilder> {
3232
private DocumentDashPattern dashPattern = DocumentDashPattern.NONE;
3333
private DocumentLineCap lineCap = DocumentLineCap.BUTT;
3434
private boolean fillWidth = false;
35+
private boolean keepWithNext = false;
3536

3637
/**
3738
* Creates a line builder.
@@ -280,6 +281,33 @@ public LineBuilder fill() {
280281
return this;
281282
}
282283

284+
/**
285+
* Keeps this line with the block that follows it: the line relocates to the
286+
* next page rather than stranding at a page bottom apart from the content it
287+
* leads into. Used so a header rule joins its banner's keep-with-next run and
288+
* the whole title block (rule + banner + rule) moves together — see
289+
* {@link SectionBuilder#keepWithNext()}.
290+
*
291+
* @return this builder
292+
* @since 2.0.0
293+
*/
294+
public LineBuilder keepWithNext() {
295+
this.keepWithNext = true;
296+
return this;
297+
}
298+
299+
/**
300+
* Sets whether the line stays with the block that follows it.
301+
*
302+
* @param value true to keep the line with the next block
303+
* @return this builder
304+
* @since 2.0.0
305+
*/
306+
public LineBuilder keepWithNext(boolean value) {
307+
this.keepWithNext = value;
308+
return this;
309+
}
310+
283311
/**
284312
* Attaches line-level external link metadata.
285313
*
@@ -404,7 +432,8 @@ public LineNode build() {
404432
dashPattern,
405433
anchor,
406434
lineCap,
407-
fillWidth);
435+
fillWidth,
436+
keepWithNext);
408437
}
409438

410439
private boolean isHorizontalLine() {

‎core/src/main/java/com/demcha/compose/document/node/LineNode.java‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
* available where it is placed (its row slot, or the
3535
* content width) instead of {@code width}; the flex line
3636
* behind a dot leader. Defaults to {@code false}.
37+
* @param keepWithNext when {@code true}, the line stays with the block that
38+
* follows it rather than stranding at a page bottom apart
39+
* from it (see {@link DocumentNode#keepWithNext()}); lets a
40+
* header rule join its banner's keep-with-next run.
41+
* Defaults to {@code false}.
3742
* @author Artem Demchyshyn
3843
*/
3944
public record LineNode(
@@ -53,7 +58,8 @@ public record LineNode(
5358
DocumentDashPattern dashPattern,
5459
String anchor,
5560
DocumentLineCap lineCap,
56-
boolean fillWidth
61+
boolean fillWidth,
62+
boolean keepWithNext
5763
) implements DocumentNode {
5864
/**
5965
* Normalizes spacing defaults and validates explicit line geometry.
@@ -74,6 +80,49 @@ public record LineNode(
7480
requireFinite(endY, "endY");
7581
}
7682

83+
/**
84+
* Backward-compatible canonical constructor without the keep-with-next flag —
85+
* defaults to {@code false} (normal flow, byte-identical placement).
86+
*
87+
* @param name node name used in snapshots and layout graph paths
88+
* @param width resolved line box width
89+
* @param height resolved line box height
90+
* @param startX line start x offset inside the box
91+
* @param startY line start y offset inside the box
92+
* @param endX line end x offset inside the box
93+
* @param endY line end y offset inside the box
94+
* @param stroke line stroke descriptor
95+
* @param linkTarget optional node-level link target
96+
* @param bookmarkOptions optional node-level bookmark metadata
97+
* @param padding inner padding
98+
* @param margin outer margin
99+
* @param transform render-time affine transform
100+
* @param dashPattern dash pattern for the stroke
101+
* @param anchor optional navigation anchor name
102+
* @param lineCap end-cap style for the stroke
103+
* @param fillWidth whether the line stretches to the available width
104+
*/
105+
public LineNode(String name,
106+
double width,
107+
double height,
108+
double startX,
109+
double startY,
110+
double endX,
111+
double endY,
112+
DocumentStroke stroke,
113+
DocumentLinkTarget linkTarget,
114+
DocumentBookmarkOptions bookmarkOptions,
115+
DocumentInsets padding,
116+
DocumentInsets margin,
117+
DocumentTransform transform,
118+
DocumentDashPattern dashPattern,
119+
String anchor,
120+
DocumentLineCap lineCap,
121+
boolean fillWidth) {
122+
this(name, width, height, startX, startY, endX, endY, stroke, linkTarget, bookmarkOptions,
123+
padding, margin, transform, dashPattern, anchor, lineCap, fillWidth, false);
124+
}
125+
77126
/**
78127
* Backward-compatible canonical constructor without the fill flag — defaults
79128
* to {@code false} (a fixed-width, byte-identical line).
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.demcha.compose.document.templates.cv.widgets;
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.dsl.PageFlowBuilder;
7+
import com.demcha.compose.document.dsl.SectionBuilder;
8+
import com.demcha.compose.document.node.ContainerNode;
9+
import com.demcha.compose.document.node.DocumentNode;
10+
import com.demcha.compose.document.node.SectionNode;
11+
import com.demcha.compose.document.style.DocumentInsets;
12+
import com.demcha.compose.document.templates.core.theme.BrandTheme;
13+
import org.junit.jupiter.api.Test;
14+
15+
import java.util.function.Consumer;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
19+
/**
20+
* Verifies that the shared CV header widgets opt their section headers into
21+
* {@code keepWithNext()}, so every preset built on them keeps a section title with
22+
* the first line of its body instead of stranding the banner at a page bottom. The
23+
* pagination behaviour itself is covered by {@code SectionKeepWithNextTest}; this
24+
* test only asserts that the widgets set the flag.
25+
*/
26+
class HeaderKeepWithNextWiringTest {
27+
28+
private static final BrandTheme THEME = BrandTheme.boxedClassic();
29+
30+
/** Each single-section {@link SectionHeader} variant marks its host keep-with-next. */
31+
@Test
32+
void singleSectionHeadersMarkKeepWithNext() {
33+
assertThat(built(h -> SectionHeader.banner(h, "EXPERIENCE", THEME)).keepWithNext()).isTrue();
34+
assertThat(built(h -> SectionHeader.underlined(h, "EXPERIENCE", THEME)).keepWithNext()).isTrue();
35+
assertThat(built(h -> SectionHeader.flat(h, "EXPERIENCE", THEME.palette().rule(), THEME))
36+
.keepWithNext()).isTrue();
37+
assertThat(built(h -> SectionHeader.flatSpacedCaps(h, "EXPERIENCE", THEME.palette().rule(), THEME, null))
38+
.keepWithNext()).isTrue();
39+
assertThat(built(h -> SectionHeader.tickLabel(h, "EXPERIENCE", THEME, THEME.palette().rule(), 20))
40+
.keepWithNext()).isTrue();
41+
assertThat(built(h -> SectionHeader.upperRule(h, "EXPERIENCE", THEME, THEME.entryTitleStyle(),
42+
THEME.palette().rule(), 40)).keepWithNext()).isTrue();
43+
assertThat(built(h -> SectionHeader.spacedCapsRule(h, "EXPERIENCE", THEME, null,
44+
THEME.palette().rule(), 40, 1, DocumentInsets.zero())).keepWithNext()).isTrue();
45+
}
46+
47+
/** The full-width banner variant (FlowSectionHeader / BlueBanner) marks its host too. */
48+
@Test
49+
void fullWidthBannerMarksKeepWithNext() {
50+
assertThat(built(h -> SectionHeader.fullWidthBanner(h, "EXPERIENCE", THEME)).keepWithNext()).isTrue();
51+
}
52+
53+
/**
54+
* The multi-sibling banner header (rule + banner + rule) marks all three so the
55+
* whole title run relocates together — every child of the flow is keep-with-next.
56+
*/
57+
@Test
58+
void flowSectionHeaderBannerRunIsFullyMarked() {
59+
assertThat(flowChildren(flow -> FlowSectionHeader.banner(flow, "Sec", "EXPERIENCE", 200,
60+
THEME, THEME.bannerStyle(), DocumentInsets.zero(), DocumentInsets.zero())))
61+
.hasSize(3)
62+
.allMatch(DocumentNode::keepWithNext);
63+
}
64+
65+
/** The label header (rule + title + rule) marks all three the same way. */
66+
@Test
67+
void flowSectionHeaderLabelRunIsFullyMarked() {
68+
assertThat(flowChildren(flow -> FlowSectionHeader.label(flow, "Sec", "EXPERIENCE", 200,
69+
THEME, THEME.bannerStyle(), DocumentInsets.zero(), DocumentInsets.of(4),
70+
DocumentInsets.zero(), true)))
71+
.hasSize(3)
72+
.allMatch(DocumentNode::keepWithNext);
73+
}
74+
75+
private static SectionNode built(Consumer<SectionBuilder> render) {
76+
SectionBuilder host = new SectionBuilder();
77+
host.name("Header");
78+
render.accept(host);
79+
return host.build();
80+
}
81+
82+
private static java.util.List<DocumentNode> flowChildren(Consumer<PageFlowBuilder> render) {
83+
try (DocumentSession document = GraphCompose.document()
84+
.pageSize(DocumentPageSize.A4)
85+
.margin(30, 30, 30, 30)
86+
.create()) {
87+
PageFlowBuilder flow = document.dsl().pageFlow().name("Flow");
88+
render.accept(flow);
89+
ContainerNode root = flow.build();
90+
return root.children();
91+
} catch (Exception e) {
92+
throw new RuntimeException(e);
93+
}
94+
}
95+
}

‎templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/FlowSectionHeader.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ public static void label(PageFlowBuilder flow,
139139
topRuleMargin);
140140
}
141141
flow.addSection(name, section -> section
142+
.keepWithNext()
142143
.spacing(0)
143144
.padding(titlePadding)
144145
.addParagraph(paragraph -> paragraph
@@ -156,8 +157,12 @@ private static void addRule(PageFlowBuilder flow,
156157
DocumentColor color,
157158
BrandTheme theme,
158159
DocumentInsets margin) {
160+
// Rules are marked keep-with-next so the whole title run (top rule + banner
161+
// + bottom rule) relocates together and the banner never strands apart from
162+
// its rules or its body across a page break.
159163
flow.addLine(line -> line
160164
.name(name)
165+
.keepWithNext()
161166
.horizontal(width)
162167
.color(color)
163168
.thickness(theme.spacing().accentRuleWidth())

‎templates/src/main/java/com/demcha/compose/document/templates/cv/widgets/SectionHeader.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ private SectionHeader() {
7171
* @param theme the active theme supplying palette, typography, and spacing
7272
*/
7373
public static void banner(SectionBuilder host, String title, BrandTheme theme) {
74+
// A section header sticks to the body it introduces: keep-with-next relocates
75+
// it with its first body line rather than stranding the banner alone at a page
76+
// bottom. Inert when nothing follows, so a trailing header is unaffected.
77+
host.keepWithNext();
7478
host.softPanel(theme.palette().banner(),
7579
theme.spacing().bannerCornerRadius(),
7680
theme.spacing().bannerInnerPadding())
@@ -113,6 +117,7 @@ public static void fullWidthBanner(SectionBuilder host, String title,
113117
DocumentTextStyle titleStyle = titleStyleOverride != null
114118
? titleStyleOverride
115119
: theme.bannerStyle();
120+
host.keepWithNext();
116121
host.fillColor(theme.palette().banner())
117122
.padding(new DocumentInsets(theme.spacing().bannerInnerPadding(),
118123
0, theme.spacing().bannerInnerPadding(), 0))
@@ -134,6 +139,7 @@ public static void fullWidthBanner(SectionBuilder host, String title,
134139
*/
135140
public static void underlined(SectionBuilder host, String title, BrandTheme theme) {
136141
DocumentTextStyle titleStyle = theme.entryTitleStyle();
142+
host.keepWithNext();
137143
host.accentBottom(theme.palette().rule(),
138144
theme.spacing().accentRuleWidth())
139145
.padding(new DocumentInsets(8, 0, 2, 0))
@@ -163,6 +169,7 @@ public static void flat(SectionBuilder host, String title,
163169
.decoration(DocumentTextDecoration.BOLD)
164170
.color(color)
165171
.build();
172+
host.keepWithNext();
166173
host.padding(new DocumentInsets(8, 0, 2, 0))
167174
.addParagraph(p -> p
168175
.text(title)
@@ -204,6 +211,7 @@ public static void flatSpacedCaps(SectionBuilder host, String title,
204211
.decoration(DocumentTextDecoration.BOLD)
205212
.color(color)
206213
.build();
214+
host.keepWithNext();
207215
host.padding(new DocumentInsets(0, 0, 0, 0))
208216
.addParagraph(p -> p
209217
.text(TextOrnaments.spacedUpper(title))
@@ -256,6 +264,7 @@ public static void tickLabel(SectionBuilder host, String title,
256264
.decoration(DocumentTextDecoration.BOLD)
257265
.color(color)
258266
.build();
267+
host.keepWithNext();
259268
host.spacing(3)
260269
.addShape(shape -> shape
261270
.name("CvV2SectionHeaderTick")
@@ -285,6 +294,7 @@ public static void tickLabel(SectionBuilder host, String title,
285294
public static void upperRule(SectionBuilder host, String title,
286295
BrandTheme theme, DocumentTextStyle titleStyle,
287296
DocumentColor ruleColor, double ruleWidth) {
297+
host.keepWithNext();
288298
host.spacing(3)
289299
.addParagraph(paragraph -> paragraph
290300
.text(title.toUpperCase(Locale.ROOT))

0 commit comments

Comments
 (0)