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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ PDF `GoTo` actions. External links are unchanged.

### Public API

- **`LineBuilder.fill()`** (`@since 1.9.0`). A line stretches to the width
available where it is placed — its column inside a row, or the content width at
flow level — instead of its authored fixed width. Paired with a dotted stroke
(`dashed(0.1, 4).lineCap(ROUND)`) it is the flex leader behind a
table-of-contents row, drawn without measuring the gap by hand. A non-fill line
is unchanged, so existing line output stays byte-identical.

- **Negative-margin handling** (`@since 1.9.0`). A negative **page** margin
(`DocumentSession.margin(...)` or the builder's `margin(...)`) is now rejected
with an `IllegalArgumentException` — it would make the content area larger than
Expand Down
Binary file added assets/readme/examples/line-fill.pdf
Binary file not shown.
18 changes: 18 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ are with the canonical DSL, then jump to its detailed section below.
| [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) |
| [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) |
| [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) |
| [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) |
| [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) |
| [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) |
| [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) |
Expand Down Expand Up @@ -404,6 +405,23 @@ flow.addLine(l -> l.horizontal(w).stroke(stroke)
[📄 View PDF](../assets/readme/examples/line-cap.pdf) ·
[📜 Full source](src/main/java/com/demcha/examples/features/shapes/LineCapExample.java)

### Fill lines & dot leaders

`LineBuilder.fill()` stretches a line to the width available where it is placed —
the content width at flow level, or its column inside a row — instead of an
authored fixed width. Paired with a dotted stroke it is the flex leader behind a
table-of-contents row, drawn without measuring the gap by hand. A non-fill line
keeps its fixed width, so existing line output stays byte-identical.

```java
flow.addRow(r -> r.weights(5, 1)
.addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)) // leader fills its column
.addParagraph("p. 12"));
```

[📄 View PDF](../assets/readme/examples/line-fill.pdf) ·
[📜 Full source](src/main/java/com/demcha/examples/features/shapes/LineFillExample.java)

### SVG icon gallery

A stress-test sheet for the beta SVG reader: 34 real-world multicolour
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.demcha.examples.features.layout.BlockAlignExample;
import com.demcha.examples.features.lists.NestedListExample;
import com.demcha.examples.features.shapes.LineCapExample;
import com.demcha.examples.features.shapes.LineFillExample;
import com.demcha.examples.features.shapes.PhotoClipExample;
import com.demcha.examples.features.shapes.ShapeContainerExample;
import com.demcha.examples.features.shapes.VectorPathExample;
Expand Down Expand Up @@ -147,6 +148,7 @@ public static void main(String[] args) throws Exception {
System.out.println("Generated: " + ShapeContainerExample.generate());
System.out.println("Generated: " + VectorPathExample.generate());
System.out.println("Generated: " + LineCapExample.generate());
System.out.println("Generated: " + LineFillExample.generate());
System.out.println("Generated: " + PhotoClipExample.generate());
System.out.println("Generated: " + SvgIconGalleryExample.generate());
System.out.println("Generated: " + BlockAlignExample.generate());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.demcha.examples.features.shapes;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentLineCap;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.examples.support.ExampleOutputPaths;

import java.nio.file.Path;

/**
* Runnable showcase for v1.9 {@code LineBuilder.fill()}: a line stretches to the
* width available where it is placed — the content width at flow level, or its
* slot inside a row — instead of an authored fixed width. Paired with a dotted
* stroke it is the flex leader behind a table-of-contents row, drawn without
* measuring the gap by hand.
*
* <pre>{@code
* flow.addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(ROUND)); // full-width dots
* flow.addRow(r -> r.weights(4, 1)
* .addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(ROUND)) // leader fills its column
* .addParagraph("p. 12"));
* }</pre>
*
* @author Artem Demchyshyn
*/
public final class LineFillExample {

private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
private static final DocumentColor MUTED = DocumentColor.rgb(90, 96, 105);

private LineFillExample() {
}

/**
* Renders the fill sheet: a full-width rule, a full-width dotted leader, and
* a weighted leader-to-number row that previews a table-of-contents line.
*
* @return path to the generated PDF
* @throws Exception if rendering or file IO fails
*/
public static Path generate() throws Exception {
Path pdfFile = ExampleOutputPaths.prepare("features/shapes", "line-fill.pdf");

DocumentTextStyle caption = DocumentTextStyle.DEFAULT.withSize(10).withColor(MUTED);
DocumentStroke rule = DocumentStroke.of(INK, 1.2);
DocumentStroke dots = DocumentStroke.of(MUTED, 1.4);

try (DocumentSession document = GraphCompose.document(pdfFile)
.pageSize(400, 260)
.margin(DocumentInsets.of(36))
.create()) {
document.pageFlow(page -> {
page.addParagraph(p -> p
.text("Fill lines & dot leaders")
.textStyle(DocumentTextStyle.DEFAULT.withSize(18)));

page.addParagraph(p -> p.text("line().fill() — rule spans the content width")
.textStyle(caption).padding(DocumentInsets.top(12)));
page.addLine(l -> l.fill().height(2).stroke(rule));

page.addParagraph(p -> p.text("line().fill().dashed(0.1, 4).lineCap(ROUND) — dotted leader")
.textStyle(caption).padding(DocumentInsets.top(12)));
page.addLine(l -> l.fill().height(2).stroke(dots)
.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND));

page.addParagraph(p -> p.text("weighted row: leader fills the gap up to the page number")
.textStyle(caption).padding(DocumentInsets.top(12)));
page.addRow(r -> r.gap(6).weights(5, 1)
.addLine(l -> l.fill().height(8).stroke(dots)
.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND))
.addParagraph(p -> p.text("p. 12").textStyle(caption)));
});

document.buildPdf();
}

return pdfFile;
}

public static void main(String[] args) throws Exception {
System.out.println("Generated: " + generate());
}
}
24 changes: 23 additions & 1 deletion src/main/java/com/demcha/compose/document/dsl/LineBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public final class LineBuilder implements Transformable<LineBuilder> {
private DocumentTransform transform = DocumentTransform.NONE;
private DocumentDashPattern dashPattern = DocumentDashPattern.NONE;
private DocumentLineCap lineCap = DocumentLineCap.BUTT;
private boolean fillWidth = false;

/**
* Creates a line builder.
Expand Down Expand Up @@ -259,6 +260,26 @@ public LineBuilder lineCap(DocumentLineCap lineCap) {
return this;
}

/**
* Stretches a horizontal line to fill the width available where it is placed,
* instead of its fixed {@link #width(double)}. In a row column the line spans
* the whole slot; at flow level it spans the content width. This is the flex
* line behind a dot leader — pair it with {@code dashed(...)} (and
* {@code lineCap(ROUND)} for dots) so the leader runs from one column to the
* next without computing the gap width by hand.
*
* <p>Applies to horizontal lines only (the default geometry); on a vertical
* or diagonal line it is a no-op, since stretching the end point would change
* the line's slope.</p>
*
* @return this builder
* @since 1.9.0
*/
public LineBuilder fill() {
this.fillWidth = true;
return this;
}

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

private boolean isHorizontalLine() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,25 @@ public Class<LineNode> nodeType() {

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

/**
* Fill applies only to horizontal lines — stretching the end point of a
* vertical or diagonal line would silently change its geometry, so fill is a
* no-op there.
*/
private static boolean isFill(LineNode node) {
return node.fillWidth() && Math.abs(node.startY() - node.endY()) <= EPS;
}

@Override
public PaginationPolicy paginationPolicy(LineNode node) {
return PaginationPolicy.ATOMIC;
Expand All @@ -49,6 +63,9 @@ public List<LayoutFragment> emitFragments(PreparedNode<LineNode> prepared,
if (width <= EPS && height <= EPS) {
return List.of();
}
// A fill line is stretched to its resolved box, so it ends at the box's
// right content edge instead of the authored endX.
double endX = isFill(node) ? width : node.endX();
LayoutFragment leaf = new LayoutFragment(
placement.path(),
0,
Expand All @@ -60,7 +77,7 @@ public List<LayoutFragment> emitFragments(PreparedNode<LineNode> prepared,
toStroke(node.stroke()),
node.startX(),
node.startY(),
node.endX(),
endX,
node.endY(),
node.linkTarget(),
node.bookmarkOptions(),
Expand Down
50 changes: 48 additions & 2 deletions src/main/java/com/demcha/compose/document/node/LineNode.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
* @param lineCap end-cap style for the stroke; defaults to
* {@link DocumentLineCap#BUTT}. {@code ROUND} turns a
* dashed stroke into a dotted line
* @param fillWidth when {@code true}, the line stretches to the width
* 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}.
* @author Artem Demchyshyn
*/
public record LineNode(
Expand All @@ -48,7 +52,8 @@ public record LineNode(
DocumentTransform transform,
DocumentDashPattern dashPattern,
String anchor,
DocumentLineCap lineCap
DocumentLineCap lineCap,
boolean fillWidth
) implements DocumentNode {
/**
* Normalizes spacing defaults and validates explicit line geometry.
Expand All @@ -69,6 +74,47 @@ public record LineNode(
requireFinite(endY, "endY");
}

/**
* Backward-compatible canonical constructor without the fill flag — defaults
* to {@code false} (a fixed-width, byte-identical line).
*
* @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
*/
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) {
this(name, width, height, startX, startY, endX, endY, stroke, linkTarget, bookmarkOptions,
padding, margin, transform, dashPattern, anchor, lineCap, false);
}

/**
* Backward-compatible canonical constructor without the line cap — defaults
* to {@link DocumentLineCap#BUTT} (a squared, byte-identical end).
Expand Down Expand Up @@ -105,7 +151,7 @@ public LineNode(String name,
DocumentDashPattern dashPattern,
String anchor) {
this(name, width, height, startX, startY, endX, endY, stroke, linkTarget, bookmarkOptions,
padding, margin, transform, dashPattern, anchor, DocumentLineCap.BUTT);
padding, margin, transform, dashPattern, anchor, DocumentLineCap.BUTT, false);
}

/**
Expand Down
80 changes: 80 additions & 0 deletions src/test/java/com/demcha/compose/document/dsl/LineFillTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.demcha.compose.document.dsl;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.layout.PlacedNode;
import com.demcha.compose.document.style.DocumentInsets;
import org.junit.jupiter.api.Test;

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

/**
* {@link LineBuilder#fill()} stretches a line to the width available where it is
* placed (its row slot or the content width) instead of its authored width — the
* flex line behind a dot leader. A non-fill line keeps its fixed width.
*/
class LineFillTest {

@Test
void fillLineStretchesToItsRowSlotWhileAFixedLineKeepsItsWidth() {
double filled = lineWidthInTwoEqualColumns(true);
double fixed = lineWidthInTwoEqualColumns(false);

// inner width = 240 - 2*20 = 200; gap 0; two equal weights => 100pt slots.
assertThat(filled).isCloseTo(100.0, within(0.5));
// A non-fill line keeps its authored width (LineBuilder default 1.0).
assertThat(fixed).isCloseTo(1.0, within(0.5));
}

@Test
void fillLineSpansTheContentWidthAtFlowLevel() {
try (DocumentSession session = GraphCompose.document()
.pageSize(240, 200)
.margin(DocumentInsets.of(20))
.create()) {
session.pageFlow(page -> page.addLine(l -> l.name("rule").height(2).fill()));
assertThat(placedWidth(session, "rule")).isCloseTo(200.0, within(0.5));
}
}

@Test
void fillIsANoOpForAVerticalLine() {
try (DocumentSession session = GraphCompose.document()
.pageSize(240, 200)
.margin(DocumentInsets.of(20))
.create()) {
// Stretching the end point would change the slope, so fill leaves a
// vertical line at its authored (stroke-width) box instead of 200pt.
session.pageFlow(page -> page.addLine(l -> l.name("rule").vertical(30).fill()));
assertThat(placedWidth(session, "rule")).isLessThan(20.0);
}
}

private double lineWidthInTwoEqualColumns(boolean fill) {
try (DocumentSession session = GraphCompose.document()
.pageSize(240, 200)
.margin(DocumentInsets.of(20))
.create()) {
session.pageFlow(page -> page.addRow(r -> {
r.gap(0).weights(1, 1);

Check notice

Code scanning / CodeQL

Deprecated method or constructor invocation Note test

Invoking
RowBuilder.gap
should be avoided because it has been deprecated.
r.addLine(l -> {
l.name("rule").height(2);
if (fill) {
l.fill();
}
});
r.addParagraph("X");
}));
return placedWidth(session, "rule");
}
}

private double placedWidth(DocumentSession session, String name) {
PlacedNode node = session.layoutGraph().nodes().stream()
.filter(n -> name.equals(n.semanticName()))
.findFirst()
.orElseThrow(() -> new AssertionError(name + " not placed"));
return node.placementWidth();
}
}