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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,18 @@ for this cycle.
page-spanning body. Inert when nothing follows (a trailing heading is never moved)
and best-effort when the heading plus one line cannot share a page. Default off, so
layouts that do not opt in are unchanged.
- `LineBuilder.keepWithNext()` — the line counterpart of
`SectionBuilder.keepWithNext()`, so a full-width header rule joins its banner's
keep-with-next run and the whole title block (rule + banner + rule) relocates
together instead of the banner stranding apart from its rules or its body.
- **Single-column CV presets no longer orphan a section title.** Every preset whose
sections flow down the page — BoxedSections, MinimalUnderlined, ModernProfessional,
Executive, CenteredHeadline, BlueBanner, EditorialBlue, and ClassicSerif — keeps a
section heading with the first line of its body across a page break: a standalone
header section binds to the following body section (a multi-node rule + banner + rule
group binds as one run), and a combined header+body module keeps the heading in a
nested keep-with-next group. Multi-column presets place their sections in fixed
columns that do not paginate, so no heading can strand there.
- **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
Expand Up @@ -32,6 +32,7 @@ public final class LineBuilder implements Transformable<LineBuilder> {
private DocumentDashPattern dashPattern = DocumentDashPattern.NONE;
private DocumentLineCap lineCap = DocumentLineCap.BUTT;
private boolean fillWidth = false;
private boolean keepWithNext = false;

/**
* Creates a line builder.
Expand Down Expand Up @@ -280,6 +281,33 @@ public LineBuilder fill() {
return this;
}

/**
* Keeps this line with the block that follows it: the line relocates to the
* next page rather than stranding at a page bottom apart from the content it
* leads into. Used so a header rule joins its banner's keep-with-next run and
* the whole title block (rule + banner + rule) moves together — see
* {@link SectionBuilder#keepWithNext()}.
*
* @return this builder
* @since 2.0.0
*/
public LineBuilder keepWithNext() {
this.keepWithNext = true;
return this;
}

/**
* Sets whether the line stays with the block that follows it.
*
* @param value true to keep the line with the next block
* @return this builder
* @since 2.0.0
*/
public LineBuilder keepWithNext(boolean value) {
this.keepWithNext = value;
return this;
}

/**
* Attaches line-level external link metadata.
*
Expand Down Expand Up @@ -404,7 +432,8 @@ public LineNode build() {
dashPattern,
anchor,
lineCap,
fillWidth);
fillWidth,
keepWithNext);
}

private boolean isHorizontalLine() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
* available where it is placed (its row slot, or the
* content width) instead of {@code width}; the flex line
* behind a dot leader. Defaults to {@code false}.
* @param keepWithNext when {@code true}, the line stays with the block that
* follows it rather than stranding at a page bottom apart
* from it (see {@link DocumentNode#keepWithNext()}); lets a
* header rule join its banner's keep-with-next run.
* Defaults to {@code false}.
* @author Artem Demchyshyn
*/
public record LineNode(
Expand All @@ -53,7 +58,8 @@ public record LineNode(
DocumentDashPattern dashPattern,
String anchor,
DocumentLineCap lineCap,
boolean fillWidth
boolean fillWidth,
boolean keepWithNext
) implements DocumentNode {
/**
* Normalizes spacing defaults and validates explicit line geometry.
Expand All @@ -74,6 +80,49 @@ public record LineNode(
requireFinite(endY, "endY");
}

/**
* Backward-compatible canonical constructor without the keep-with-next flag —
* defaults to {@code false} (normal flow, byte-identical placement).
*
* @param name node name used in snapshots and layout graph paths
* @param width resolved line box width
* @param height resolved line box height
* @param startX line start x offset inside the box
* @param startY line start y offset inside the box
* @param endX line end x offset inside the box
* @param endY line end y offset inside the box
* @param stroke line stroke descriptor
* @param linkTarget optional node-level link target
* @param bookmarkOptions optional node-level bookmark metadata
* @param padding inner padding
* @param margin outer margin
* @param transform render-time affine transform
* @param dashPattern dash pattern for the stroke
* @param anchor optional navigation anchor name
* @param lineCap end-cap style for the stroke
* @param fillWidth whether the line stretches to the available width
*/
public LineNode(String name,
double width,
double height,
double startX,
double startY,
double endX,
double endY,
DocumentStroke stroke,
DocumentLinkTarget linkTarget,
DocumentBookmarkOptions bookmarkOptions,
DocumentInsets padding,
DocumentInsets margin,
DocumentTransform transform,
DocumentDashPattern dashPattern,
String anchor,
DocumentLineCap lineCap,
boolean fillWidth) {
this(name, width, height, startX, startY, endX, endY, stroke, linkTarget, bookmarkOptions,
padding, margin, transform, dashPattern, anchor, lineCap, fillWidth, false);
}

/**
* Backward-compatible canonical constructor without the fill flag — defaults
* to {@code false} (a fixed-width, byte-identical line).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.demcha.compose.document.templates.cv.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.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.cv.data.CvDocument;
import com.demcha.compose.document.templates.cv.data.CvIdentity;
import com.demcha.compose.document.templates.cv.data.CvSkill;
import com.demcha.compose.document.templates.cv.data.EntriesSection;
import com.demcha.compose.document.templates.cv.data.ParagraphSection;
import com.demcha.compose.document.templates.cv.data.SkillsSection;
import org.junit.jupiter.api.Test;

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

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

/**
* Verifies the preset-level keep-with-next wiring: single-column CV presets keep a
* section heading with the first line of its body, marking keep-with-next at the
* level that binds the heading to the body — the header SECTION for standalone-header
* presets, the nested header SUBSECTION for combined header+body presets — and
* crucially NOT the combined body/module section itself, which would wrongly bind it
* to the next module. The pagination mechanism is covered by {@code
* SectionKeepWithNextTest}; the last test here exercises the nested (combined-preset)
* shape end-to-end across a page break.
*/
class PresetHeaderKeepWithNextTest {

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

private static CvDocument sampleDoc() {
return CvDocument.builder()
.identity(CvIdentity.builder()
.name("Test", "User").jobTitle("Engineer")
.contact("+1 555 0100", "user@example.com", "City").build())
.section(new ParagraphSection("Professional Summary", "Summary text goes here."))
.section(SkillsSection.builder("Technical Skills")
.leveledGroup("Languages", List.of(CvSkill.of("Java", 0.9), CvSkill.of("Kotlin", 0.8)))
.build())
.section(EntriesSection.builder("Professional Experience")
.entry("Senior Engineer", "ACME", "2020-2024", "Built things.")
.entry("Engineer", "Globex", "2018-2020", "Built more things.")
.build())
.section(EntriesSection.builder("Education")
.entry("BSc Computer Science", "University", "2014-2018", "Studied.")
.build())
.build();
}

private static List<DocumentNode> nodesOf(DocumentTemplate<CvDocument> template) {
try (DocumentSession document = GraphCompose.document()
.pageSize(DocumentPageSize.A4).margin(28, 28, 28, 28).create()) {
template.compose(document, sampleDoc());
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);
}
}

/** Standalone-header preset: every banner section is kept with next; no body section is. */
@Test
void boxedSectionsKeepsBannerNotBody() {
List<DocumentNode> nodes = nodesOf(BoxedSections.create());
assertThat(nodes.stream().filter(n -> n.name().startsWith("CvV2Banner")).toList())
.isNotEmpty().allMatch(DocumentNode::keepWithNext);
assertThat(nodes.stream().filter(n -> n.name().startsWith("CvV2Body")).toList())
.isNotEmpty().noneMatch(DocumentNode::keepWithNext);
}

/**
* Combined header+body preset: the nested header subsection is kept with next, but
* the module section that wraps header and body is NOT — marking it would bind the
* whole module to the next module instead of keeping the title with its own body.
*/
@Test
void classicSerifKeepsHeaderSubsectionNotModule() {
List<DocumentNode> nodes = nodesOf(ClassicSerif.create());
assertThat(nodes.stream().filter(n -> n.name().startsWith("CvV2ClassicSerif")).toList())
.isNotEmpty().noneMatch(DocumentNode::keepWithNext);
assertThat(nodes.stream().filter(n -> "Header".equals(n.name())).toList())
.isNotEmpty().allMatch(DocumentNode::keepWithNext);
}

/** Multi-node banner header: the whole rule + banner + rule title run is kept together. */
@Test
void blueBannerKeepsWholeTitleRun() {
List<DocumentNode> nodes = nodesOf(BlueBanner.create());
List<DocumentNode> firstRun = nodes.stream()
.filter(n -> n.name().startsWith("BlueBannerTitle_0")).toList();
assertThat(firstRun).hasSizeGreaterThanOrEqualTo(3).allMatch(DocumentNode::keepWithNext);
}

/**
* End-to-end for the combined-preset shape: a module section whose header is a
* nested keep-with-next subsection relocates the header with the first line of the
* body across a page break, rather than stranding it at the page bottom.
*/
@Test
void nestedHeaderSubsectionStaysWithBodyAcrossBoundary() {
int headerPage = combinedModulePages(true, "HeaderMark");
int bodyPage = combinedModulePages(true, "BodyMark");
assertThat(headerPage).isEqualTo(bodyPage);

// Control: without the opt-in, the header strands on the earlier page.
assertThat(combinedModulePages(false, "HeaderMark"))
.isLessThan(combinedModulePages(false, "BodyMark"));
}

private static int combinedModulePages(boolean keepWithNext, String mark) {
try (DocumentSession document = GraphCompose.document()
.pageSize(300, 400).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) {
document.pageFlow().name("Flow").spacing(12)
.addSection("Filler", s -> s.addShape(260, 250, GREY))
.addSection("Module", host -> {
host.spacing(12);
// Header nested in its own subsection (the ClassicSerif shape).
host.addSection("Header", header -> {
if (keepWithNext) {
header.keepWithNext();
}
header.addShape(shape -> shape.name("HeaderMark").size(260, 40).fillColor(INK));
});
host.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 @@ -122,8 +122,11 @@ public void compose(DocumentSession document, CvDocument doc) {
for (int i = 0; i < sections.size(); i++) {
final CvSection sec = sections.get(i);
final int idx = i;
pageFlow.addSection("CvV2Banner_" + idx,
host -> SectionHeader.banner(host, sec.title(), theme));
pageFlow.addSection("CvV2Banner_" + idx, host -> {
// Keep the banner with the first line of its body across a page break.
host.keepWithNext();
SectionHeader.banner(host, sec.title(), theme);
});
pageFlow.addSection("CvV2Body_" + idx,
host -> SectionDispatcher.renderBody(host, sec, theme));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,17 @@ public void compose(DocumentSession document, CvDocument doc) {
for (int i = 0; i < sections.size(); i++) {
final CvSection sec = sections.get(i);
final int idx = i;
pageFlow.addSection("Title_" + idx, host ->
SectionHeader.flatSpacedCaps(host, sec.title(),
theme.palette().muted(), theme, null));
pageFlow.addLine(line ->
rule(line, "TitleBottomRule_" + idx, ruleWidth, 8, 8));
// Title + its trailing rule form one keep-with-next run so the group
// stays with the first line of the body across a page break.
pageFlow.addSection("Title_" + idx, host -> {
host.keepWithNext();
SectionHeader.flatSpacedCaps(host, sec.title(),
theme.palette().muted(), theme, null);
});
pageFlow.addLine(line -> {
rule(line, "TitleBottomRule_" + idx, ruleWidth, 8, 8);
line.keepWithNext();
});
pageFlow.addSection("Body_" + idx, host ->
renderBody(host, sec));
if (idx < sections.size() - 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,24 @@ private void addSummary(PageFlowBuilder flow, CvSection section) {
});
}

/**
* Renders the spaced-caps rule header inside a keep-with-next subsection so
* the title + rule stay with the first line of the module body across a page
* break instead of stranding at a page bottom. The subsection's spacing
* matches the module spacing and it adds no padding or margin, so header and
* body placement is unchanged from rendering the header directly into the
* module host.
*/
private void keptHeader(SectionBuilder host, String title) {
host.addSection("Header", header -> {
header.keepWithNext()
.spacing(theme.spacing().sectionBodySpacing());
SectionHeader.spacedCapsRule(header, title, theme,
titleStyle(), ACCENT, 72, 1.0,
new DocumentInsets(0, 0, 2, 0));
});
}

private void addCoverSkillsModule(PageFlowBuilder flow, CvSection section) {
if (!SectionLookup.hasContent(section)) {
return;
Expand All @@ -177,9 +195,7 @@ private void addCoverSkillsModule(PageFlowBuilder flow, CvSection section) {
flow.addSection("CvV2ClassicSerifCoreSkills", host -> {
host.spacing(theme.spacing().sectionBodySpacing())
.padding(new DocumentInsets(0, 0, 2, 0));
SectionHeader.spacedCapsRule(host, "Core Skills", theme,
titleStyle(), ACCENT, 72, 1.0,
new DocumentInsets(0, 0, 2, 0));
keptHeader(host, "Core Skills");
renderCoverSkillsBody(host, section);
});
}
Expand All @@ -205,9 +221,7 @@ private void addLinearModule(PageFlowBuilder flow, String title,
flow.addSection("CvV2ClassicSerif" + SectionLookup.normalize(title), host -> {
host.spacing(theme.spacing().sectionBodySpacing())
.padding(new DocumentInsets(0, 0, 2, 0));
SectionHeader.spacedCapsRule(host, title, theme,
titleStyle(), ACCENT, 72, 1.0,
new DocumentInsets(0, 0, 2, 0));
keptHeader(host, title);
renderDetailBody(host, section);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,12 @@ public void compose(DocumentSession document, CvDocument doc) {
for (int i = 0; i < sections.size(); i++) {
CvSection sec = sections.get(i);
int idx = i;
flow.addSection("CvV2ExecutiveTitle_" + idx, host ->
SectionHeader.flat(host,
sec.title().toUpperCase(Locale.ROOT),
ACCENT, theme));
flow.addSection("CvV2ExecutiveTitle_" + idx, host -> {
host.keepWithNext();
SectionHeader.flat(host,
sec.title().toUpperCase(Locale.ROOT),
ACCENT, theme);
});
flow.addSection("CvV2ExecutiveBody_" + idx, host ->
SectionDispatcher.renderBody(host, sec, theme));
}
Expand Down
Loading
Loading