diff --git a/assets/readme/examples/table-of-contents.pdf b/assets/readme/examples/table-of-contents.pdf index 7161a42a1..6b8570fce 100644 Binary files a/assets/readme/examples/table-of-contents.pdf and b/assets/readme/examples/table-of-contents.pdf differ diff --git a/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java b/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java index 190e163d7..438d1288c 100644 --- a/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java @@ -1,8 +1,10 @@ package com.demcha.compose.document.dsl; import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.RowVerticalAlign; import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentLeader; import com.demcha.compose.document.style.DocumentLineCap; import com.demcha.compose.document.style.DocumentRowColumn; @@ -29,6 +31,13 @@ public final class TocBuilder { private static final DocumentColor DEFAULT_LEADER_COLOR = DocumentColor.rgb(150, 150, 150); + // A bottom-aligned thin line lands on the descender line; lifting it by roughly the + // font's descent seats it on the text baseline. The exact descent is a font metric + // not reachable from the DSL layer, so this is a deliberate approximation tuned for + // typical text faces. It assumes the entry and page-number styles share a size — the + // common case; if they differ they sit on different baselines regardless. + private static final double LEADER_BASELINE_LIFT_RATIO = 0.2; + private String title = ""; private DocumentTextStyle titleStyle = DocumentTextStyle.DEFAULT.withSize(16); private DocumentTextStyle entryStyle = DocumentTextStyle.DEFAULT; @@ -147,15 +156,21 @@ List buildEntries() { } private DocumentNode buildEntryRow(Entry entry) { + // Bottom-align so the leader sits on the entries' baseline rather than + // riding along the top of the line. RowBuilder row = new RowBuilder() .gap(6) + .verticalAlign(RowVerticalAlign.BOTTOM) .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1), DocumentRowColumn.auto()); row.addParagraph(p -> p.text(entry.label()).textStyle(entryStyle).linkTo(entry.anchor())); if (leader == DocumentLeader.NONE) { row.addSpacer(s -> s.width(1).height(1)); } else { + // Lift the leader off the descender line onto the baseline (see the ratio above). + double baselineLift = entryStyle.size() * LEADER_BASELINE_LIFT_RATIO; row.addLine(line -> { - line.fill().stroke(DocumentStroke.of(leaderColor, 1.0)); + line.fill().stroke(DocumentStroke.of(leaderColor, 1.0)) + .margin(DocumentInsets.bottom(baselineLift)); if (leader == DocumentLeader.DOTS) { line.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND); } else { diff --git a/src/test/java/com/demcha/compose/document/dsl/TocBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/TocBuilderTest.java index 414d8c31d..5a2b1b2bf 100644 --- a/src/test/java/com/demcha/compose/document/dsl/TocBuilderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/TocBuilderTest.java @@ -1,7 +1,15 @@ package com.demcha.compose.document.dsl; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LineNode; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.RowVerticalAlign; +import com.demcha.compose.document.style.DocumentLeader; import org.junit.jupiter.api.Test; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; /** @@ -11,6 +19,34 @@ */ class TocBuilderTest { + @Test + void entryRowIsBottomAlignedSoTheLeaderSitsOnTheBaseline() { + List nodes = new TocBuilder() + .leader(DocumentLeader.DOTS) + .entry("Introduction", "intro") + .buildEntries(); + + DocumentNode entryRow = nodes.get(nodes.size() - 1); // no title set -> the entry row is last + assertThat(entryRow).isInstanceOf(RowNode.class); + assertThat(((RowNode) entryRow).verticalAlign()).isEqualTo(RowVerticalAlign.BOTTOM); + } + + @Test + void theLeaderIsLiftedOntoTheBaselineNotLeftOnTheDescender() { + // Bottom-alignment alone leaves the leader on the descender line — the user-visible + // fix is the upward lift, so pin that the leader carries a positive bottom margin. + List nodes = new TocBuilder() + .leader(DocumentLeader.DOTS) + .entry("Introduction", "intro") + .buildEntries(); + + LineNode leader = (LineNode) ((RowNode) nodes.get(nodes.size() - 1)).children().stream() + .filter(LineNode.class::isInstance) + .findFirst() + .orElseThrow(); + assertThat(leader.margin().bottom()).isGreaterThan(0.0); + } + @Test void entryRejectsBlankLabel() { assertThatExceptionOfType(IllegalArgumentException.class)