Skip to content

Commit 40d854a

Browse files
authored
Merge pull request #161 from DemchaAV/fix/docx-list-marker-spacing
fix(docx): list marker spacing + audit-driven edge-case coverage
2 parents f5a4108 + e318247 commit 40d854a

6 files changed

Lines changed: 278 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ Entries land here as they merge.
111111
style, with nested items indented per depth and keeping their own markers.
112112
(Found by the recipe fact-check: the docx-export recipe's "what is skipped"
113113
list could not honestly be written without it.)
114+
- **DOCX list items no longer double-space after the marker.** The new list
115+
branch concatenated `ListMarker.value()` — which already carries its
116+
trailing space — with another literal space, so every exported item read
117+
`"• text"`, and markerless lists gained a stray leading space. The export
118+
now uses `ListMarker.prefix()`, matching the fixed-layout text pipeline.
114119

115120
### Documentation
116121

@@ -144,6 +149,14 @@ Entries land here as they merge.
144149
text-metrics fake; `ChartLayoutSnapshotTest` layout snapshots + a
145150
fragment-lowering assertion; `SectionKeepTogetherTest` covers section,
146151
module, and timeline relocation plus the unchanged default.
152+
- Audit-driven edge-case coverage. DOCX semantic export: nested lists indent
153+
two spaces per depth, per-depth custom markers survive, lists inside
154+
sections export, empty lists are a no-op. Pagination: a keep-together
155+
section taller than a full page still flows instead of relocating. Charts:
156+
negative bar values extend the axis below zero and measure from the nice
157+
floor, stacked bars skip non-positive segments, a one-point smooth/area
158+
line keeps its marker and label, long category labels stay slot-sized,
159+
tight-width legends keep every entry, all-negative `NiceScale` ranges.
147160

148161
## v1.7.1 — 2026-06-09
149162

