Skip to content

Commit 057ce39

Browse files
authored
feat(api): LineBuilder.fill() — line stretches to its available width (#234)
A line drew at its authored width; there was no way to make it span its column or the content width without computing that width by hand. fill() stretches a horizontal line to the width available where it is placed — its row column, or the content width at flow level — so a dotted leader runs from one column to the next on its own. It is the flex line behind a table-of-contents row. Resolved in LineDefinition: a fill line measures to the available width and draws to the box's right edge; fill is a no-op on a vertical or diagonal line, where stretching the end point would change the slope. A non-fill line is unchanged, so existing line output is byte-identical. Tests: LineFillTest covers a fill line resolving to its row slot and to the content width at flow level, a non-fill line keeping its authored width, and fill as a no-op on a vertical line. Example: LineFillExample (full-width rule, dotted leader, weighted leader-to-number row). Full suite green, no visual baselines changed.
1 parent b740a7d commit 057ce39

9 files changed

Lines changed: 284 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ PDF `GoTo` actions. External links are unchanged.
1212

1313
### Public API
1414

15+
- **`LineBuilder.fill()`** (`@since 1.9.0`). A line stretches to the width
16+
available where it is placed — its column inside a row, or the content width at
17+
flow level — instead of its authored fixed width. Paired with a dotted stroke
18+
(`dashed(0.1, 4).lineCap(ROUND)`) it is the flex leader behind a
19+
table-of-contents row, drawn without measuring the gap by hand. A non-fill line
20+
is unchanged, so existing line output stays byte-identical.
21+
1522
- **Negative-margin handling** (`@since 1.9.0`). A negative **page** margin
1623
(`DocumentSession.margin(...)` or the builder's `margin(...)`) is now rejected
1724
with an `IllegalArgumentException` — it would make the content area larger than
1.13 KB
Binary file not shown.

examples/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ are with the canonical DSL, then jump to its detailed section below.
9393
| [Shape containers](#shape-containers) | Circles, ellipses, rounded cards with `ClipPolicy.CLIP_PATH` | [PDF](../assets/readme/examples/shape-container.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/ShapeContainerExample.java) |
9494
| [Vector paths (Bézier)](#vector-paths-bézier) | `addPath(...)` + `SvgPath.parse(...)` — design shapes and imported SVG icons as native curves; zero tessellation | [PDF](../assets/readme/examples/vector-path.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java) |
9595
| [Line caps & dotted lines](#line-caps--dotted-lines) | `line.lineCap(ROUND)` — round / square caps for plain lines; `dashed(0.1, 4).lineCap(ROUND)` draws a dotted leader | [PDF](../assets/readme/examples/line-cap.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/LineCapExample.java) |
96+
| [Fill lines & dot leaders](#fill-lines--dot-leaders) | `line().fill()` — a line stretches to its row column or the content width; pair with `dashed(...).lineCap(ROUND)` for a dot leader drawn without measuring the gap | [PDF](../assets/readme/examples/line-fill.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/LineFillExample.java) |
9697
| [SVG icon gallery](#svg-icon-gallery) | 34 real-world multicolour svgrepo icons via `SvgIcon.parse` — up to 19 layers each, the whole set 156 KB of sources | [PDF](../assets/readme/examples/svg-icon-gallery.pdf) · [Source](src/main/java/com/demcha/examples/features/svg/SvgIconGalleryExample.java) |
9798
| [Advanced tables](#advanced-tables) | Row span, zebra rows, totals, repeating header on page break | [PDF](../assets/readme/examples/table-advanced.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/TableAdvancedExample.java) |
9899
| [Barcodes](#barcodes) | QR, Code 128, Code 39, EAN-13, EAN-8, branded QR with theme colours | [PDF](../assets/readme/examples/barcode-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/barcodes/BarcodeShowcaseExample.java) |
@@ -404,6 +405,23 @@ flow.addLine(l -> l.horizontal(w).stroke(stroke)
404405
[📄 View PDF](../assets/readme/examples/line-cap.pdf) ·
405406
[📜 Full source](src/main/java/com/demcha/examples/features/shapes/LineCapExample.java)
406407

408+
### Fill lines & dot leaders
409+
410+
`LineBuilder.fill()` stretches a line to the width available where it is placed —
411+
the content width at flow level, or its column inside a row — instead of an
412+
authored fixed width. Paired with a dotted stroke it is the flex leader behind a
413+
table-of-contents row, drawn without measuring the gap by hand. A non-fill line
414+
keeps its fixed width, so existing line output stays byte-identical.
415+
416+
```java
417+
flow.addRow(r -> r.weights(5, 1)
418+
.addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)) // leader fills its column
419+
.addParagraph("p. 12"));
420+
```
421+
422+
[📄 View PDF](../assets/readme/examples/line-fill.pdf) ·
423+
[📜 Full source](src/main/java/com/demcha/examples/features/shapes/LineFillExample.java)
424+
407425
### SVG icon gallery
408426

409427
A stress-test sheet for the beta SVG reader: 34 real-world multicolour

examples/src/main/java/com/demcha/examples/GenerateAllExamples.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.demcha.examples.features.layout.BlockAlignExample;
1212
import com.demcha.examples.features.lists.NestedListExample;
1313
import com.demcha.examples.features.shapes.LineCapExample;
14+
import com.demcha.examples.features.shapes.LineFillExample;
1415
import com.demcha.examples.features.shapes.PhotoClipExample;
1516
import com.demcha.examples.features.shapes.ShapeContainerExample;
1617
import com.demcha.examples.features.shapes.VectorPathExample;
@@ -147,6 +148,7 @@ public static void main(String[] args) throws Exception {
147148
System.out.println("Generated: " + ShapeContainerExample.generate());
148149
System.out.println("Generated: " + VectorPathExample.generate());
149150
System.out.println("Generated: " + LineCapExample.generate());
151+
System.out.println("Generated: " + LineFillExample.generate());
150152
System.out.println("Generated: " + PhotoClipExample.generate());
151153
System.out.println("Generated: " + SvgIconGalleryExample.generate());
152154
System.out.println("Generated: " + BlockAlignExample.generate());
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.demcha.examples.features.shapes;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.style.DocumentColor;
6+
import com.demcha.compose.document.style.DocumentInsets;
7+
import com.demcha.compose.document.style.DocumentLineCap;
8+
import com.demcha.compose.document.style.DocumentStroke;
9+
import com.demcha.compose.document.style.DocumentTextStyle;
10+
import com.demcha.examples.support.ExampleOutputPaths;
11+
12+
import java.nio.file.Path;
13+
14+
/**
15+
* Runnable showcase for v1.9 {@code LineBuilder.fill()}: a line stretches to the
16+
* width available where it is placed — the content width at flow level, or its
17+
* slot inside a row — instead of an authored fixed width. Paired with a dotted
18+
* stroke it is the flex leader behind a table-of-contents row, drawn without
19+
* measuring the gap by hand.
20+
*
21+
* <pre>{@code
22+
* flow.addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(ROUND)); // full-width dots
23+
* flow.addRow(r -> r.weights(4, 1)
24+
* .addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(ROUND)) // leader fills its column
25+
* .addParagraph("p. 12"));
26+
* }</pre>
27+
*
28+
* @author Artem Demchyshyn
29+
*/
30+
public final class LineFillExample {
31+
32+
private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
33+
private static final DocumentColor MUTED = DocumentColor.rgb(90, 96, 105);
34+
35+
private LineFillExample() {
36+
}
37+
38+
/**
39+
* Renders the fill sheet: a full-width rule, a full-width dotted leader, and
40+
* a weighted leader-to-number row that previews a table-of-contents line.
41+
*
42+
* @return path to the generated PDF
43+
* @throws Exception if rendering or file IO fails
44+
*/
45+
public static Path generate() throws Exception {
46+
Path pdfFile = ExampleOutputPaths.prepare("features/shapes", "line-fill.pdf");
47+
48+
DocumentTextStyle caption = DocumentTextStyle.DEFAULT.withSize(10).withColor(MUTED);
49+
DocumentStroke rule = DocumentStroke.of(INK, 1.2);
50+
DocumentStroke dots = DocumentStroke.of(MUTED, 1.4);
51+
52+
try (DocumentSession document = GraphCompose.document(pdfFile)
53+
.pageSize(400, 260)
54+
.margin(DocumentInsets.of(36))
55+
.create()) {
56+
document.pageFlow(page -> {
57+
page.addParagraph(p -> p
58+
.text("Fill lines & dot leaders")
59+
.textStyle(DocumentTextStyle.DEFAULT.withSize(18)));
60+
61+
page.addParagraph(p -> p.text("line().fill() — rule spans the content width")
62+
.textStyle(caption).padding(DocumentInsets.top(12)));
63+
page.addLine(l -> l.fill().height(2).stroke(rule));
64+
65+
page.addParagraph(p -> p.text("line().fill().dashed(0.1, 4).lineCap(ROUND) — dotted leader")
66+
.textStyle(caption).padding(DocumentInsets.top(12)));
67+
page.addLine(l -> l.fill().height(2).stroke(dots)
68+
.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND));
69+
70+
page.addParagraph(p -> p.text("weighted row: leader fills the gap up to the page number")
71+
.textStyle(caption).padding(DocumentInsets.top(12)));
72+
page.addRow(r -> r.gap(6).weights(5, 1)
73+
.addLine(l -> l.fill().height(8).stroke(dots)
74+
.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND))
75+
.addParagraph(p -> p.text("p. 12").textStyle(caption)));
76+
});
77+
78+
document.buildPdf();
79+
}
80+
81+
return pdfFile;
82+
}
83+
84+
public static void main(String[] args) throws Exception {
85+
System.out.println("Generated: " + generate());
86+
}
87+
}

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public final class LineBuilder implements Transformable<LineBuilder> {
3131
private DocumentTransform transform = DocumentTransform.NONE;
3232
private DocumentDashPattern dashPattern = DocumentDashPattern.NONE;
3333
private DocumentLineCap lineCap = DocumentLineCap.BUTT;
34+
private boolean fillWidth = false;
3435

3536
/**
3637
* Creates a line builder.
@@ -259,6 +260,26 @@ public LineBuilder lineCap(DocumentLineCap lineCap) {
259260
return this;
260261
}
261262

263+
/**
264+
* Stretches a horizontal line to fill the width available where it is placed,
265+
* instead of its fixed {@link #width(double)}. In a row column the line spans
266+
* the whole slot; at flow level it spans the content width. This is the flex
267+
* line behind a dot leader — pair it with {@code dashed(...)} (and
268+
* {@code lineCap(ROUND)} for dots) so the leader runs from one column to the
269+
* next without computing the gap width by hand.
270+
*
271+
* <p>Applies to horizontal lines only (the default geometry); on a vertical
272+
* or diagonal line it is a no-op, since stretching the end point would change
273+
* the line's slope.</p>
274+
*
275+
* @return this builder
276+
* @since 1.9.0
277+
*/
278+
public LineBuilder fill() {
279+
this.fillWidth = true;
280+
return this;
281+
}
282+
262283
/**
263284
* Attaches line-level external link metadata.
264285
*
@@ -382,7 +403,8 @@ public LineNode build() {
382403
transform,
383404
dashPattern,
384405
anchor,
385-
lineCap);
406+
lineCap,
407+
fillWidth);
386408
}
387409

388410
private boolean isHorizontalLine() {

src/main/java/com/demcha/compose/document/layout/definitions/LineDefinition.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,25 @@ public Class<LineNode> nodeType() {
2929

3030
@Override
3131
public PreparedNode<LineNode> prepare(LineNode node, PrepareContext ctx, BoxConstraints constraints) {
32+
// A horizontal fill line claims the width available where it is placed (its
33+
// row slot, or the content width) rather than its authored fixed width.
34+
double outerWidth = isFill(node)
35+
? constraints.availableWidth()
36+
: node.width() + node.padding().horizontal();
3237
return PreparedNode.leaf(node, new MeasureResult(
33-
node.width() + node.padding().horizontal(),
38+
outerWidth,
3439
node.height() + node.padding().vertical()));
3540
}
3641

42+
/**
43+
* Fill applies only to horizontal lines — stretching the end point of a
44+
* vertical or diagonal line would silently change its geometry, so fill is a
45+
* no-op there.
46+
*/
47+
private static boolean isFill(LineNode node) {
48+
return node.fillWidth() && Math.abs(node.startY() - node.endY()) <= EPS;
49+
}
50+
3751
@Override
3852
public PaginationPolicy paginationPolicy(LineNode node) {
3953
return PaginationPolicy.ATOMIC;
@@ -49,6 +63,9 @@ public List<LayoutFragment> emitFragments(PreparedNode<LineNode> prepared,
4963
if (width <= EPS && height <= EPS) {
5064
return List.of();
5165
}
66+
// A fill line is stretched to its resolved box, so it ends at the box's
67+
// right content edge instead of the authored endX.
68+
double endX = isFill(node) ? width : node.endX();
5269
LayoutFragment leaf = new LayoutFragment(
5370
placement.path(),
5471
0,
@@ -60,7 +77,7 @@ public List<LayoutFragment> emitFragments(PreparedNode<LineNode> prepared,
6077
toStroke(node.stroke()),
6178
node.startX(),
6279
node.startY(),
63-
node.endX(),
80+
endX,
6481
node.endY(),
6582
node.linkTarget(),
6683
node.bookmarkOptions(),

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
* @param lineCap end-cap style for the stroke; defaults to
3131
* {@link DocumentLineCap#BUTT}. {@code ROUND} turns a
3232
* dashed stroke into a dotted line
33+
* @param fillWidth when {@code true}, the line stretches to the width
34+
* available where it is placed (its row slot, or the
35+
* content width) instead of {@code width}; the flex line
36+
* behind a dot leader. Defaults to {@code false}.
3337
* @author Artem Demchyshyn
3438
*/
3539
public record LineNode(
@@ -48,7 +52,8 @@ public record LineNode(
4852
DocumentTransform transform,
4953
DocumentDashPattern dashPattern,
5054
String anchor,
51-
DocumentLineCap lineCap
55+
DocumentLineCap lineCap,
56+
boolean fillWidth
5257
) implements DocumentNode {
5358
/**
5459
* Normalizes spacing defaults and validates explicit line geometry.
@@ -69,6 +74,47 @@ public record LineNode(
6974
requireFinite(endY, "endY");
7075
}
7176

77+
/**
78+
* Backward-compatible canonical constructor without the fill flag — defaults
79+
* to {@code false} (a fixed-width, byte-identical line).
80+
*
81+
* @param name node name used in snapshots and layout graph paths
82+
* @param width resolved line box width
83+
* @param height resolved line box height
84+
* @param startX line start x offset inside the box
85+
* @param startY line start y offset inside the box
86+
* @param endX line end x offset inside the box
87+
* @param endY line end y offset inside the box
88+
* @param stroke line stroke descriptor
89+
* @param linkTarget optional node-level link target
90+
* @param bookmarkOptions optional node-level bookmark metadata
91+
* @param padding inner padding
92+
* @param margin outer margin
93+
* @param transform render-time affine transform
94+
* @param dashPattern dash pattern for the stroke
95+
* @param anchor optional navigation anchor name
96+
* @param lineCap end-cap style for the stroke
97+
*/
98+
public LineNode(String name,
99+
double width,
100+
double height,
101+
double startX,
102+
double startY,
103+
double endX,
104+
double endY,
105+
DocumentStroke stroke,
106+
DocumentLinkTarget linkTarget,
107+
DocumentBookmarkOptions bookmarkOptions,
108+
DocumentInsets padding,
109+
DocumentInsets margin,
110+
DocumentTransform transform,
111+
DocumentDashPattern dashPattern,
112+
String anchor,
113+
DocumentLineCap lineCap) {
114+
this(name, width, height, startX, startY, endX, endY, stroke, linkTarget, bookmarkOptions,
115+
padding, margin, transform, dashPattern, anchor, lineCap, false);
116+
}
117+
72118
/**
73119
* Backward-compatible canonical constructor without the line cap — defaults
74120
* to {@link DocumentLineCap#BUTT} (a squared, byte-identical end).
@@ -105,7 +151,7 @@ public LineNode(String name,
105151
DocumentDashPattern dashPattern,
106152
String anchor) {
107153
this(name, width, height, startX, startY, endX, endY, stroke, linkTarget, bookmarkOptions,
108-
padding, margin, transform, dashPattern, anchor, DocumentLineCap.BUTT);
154+
padding, margin, transform, dashPattern, anchor, DocumentLineCap.BUTT, false);
109155
}
110156

111157
/**
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.demcha.compose.document.dsl;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.layout.PlacedNode;
6+
import com.demcha.compose.document.style.DocumentInsets;
7+
import org.junit.jupiter.api.Test;
8+
9+
import static org.assertj.core.api.Assertions.assertThat;
10+
import static org.assertj.core.api.Assertions.within;
11+
12+
/**
13+
* {@link LineBuilder#fill()} stretches a line to the width available where it is
14+
* placed (its row slot or the content width) instead of its authored width — the
15+
* flex line behind a dot leader. A non-fill line keeps its fixed width.
16+
*/
17+
class LineFillTest {
18+
19+
@Test
20+
void fillLineStretchesToItsRowSlotWhileAFixedLineKeepsItsWidth() {
21+
double filled = lineWidthInTwoEqualColumns(true);
22+
double fixed = lineWidthInTwoEqualColumns(false);
23+
24+
// inner width = 240 - 2*20 = 200; gap 0; two equal weights => 100pt slots.
25+
assertThat(filled).isCloseTo(100.0, within(0.5));
26+
// A non-fill line keeps its authored width (LineBuilder default 1.0).
27+
assertThat(fixed).isCloseTo(1.0, within(0.5));
28+
}
29+
30+
@Test
31+
void fillLineSpansTheContentWidthAtFlowLevel() {
32+
try (DocumentSession session = GraphCompose.document()
33+
.pageSize(240, 200)
34+
.margin(DocumentInsets.of(20))
35+
.create()) {
36+
session.pageFlow(page -> page.addLine(l -> l.name("rule").height(2).fill()));
37+
assertThat(placedWidth(session, "rule")).isCloseTo(200.0, within(0.5));
38+
}
39+
}
40+
41+
@Test
42+
void fillIsANoOpForAVerticalLine() {
43+
try (DocumentSession session = GraphCompose.document()
44+
.pageSize(240, 200)
45+
.margin(DocumentInsets.of(20))
46+
.create()) {
47+
// Stretching the end point would change the slope, so fill leaves a
48+
// vertical line at its authored (stroke-width) box instead of 200pt.
49+
session.pageFlow(page -> page.addLine(l -> l.name("rule").vertical(30).fill()));
50+
assertThat(placedWidth(session, "rule")).isLessThan(20.0);
51+
}
52+
}
53+
54+
private double lineWidthInTwoEqualColumns(boolean fill) {
55+
try (DocumentSession session = GraphCompose.document()
56+
.pageSize(240, 200)
57+
.margin(DocumentInsets.of(20))
58+
.create()) {
59+
session.pageFlow(page -> page.addRow(r -> {
60+
r.gap(0).weights(1, 1);
61+
r.addLine(l -> {
62+
l.name("rule").height(2);
63+
if (fill) {
64+
l.fill();
65+
}
66+
});
67+
r.addParagraph("X");
68+
}));
69+
return placedWidth(session, "rule");
70+
}
71+
}
72+
73+
private double placedWidth(DocumentSession session, String name) {
74+
PlacedNode node = session.layoutGraph().nodes().stream()
75+
.filter(n -> name.equals(n.semanticName()))
76+
.findFirst()
77+
.orElseThrow(() -> new AssertionError(name + " not placed"));
78+
return node.placementWidth();
79+
}
80+
}

0 commit comments

Comments
 (0)