From 33e36dfdca323f4f906a426310fa601744d0491d Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 23:18:23 +0100 Subject: [PATCH 1/3] fix(api): TOC dot leaders sit on the entry baseline, not the top addTableOfContents(...) laid each entry row with the default TOP cross-axis alignment, so the thin leader line rode along the top of the text instead of the baseline where a dot leader belongs. Bottom-align the entry row so the leader, the label, and the page number share the baseline. Tests: TocBuilderTest asserts the entry row is bottom-aligned. Output-only change (no API surface, no snapshot baselines affected); the committed table-of-contents preview is refreshed. --- assets/readme/examples/table-of-contents.pdf | Bin 2507 -> 2508 bytes .../compose/document/dsl/TocBuilder.java | 4 ++++ .../compose/document/dsl/TocBuilderTest.java | 19 ++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/assets/readme/examples/table-of-contents.pdf b/assets/readme/examples/table-of-contents.pdf index 7161a42a1db76dbd4160ff981012695e8f7b53dc..1d66b6171ff86f8e9c2f5918192a51280160a13e 100644 GIT binary patch delta 619 zcmX>td`5Ue3Zv!ZdrZm`JNWA_wWzo|q<&fU!m+xKN>E@5JM0yUz3}8XSDS+|P4K+Nnj1 zKc{+d*4%Ixf@48;S(#r+GFMz1DuQIi!j6lv2qaeerKkSl({Q zZn&nIWNmVj;cUdIFFUs$I&5=Z{I=1}xt1Dt?@T?neBJRXP0!qvi_4Y;&b{<+Z)d=C zo3||?%!;`y{vO|I619Hr`A^^fEs${K{q;O5_}^PEtDIRoO1v-nrB!_T&v-B+eljmt zYFfj%3~y3v%vCwZELv zmN=0saB?K4wfSLL*(#e4M;3fZH;$9z`3rY^VlK!V`AJz^#U+VFB^5=fXTSTmUlQ0a*Y5 delta 596 zcmX>jd|G%y3Zup3`%KCcJNWCjdCrz$oO*HgrP+@gGnua(6uGtFU(Iwr5BJkPu70=o z9KKz9|4rFpH_dL51eMA)3TJOV@MGfgc%D=6_+IxJTVJ;cSA7Z+r@9ElaAlXdMm7ed zzsZXE^`K_Odd1q*?V8movzym$u@TdFaxn6gz2*Ji0aJgPal5{}{Z6;};}o7PhV?5R zq|fb}*~0(Lr*FXnR+c9!HU_f`re-+!Jm_^5+G)6|a_x;T)fKb$O#H1H(0O5+)aqKH z-Y*wj4)AFmDsp{$F4?G1QKXKsbVlgGr_&EBb+T+-c*4+p#qFGLUgGoZBK zt>{XH+FZpoUi%9~Q%>Ks3gC5(u;e%$*^u?RK6}R#rG*Z9N=X8jKSpHVKCy2rU&dag zd2P$A7D%3UEqdp5k@0-@(QUKdzT?p{FQ58(=I#esnMZR|G%NO4+wNMXymIk1*H!-O zE-xuzUwNe9cYpk)g1u#S`gZ#4$20OW>N1n$w(c}}uc!7`XwEVr`Skxx_xNJJ^Kzx; zr4*MGr6x|+X5rc_&*Z^Y@8az2;_75>;c8^*W@_x@=4NE%=4xhY>f~f#Zfb1c>|$}+CgYVK-i;bLs!X6$Tf>1gR{U}|IvRPSP9r(i>g5iB4xCcolH=G9n~6FaN@;f%J# uiCmGBqd2Yko!hOeY(5-W@Fm?i`Ww&p$%-t(lecs7a#(Pws=E5SaRC4qsrSAB 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..f26671eb8 100644 --- a/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java @@ -1,6 +1,7 @@ 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.DocumentLeader; @@ -147,8 +148,11 @@ 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) { 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..688ee93ec 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,14 @@ package com.demcha.compose.document.dsl; +import com.demcha.compose.document.node.DocumentNode; +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 +18,18 @@ */ 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 entryRejectsBlankLabel() { assertThatExceptionOfType(IllegalArgumentException.class) From 02c4a8eefab88a6da71e0ad36227c54ba7a07886 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 23:31:20 +0100 Subject: [PATCH 2/3] fix(api): lift the TOC leader onto the baseline, level with the text Bottom-aligning the entry row dropped the thin leader line to the descender line, below the baseline the label and page number sit on. Lift the leader by about the font's descent (proportional to the entry size) so the dots are level with the text and the number. Committed table-of-contents preview refreshed. --- assets/readme/examples/table-of-contents.pdf | Bin 2508 -> 2507 bytes .../compose/document/dsl/TocBuilder.java | 8 +++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/assets/readme/examples/table-of-contents.pdf b/assets/readme/examples/table-of-contents.pdf index 1d66b6171ff86f8e9c2f5918192a51280160a13e..6b8570fce37212b90500c5e3bfdf89634c3216b3 100644 GIT binary patch delta 687 zcmX>jd|G%y3Zwbt`%KF9Yp3`JHya2Xeg9LW`il1~9Zr?w+p^sAqw@OL*)EAKZxajp z`gpd{-)1i32ut0_w77e}&*p7Q5t{74z&59i=}}M5I}3&83P(5dXSV)lFXu32x=N%* z6|lHjUe1bdN#h9hTDPpV^w_RfQjfxmq$j&?vOQJg>)h5OKh^U8r*Gb4Vo^# zZRI_I8RzykgmAI`S@T)*;RnZgdzCmU7};j#+X%=83VJ$xF<*Q^>Ym4eiCU^sdrfD~ zZ~7tf^?Y5X%aJAD)Q%`lb@?B7{GgbVPjKVK)|#|qJMZW%Eb@HmVGc_`C zb~QD2a&vNYadNb@G&FTJFmQHsa&mSuGH`J*b2K+MFmp0+GIn-0G;?)vb+%I=+6W0; zW=wv?5t?>dw8=q$^(Orm@kv)t1j fpXX)&jXm-l&qrWj2v6S5$;)BRrK;-c@5TiHpb8Q4 delta 708 zcmX>td`5Ue3Zv!ZyG+XUYo|B{A2JYVeg9Lm`ii&5r4|);htx02zC5dVv4!zf0#|N8 z{XTB4klb{wZ(H7$J^Q*hKW>xa;~6eOE=OJlIL(=OkeB_S!o0}`r&ga0{%#=a93|*B zb7~C3;yICc#Vz6nOojHx{nJ`|r*YdI`k>Eb%!0}GE)%pJkiE3a^!&CF_P5*I3T?45Z0Y}c7S zMT3LSm-~4xNjtTO@#jqSx9lHitBE zo>D5=qc8sL3Cr6p*$vk;ldMf{GMtS#^=0SQLx*k7i{Cc7IoDF-?wzUUmajWrrRkZQ za&g(Rz`2+H?d=ShZu7P!gjq3n#oyywO`_J%J^$(ZzXcMmyuY4D1^;{NWtB5)M~U}E zzqE=^Z2uV#X2eh4%*o`zR&UCsAC#Y8qF|_Cpb*5R@9CltZR6xWWaw(? z>g4KX>0)SL;OcDRY-r?WWaeV#XyIh!>}=}f;%4D&=wxDGWN7AM;AUWKZsO)>X=$fG zv=I`x%$WRwBeedcXj6ay>&3eH{SP`U3|B13xhvKFazT7EiPaB~;wmmlEGnre;!92AGBPx<^a} E0KmW~?*IS* 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 f26671eb8..5688c6f56 100644 --- a/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java @@ -4,6 +4,7 @@ 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; @@ -158,8 +159,13 @@ private DocumentNode buildEntryRow(Entry entry) { if (leader == DocumentLeader.NONE) { row.addSpacer(s -> s.width(1).height(1)); } else { + // Bottom-aligning a thin line drops it to the descender line; lift it by + // about the font's descent so the leader sits on the text baseline, level + // with the label and the page number. + double baselineLift = entryStyle.size() * 0.2; 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 { From 04d6102225f238493deaee98524e2972ad2b3bd0 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 23:48:08 +0100 Subject: [PATCH 3/3] test(dsl): pin the TOC leader baseline lift; name the descent ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name the magic 0.2 leader-lift factor (LEADER_BASELINE_LIFT_RATIO) and document that it is a deliberate approximation of the font descent, which is not reachable from the DSL layer, and that it assumes the entry and page-number styles share a size. Add a test asserting the leader carries a positive bottom margin — bottom alignment alone leaves it on the descender line, so the lift is the behaviour to pin. --- .../demcha/compose/document/dsl/TocBuilder.java | 13 +++++++++---- .../compose/document/dsl/TocBuilderTest.java | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) 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 5688c6f56..438d1288c 100644 --- a/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/TocBuilder.java @@ -31,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; @@ -159,10 +166,8 @@ private DocumentNode buildEntryRow(Entry entry) { if (leader == DocumentLeader.NONE) { row.addSpacer(s -> s.width(1).height(1)); } else { - // Bottom-aligning a thin line drops it to the descender line; lift it by - // about the font's descent so the leader sits on the text baseline, level - // with the label and the page number. - double baselineLift = entryStyle.size() * 0.2; + // 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)) .margin(DocumentInsets.bottom(baselineLift)); 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 688ee93ec..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,6 +1,7 @@ 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; @@ -30,6 +31,22 @@ void entryRowIsBottomAlignedSoTheLeaderSitsOnTheBaseline() { 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)