src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ private void writeList(XWPFDocument document,
166166
com.demcha.compose.document.node.ListNode list) {
167167
for (String item : list.items()) {
168168
writeListLine(document, list.textStyle(),
169-
list.marker().value() + " " + item, 0);
169+
list.marker().prefix() + item, 0);
170170
}
171171
for (com.demcha.compose.document.node.ListItem item : list.nestedItems()) {
172172
writeNestedItem(document, list, item, 0);
@@ -177,8 +177,11 @@ private void writeNestedItem(XWPFDocument document,
177177
com.demcha.compose.document.node.ListNode list,
178178
com.demcha.compose.document.node.ListItem item,
179179
int depth) {
180-
String marker = item.marker() != null ? item.marker().value() : list.marker().value();
181-
writeListLine(document, list.textStyle(), marker + " " + item.label(), depth);
180+
// prefix() carries its own trailing space (and is empty for
181+
// markerless lists), matching the fixed-layout text pipeline.
182+
com.demcha.compose.document.node.ListMarker marker =
183+
item.marker() != null ? item.marker() : list.marker();
184+
writeListLine(document, list.textStyle(), marker.prefix() + item.label(), depth);
182185
for (com.demcha.compose.document.node.ListItem child : item.children()) {
183186
writeNestedItem(document, list, child, depth + 1);
184187
}

src/test/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackendTest.java

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.demcha.compose.document.api.DocumentSession;
55
import com.demcha.compose.document.dsl.ParagraphBuilder;
66
import com.demcha.compose.document.dsl.ShapeContainerBuilder;
7+
import com.demcha.compose.document.node.ListMarker;
78
import com.demcha.compose.document.node.ParagraphNode;
89
import com.demcha.compose.document.output.DocumentMetadata;
910
import com.demcha.compose.document.style.DocumentColor;
@@ -89,6 +90,107 @@ void listsExportAsMarkerPrefixedParagraphs() throws Exception {
8990
}
9091
}
9192

93+
@Test
94+
void nestedListItemsIndentTwoSpacesPerDepth() throws Exception {
95+
byte[] docxBytes;
96+
try (DocumentSession session = GraphCompose.document()
97+
.pageSize(595, 842)
98+
.margin(DocumentInsets.of(36))
99+
.create()) {
100+
session.dsl().pageFlow().name("Flow")
101+
.addList(list -> list
102+
.name("Outline")
103+
.addItem("Level zero", l1 -> l1
104+
.addItem("Level one", l2 -> l2
105+
.addItem("Level two"))))
106+
.build();
107+
docxBytes = session.export(new DocxSemanticBackend());
108+
}
109+
110+
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
111+
List<String> texts = document.getParagraphs().stream()
112+
.map(XWPFParagraph::getText).toList();
113+
// Two spaces of indent per depth; without per-item markers the
114+
// semantic export falls back to the list's top-level bullet at
115+
// every level (the visual depth cascade is a layout-pass concern).
116+
assertThat(texts).contains(
117+
"• Level zero",
118+
" • Level one",
119+
" • Level two");
120+
}
121+
}
122+
123+
@Test
124+
void nestedListItemsKeepTheirCustomMarkers() throws Exception {
125+
byte[] docxBytes;
126+
try (DocumentSession session = GraphCompose.document()
127+
.pageSize(595, 842)
128+
.margin(DocumentInsets.of(36))
129+
.create()) {
130+
session.dsl().pageFlow().name("Flow")
131+
.addList(list -> list
132+
.marker("→")
133+
.markerFor(1, ListMarker.custom("‣"))
134+
.addItem("Root", child -> child.addItem("Child")))
135+
.build();
136+
docxBytes = session.export(new DocxSemanticBackend());
137+
}
138+
139+
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
140+
List<String> texts = document.getParagraphs().stream()
141+
.map(XWPFParagraph::getText).toList();
142+
// The top-level custom marker and the per-depth override both
143+
// survive the export.
144+
assertThat(texts).contains(
145+
"→ Root",
146+
" ‣ Child");
147+
}
148+
}
149+
150+
@Test
151+
void listInsideASectionIsExported() throws Exception {
152+
byte[] docxBytes;
153+
try (DocumentSession session = GraphCompose.document()
154+
.pageSize(595, 842)
155+
.margin(DocumentInsets.of(36))
156+
.create()) {
157+
session.dsl().pageFlow().name("Flow")
158+
.addSection("Wrapper", s -> s.addList("Inside section"))
159+
.build();
160+
docxBytes = session.export(new DocxSemanticBackend());
161+
}
162+
163+
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
164+
List<String> texts = document.getParagraphs().stream()
165+
.map(XWPFParagraph::getText).toList();
166+
// writeNode recurses through section/container wrappers, so the
167+
// nested list is not dropped.
168+
assertThat(texts).contains("• Inside section");
169+
}
170+
}
171+
172+
@Test
173+
void emptyListExportsNothingAndDoesNotFail() throws Exception {
174+
byte[] docxBytes;
175+
try (DocumentSession session = GraphCompose.document()
176+
.pageSize(595, 842)
177+
.margin(DocumentInsets.of(36))
178+
.create()) {
179+
session.dsl().pageFlow().name("Flow")
180+
.addParagraph(paragraph -> paragraph.text("Before"))
181+
.addList(list -> list.name("Empty"))
182+
.build();
183+
docxBytes = session.export(new DocxSemanticBackend());
184+
}
185+
186+
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
187+
List<String> texts = document.getParagraphs().stream()
188+
.map(XWPFParagraph::getText).toList();
189+
assertThat(texts).contains("Before");
190+
assertThat(texts).noneMatch(t -> t.contains("•"));
191+
}
192+
}
193+
92194
@Test
93195
void exportProducesDocxWithParagraphAndTableContent() throws Exception {
94196
byte[] docxBytes;

src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,122 @@ void stackedBarsLabelTheCategoryTotal() {
195195
assertThat(total.text()).isEqualTo("30");
196196
}
197197

198+
@Test
199+
void groupedBarsWithNegativeValuesMeasureFromTheNiceFloor() {
200+
ChartData data = ChartData.builder()
201+
.categories("A", "B")
202+
.series("S", 10.0, -2.0)
203+
.build();
204+
ChartSpec.Bar bar = ChartSpec.bar().data(data).build();
205+
206+
List<ChartPrimitive> out = ChartLayoutResolver.resolve(
207+
bar, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 100.0, METRICS);
208+
209+
// Domain [-2, 10] with the zero baseline rounds to the nice range
210+
// [-5, 10]: the axis extends below zero and both bars anchor at the
211+
// -5 floor, so the negative bar renders as a short positive-height
212+
// column reaching its value level (no crash, no inverted geometry).
213+
ChartPrimitive positive = byName(out, "bar_c0_s0");
214+
ChartPrimitive negative = byName(out, "bar_c1_s0");
215+
assertThat(negative.y()).isEqualTo(positive.y());
216+
// fractionOf(10) = 1.0 vs fractionOf(-2) = 0.2 over [-5, 10].
217+
assertThat(negative.height()).isCloseTo(positive.height() * 0.2, within(1e-9));
218+
// The negative bound appears as a tick label.
219+
assertThat(out.stream().anyMatch(p -> p.node() instanceof ParagraphNode pn
220+
&& pn.name().startsWith("tick_") && "-5".equals(pn.text()))).isTrue();
221+
}
222+
223+
@Test
224+
void stackedBarsSkipNonPositiveSegments() {
225+
ChartData data = ChartData.builder().categories("A")
226+
.series("Up", 5.0)
227+
.series("Down", -3.0)
228+
.series("Zero", 0.0)
229+
.build();
230+
ChartSpec.Bar bar = ChartSpec.bar().data(data)
231+
.grouping(BarGrouping.STACKED).valueLabels(ValueLabelMode.OUTSIDE).build();
232+
233+
List<ChartPrimitive> out = ChartLayoutResolver.resolve(
234+
bar, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS);
235+
236+
// Only the positive segment stacks; negative and zero segments are
237+
// skipped and the category total counts the positives alone.
238+
assertThat(out.stream().filter(p -> p.node().name().startsWith("bar_c0_")).count())
239+
.isEqualTo(1);
240+
byName(out, "bar_c0_s0");
241+
ParagraphNode total = (ParagraphNode) byName(out, "total_c0").node();
242+
assertThat(total.text()).isEqualTo("5");
243+
}
244+
245+
@Test
246+
void onePointLineEmitsItsMarkerButNoSegments() {
247+
ChartData data = ChartData.builder().categories("A").series("S", 7.0).build();
248+
ChartStyle style = baseStyle().mergedUnder(ChartStyle.builder()
249+
.pointMarker(PointMarker.circle(5.0))
250+
.build());
251+
// smooth + area exercise the interpolation and fill paths with a
252+
// single sample as well.
253+
ChartSpec.Line line = ChartSpec.line().data(data)
254+
.smooth(true).area(true)
255+
.valueLabels(ValueLabelMode.OUTSIDE).build();
256+
257+
List<ChartPrimitive> out = ChartLayoutResolver.resolve(
258+
line, style, ChartDefaults.DEFAULT_THEME, 200.0, 100.0, METRICS);
259+
260+
// No connecting stroke exists, but the lone point still gets its
261+
// marker and value label.
262+
assertThat(out.stream().noneMatch(p -> p.node().name().startsWith("line_s0_seg")))
263+
.isTrue();
264+
byName(out, "point_s0_c0");
265+
ParagraphNode label = (ParagraphNode) byName(out, "value_s0_c0").node();
266+
assertThat(label.text()).isEqualTo("7");
267+
}
268+
269+
@Test
270+
void veryLongCategoryLabelsKeepTheirSlotWidth() {
271+
ChartData data = ChartData.builder()
272+
.categories("Short", "An extremely long category label that far exceeds the slot")
273+
.series("S", 10.0, 20.0)
274+
.build();
275+
ChartSpec.Bar bar = ChartSpec.bar().data(data).build();
276+
277+
List<ChartPrimitive> out = ChartLayoutResolver.resolve(
278+
bar, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 100.0, METRICS);
279+
280+
// Category labels are slot-sized: a long text wraps/centres inside
281+
// its category slot instead of widening the geometry.
282+
ChartPrimitive shortLabel = byName(out, "cat_0");
283+
ChartPrimitive longLabel = byName(out, "cat_1");
284+
assertThat(longLabel.width()).isEqualTo(shortLabel.width());
285+
assertThat(longLabel.x()).isGreaterThan(shortLabel.x());
286+
}
287+
288+
@Test
289+
void bottomLegendInTightWidthStillEmitsEveryEntry() {
290+
ChartData data = ChartData.builder().categories("A")
291+
.series("First series with a long name", 1.0)
292+
.series("Second series with a long name", 2.0)
293+
.series("Third series with a long name", 3.0)
294+
.series("Fourth series with a long name", 4.0)
295+
.series("Fifth series with a long name", 5.0)
296+
.build();
297+
ChartSpec.Bar bar = ChartSpec.bar().data(data)
298+
.legend(LegendPosition.BOTTOM).build();
299+
300+
// 120pt is far too narrow for five long legend entries: layout must
301+
// stay deterministic and keep every entry (overflow, not loss).
302+
List<ChartPrimitive> out = ChartLayoutResolver.resolve(
303+
bar, baseStyle(), ChartDefaults.DEFAULT_THEME, 120.0, 100.0, METRICS);
304+
305+
double previousX = Double.NEGATIVE_INFINITY;
306+
for (int s = 0; s < 5; s++) {
307+
byName(out, "legend_swatch_" + s);
308+
ChartPrimitive label = byName(out, "legend_label_" + s);
309+
assertThat(label.x()).isGreaterThan(previousX);
310+
previousX = label.x();
311+
}
312+
}
313+
198314
@Test
199315
void areaFillsRenderUnderEveryStroke() {
200316
ChartData data = ChartData.builder().categories("A", "B", "C")

src/test/java/com/demcha/compose/document/chart/NiceScaleTest.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ void includeZeroPullsRangeDown() {
4646
assertThat(s.niceMax()).isGreaterThanOrEqualTo(95.0);
4747
}
4848

49+
@Test
50+
void allNegativeRangeKeepsNegativeBounds() {
51+
NiceScale s = NiceScale.compute(-30.0, -5.0, false, 5);
52+
assertThat(s.niceMin()).isEqualTo(-30.0);
53+
// ceil(-5 / 10) rounds the upper bound up to (negative) zero.
54+
assertThat(s.niceMax()).isCloseTo(0.0, within(1e-12));
55+
assertThat(s.tickStep()).isEqualTo(10.0);
56+
assertThat(s.tickCount()).isEqualTo(4);
57+
}
58+
4959
@Test
5060
void degenerateFlatRangeStillPlots() {
5161
NiceScale s = NiceScale.compute(7.0, 7.0, false, 5);

src/test/java/com/demcha/compose/document/dsl/SectionKeepTogetherTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,37 @@ void timelineKeepTogetherRelocatesWhole() {
105105
}
106106
}
107107

108+
@Test
109+
void keepTogetherSectionTallerThanAPageStillFlows() {
110+
try (DocumentSession document = GraphCompose.document()
111+
.pageSize(300, 400)
112+
.margin(DocumentInsets.of(20))
113+
.create()) {
114+
document.pageFlow().name("Flow").spacing(12)
115+
.addSection("Filler", s -> s.addShape(260, 250, DocumentColor.rgb(220, 220, 220)))
116+
.addSection("Oversized", s -> {
117+
s.keepTogether().spacing(8);
118+
// 5 × 100pt + spacing ≈ 532pt — taller than the 360pt
119+
// inner page, so relocation cannot help.
120+
for (int i = 0; i < 5; i++) {
121+
s.addShape(260, 100, DocumentColor.rgb(20, 80, 95));
122+
}
123+
})
124+
.build();
125+
126+
PlacedNode oversized = document.layoutGraph().nodes().stream()
127+
.filter(n -> "Oversized".equals(n.semanticName()))
128+
.findFirst().orElseThrow();
129+
// The keep-together request is ignored for a block taller than a
130+
// full page: the section starts in the remaining space on page 0
131+
// (no pointless relocation) and flows across the boundary.
132+
assertThat(oversized.startPage()).isEqualTo(0);
133+
assertThat(oversized.endPage()).isGreaterThan(oversized.startPage());
134+
} catch (Exception e) {
135+
throw new RuntimeException(e);
136+
}
137+
}
138+
108139
@Test
109140
void withoutKeepTogetherTheCardStraddlesThePageBoundary() {
110141
PlacedNode card = cardNode(false);

0 commit comments

Comments
 (0)