> fragments;
+ try (DocumentSession session = GraphCompose.document().pageSize(240, 600).margin(DocumentInsets.of(30)).create()) {
+ session.pageFlow(content::accept);
+ fragments = session.layoutGraph().fragments().stream()
+ .collect(java.util.stream.Collectors.groupingBy(PlacedFragment::path));
+ docx = session.export(new DocxSemanticBackend(report::set));
+ }
+ return new Export(new XWPFDocument(new ByteArrayInputStream(docx)), report.get(), fragments);
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
index eec0df990..d9f261822 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
@@ -209,8 +209,9 @@ private record Entry(Fate fate, String note) {
+ "a side of one; a level past Word's ninth that shares it with another; the right side's of a "
+ "pair whose left holds the level",
"padding:WRITTEN", "margin:WRITTEN",
- "autoSize:REPORTED:the size the page fits the text in the paragraph's style to, where Word holds "
- + "it apart; where the layout does not tell it, not measured",
+ "autoSize:REPORTED:where the layout does not tell the size the page fits the text in the "
+ + "paragraph's style to — no layout, or lines in sizes each a run's own — the text written at its "
+ + "style's, not measured; any other is written, at the size the page fits it to",
"verticalAlign:WRITTEN", "anchor:WRITTEN", "direction:WRITTEN");
node(PathNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "segments:WRITTEN",
"fillColor:WRITTEN", "fillPaint:REPORTED", "stroke:WRITTEN", "strokePaint:REPORTED",
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
index 19ef4a833..f062202a6 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
@@ -20,7 +20,6 @@
import com.demcha.compose.document.style.DocumentTextStyle;
import org.junit.jupiter.api.Test;
-import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
@@ -29,8 +28,8 @@
/**
* What a paragraph's own fields lose in the Word file is in the report: the size an auto-sized
- * paragraph's text is fitted to, the prefix its {@code bulletOffset} sets before its lines, and its
- * outline entry's title and level.
+ * paragraph's text is fitted to, where its lines do not tell it, the prefix its {@code bulletOffset}
+ * sets before its lines, and its outline entry's title and level.
*
* Each was drawn by the page and left out of the file without a note. A paragraph that keeps
* them — its text at the size the page fits it to, a blank prefix written as its indent, an outline
@@ -43,65 +42,45 @@ class DocxParagraphReportTest {
private static final double CONTENT = 180;
@Test
- void anAutoSizedParagraphsTextIsNamedWhereThePageFitsItToAnotherSize() throws Exception {
+ void anAutoSizedParagraphsTextIsNamedOnlyWhereItsLinesDoNotTellTheSizeThePageFitsItTo() throws Exception {
+ // Written at the size the page fits it to, smaller or larger than its style's, it loses nothing.
Consumer shrunk = page -> page.addParagraph(p -> p.name("Headline").text(HEADLINE)
.textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6));
- double fitted = firstSpan(shrunk).textStyle().size();
- assertThat(fitted).as("the page fits it smaller").isLessThan(24);
- assertThat(paragraphNote(shrunk))
- .isEqualTo("written as a paragraph; its text is written at 24pt, where the page fits it to "
- + points(fitted) + "pt");
- // Up to a size above its style's, a line that fits at it is set at it.
- assertThat(paragraphNote(page -> page.addParagraph(p -> p.text("Hi").textStyle(TEN).autoSize(24))))
- .isEqualTo("written as a paragraph; its text is written at 10pt, where the page fits it to 24pt");
- // Fitted to its own size, it loses nothing.
- assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Hi")
- .textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6)))).isEmpty();
+ assertThat(firstSpan(shrunk).textStyle().size()).as("the page fits it smaller").isLessThan(24);
+ assertThat(paragraphNotes(shrunk)).isEmpty();
+ assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Hi").textStyle(TEN).autoSize(24)))).isEmpty();
+ // So is a prefix the page sets in the paragraph's style, where every run keeps its own.
+ assertThat(paragraphNotes(page -> page.addParagraph(p -> p
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(24)).inlineText(HEADLINE, TEN)
+ .bulletOffset(" ").indentStrategy(DocumentTextIndent.ALL_LINES).autoSize(24, 6)))).isEmpty();
+ // Fitted to the size a run has of its own, the lines hold no other: it is that size.
+ assertThat(paragraphNotes(page -> page.addParagraph(p -> p.textStyle(TEN)
+ .inlineText("Hi ", DocumentTextStyle.DEFAULT.withSize(24)).inlineText("there")
+ .autoSize(24)))).isEmpty();
+ // Fitted to 12pt beside runs of 12pt and 10pt of their own, the lines do not tell which is its.
+ String unmeasured = "its text is written at 10pt — the size the page fits it to is not measured";
+ assertThat(paragraphNote(page -> page.addParagraph(p -> p.textStyle(TEN)
+ .inlineText("A ", DocumentTextStyle.DEFAULT.withSize(12))
+ .inlineText("B ", DocumentTextStyle.DEFAULT.withSize(10)).inlineText("C").autoSize(12))))
+ .isEqualTo("written as a paragraph; " + unmeasured);
+ // On a side of a pair as in the body.
+ assertThat(paragraphNote(page -> page.add(pair(new ParagraphBuilder().name("Title").textStyle(TEN)
+ .inlineText("A ", DocumentTextStyle.DEFAULT.withSize(12))
+ .inlineText("B ", DocumentTextStyle.DEFAULT.withSize(10)).inlineText("C").autoSize(12).build(),
+ "2022"))))
+ .isEqualTo("written as one side of a line it shares; " + unmeasured);
// Not auto-sized, it is not named.
assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text(HEADLINE)
.textStyle(DocumentTextStyle.DEFAULT.withSize(24))))).isEmpty();
- // Word holds a size to the half point: 10.3 and 10.5 are one size in the file.
- assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Hi")
- .textStyle(DocumentTextStyle.DEFAULT.withSize(10.3)).autoSize(10.5)))).isEmpty();
- }
-
- @Test
- void aPrefixThePageSetsInTheFittedSizeIsNamedWhereEveryRunKeepsItsOwn() throws Exception {
- // The page sets the prefix in the paragraph's style at the fitted size; the indent it is
- // written as is measured at the style's.
- Consumer prefixed = page -> page.addParagraph(p -> p
- .textStyle(DocumentTextStyle.DEFAULT.withSize(24)).inlineText(HEADLINE, TEN)
- .bulletOffset(" ").indentStrategy(DocumentTextIndent.ALL_LINES).autoSize(24, 6));
- ParagraphTextSpan prefix = firstSpan(prefixed);
- assertThat(prefix.text()).as("the prefix, laid out first").isBlank();
- double fitted = prefix.textStyle().size();
- assertThat(fitted).as("at the fitted size, not the run's").isLessThan(24).isNotEqualTo(10.0);
- assertThat(paragraphNote(prefixed))
- .isEqualTo("written as a paragraph; its text is written at 24pt, where the page fits it to "
- + points(fitted) + "pt");
}
@Test
void onlyTheTextThatTakesTheParagraphsStyleIsFitted() throws Exception {
- // A run with a style of its own is laid out at its own size, as it is written.
+ // A run with a style of its own is laid out at its own size, as it is written: with no
+ // text in the paragraph's style, nothing is fitted to lose.
assertThat(paragraphNotes(page -> page.addParagraph(p -> p.textStyle(TEN)
- .inlineText("Hi ", TEN).inlineText("there", DocumentTextStyle.DEFAULT.withSize(12))
- .autoSize(24)))).isEmpty();
- // Beside one, a run with none takes the size the page fits the paragraph to.
- assertThat(paragraphNote(page -> page.addParagraph(p -> p.textStyle(TEN)
- .inlineText("Hi ", DocumentTextStyle.DEFAULT.withSize(12)).inlineText("there")
- .autoSize(24))))
- .isEqualTo("written as a paragraph; its text is written at 10pt, where the page fits it to 24pt");
- // On a side of a pair as in the body.
- assertThat(paragraphNote(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER")
- .textStyle(TEN).autoSize(14).build(), "2022"))))
- .isEqualTo("written as one side of a line it shares; its text is written at 10pt, where the page "
- + "fits it to 14pt");
- // Fitted to the size a run has of its own, it is that size.
- assertThat(paragraphNote(page -> page.addParagraph(p -> p.textStyle(TEN)
- .inlineText("Hi ", DocumentTextStyle.DEFAULT.withSize(24)).inlineText("there")
- .autoSize(24))))
- .isEqualTo("written as a paragraph; its text is written at 10pt, where the page fits it to 24pt");
+ .inlineText("A ", DocumentTextStyle.DEFAULT.withSize(12))
+ .inlineText("B ", DocumentTextStyle.DEFAULT.withSize(10)).autoSize(12)))).isEmpty();
}
@Test
@@ -294,10 +273,6 @@ private static DocumentNode sidebar(DocumentNode text) {
.build();
}
- private static String points(double size) {
- return BigDecimal.valueOf(Math.round(size * 100) / 100.0).stripTrailingZeros().toPlainString();
- }
-
/** The first text the page lays out. */
private static ParagraphTextSpan firstSpan(Consumer content) throws Exception {
try (DocumentSession session = session(content)) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSessionMarkdownTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSessionMarkdownTest.java
index 4fc1fe341..80180bf46 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSessionMarkdownTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSessionMarkdownTest.java
@@ -165,19 +165,45 @@ void textTheParserReadsAsThePageDoesIsWrittenSoWhateverItHolds() throws Exceptio
}
@Test
- void anAutoSizedParagraphIsWrittenInThePiecesAtItsStylesSize() throws Exception {
- Export export = export(true, page -> page.addParagraph(p -> p.text("Fit **this** text")
+ void anAutoSizedParagraphIsWrittenInThePiecesAtTheSizeThePageFitsItTo() throws Exception {
+ Export export = export(true, page -> page.addParagraph(p -> p.name("Fit").text("Fit **this** text")
.textStyle(DocumentTextStyle.builder().size(10).build()).autoSize(20, 6)));
XWPFParagraph paragraph = export.paragraphWith("this");
- assertThat(paragraph.getRuns()).extracting(XWPFRun::text, XWPFRun::isBold).containsExactly(
- org.assertj.core.groups.Tuple.tuple("Fit ", false), org.assertj.core.groups.Tuple.tuple("this", true),
- org.assertj.core.groups.Tuple.tuple(" text", false));
- assertThat(export.notes("ParagraphNode")).noneMatch(note -> note.contains("markdown"));
- // A heading at a multiple of the style's size fits the taller line the page fits the text to.
+ assertThat(paragraph.getRuns()).extracting(XWPFRun::text, XWPFRun::isBold, XWPFRun::getFontSizeAsDouble)
+ .containsExactly(org.assertj.core.groups.Tuple.tuple("Fit ", false, 20.0),
+ org.assertj.core.groups.Tuple.tuple("this", true, 20.0),
+ org.assertj.core.groups.Tuple.tuple(" text", false, 20.0));
+ assertThat(export.notes("ParagraphNode")).isEmpty();
+ // A heading at its multiple of the size the page fits the text to, 30pt, as the page sets
+ // it: drawn past the paragraph's line, which Word cuts it in, and named.
Export heading = export(true, page -> page.addParagraph(p -> p.text("# Big_x")
.textStyle(DocumentTextStyle.builder().size(10).build()).autoSize(30, 6)));
- assertThat(heading.paragraphWith("Big").getText()).isEqualTo("Big_x");
- assertThat(heading.notes("ParagraphNode")).noneMatch(note -> note.contains("markdown"));
+ XWPFParagraph big = heading.paragraphWith("Big");
+ assertThat(big.getText()).isEqualTo("Big_x");
+ assertThat(big.getRuns()).singleElement().satisfies(run -> assertThat(run.getFontSizeAsDouble()).isEqualTo(60.0));
+ assertThat(heading.notes("ParagraphNode")).singleElement().asString()
+ .startsWith("written as a paragraph; its markdown heading is written at 60pt in a line ")
+ .endsWith(HEADING_CUT);
+ // After a prefix the page sets before its first line, the share is read past the prefix.
+ Export prefixed = export(true, page -> page.addParagraph(p -> p.text("# Big_x").bulletOffset("• ")
+ .indentStrategy(com.demcha.compose.document.style.DocumentTextIndent.FIRST_LINE)
+ .textStyle(DocumentTextStyle.builder().size(10).build()).autoSize(30, 6)));
+ assertThat(prefixed.paragraphWith("Big").getRuns()).filteredOn(run -> run.text().contains("Big"))
+ .singleElement().satisfies(run -> assertThat(run.getFontSizeAsDouble()).isEqualTo(60.0));
+ // Fitted far below its style's 30pt, a heading at twice the fitted size is still smaller
+ // than the style's; it stands taller than the line the page fits the text to, and is named.
+ Export small = export(true, page -> page.addParagraph(p -> p.text("# A heading far too long_for one line")
+ .textStyle(DocumentTextStyle.builder().size(30).build()).autoSize(30, 6)));
+ assertThat(small.paragraphWith("heading").getRuns()).filteredOn(run -> run.text().contains("heading"))
+ .singleElement().satisfies(run -> assertThat(run.getFontSizeAsDouble()).isLessThan(30.0));
+ assertThat(small.notes("ParagraphNode")).singleElement().asString().endsWith(HEADING_CUT);
+ // Over two lines it is fitted to its least size, its heading at twice that and its body at it.
+ Export two = export(true, page -> page.addParagraph(p -> p.text("# Head_x\nbody *y*")
+ .textStyle(DocumentTextStyle.builder().size(10).build()).autoSize(20, 6)));
+ assertThat(two.paragraphWith("Head").getRuns()).filteredOn(run -> run.text().contains("_") || run.text().equals("y"))
+ .extracting(XWPFRun::text, XWPFRun::getFontSizeAsDouble)
+ .containsExactly(org.assertj.core.groups.Tuple.tuple("Head_x", 12.0),
+ org.assertj.core.groups.Tuple.tuple("y", 6.0));
}
@Test
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
index 713379cbe..c4363a3c1 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
@@ -15,6 +15,7 @@
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFHeaderFooter;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.junit.jupiter.api.Test;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFramePr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar;
@@ -218,20 +219,44 @@ void aLineReachingPastTheMarginHoldsTheBodyAtItAndIsNamed() throws Exception {
}
@Test
- void aPartFittedSmallerIsGivenItsStylesLine() throws Exception {
- // Word writes it at its style's 18pt, where the page fits it smaller: in a line the page's
- // height, its letters' tops would be cut.
+ void aPartFittedSmallerIsWrittenAtTheSizeThePageFitsItToInThePagesLine() throws Exception {
Exported exported = export(DocumentPageZone.builder().zone(DocumentHeaderFooterZone.HEADER).height(40)
.padding(new DocumentInsets(4, 0, 0, 0))
.content(page -> new ParagraphBuilder()
.text("A running header far too long to set at its eighteen points across this page")
.textStyle(DocumentTextStyle.DEFAULT.withSize(18)).autoSize(18, 6).build())
.build());
+ double written = exported.headerLine().getRuns().get(0).getFontSizeAsDouble();
+ assertThat(written).as("fitted smaller than its style's 18pt").isLessThan(18);
+ Exported unfitted = export(zone(DocumentHeaderFooterZone.HEADER, 40, new DocumentInsets(4, 0, 0, 0), "Acme",
+ DocumentTextStyle.DEFAULT.withSize(written)));
+
+ assertThat(lineOf(exported.headerLine())).as("the page's line, as a part's of that size is")
+ .isCloseTo(lineOf(unfitted.headerLine()), within(0.05));
+ assertThat(exported.report().bySubject()).doesNotContainKey("page zone");
+ }
+
+ @Test
+ void aPartWhoseLinesDoNotTellTheSizeThePageFitsItToIsGivenItsStylesLine() throws Exception {
+ // Fitted to 12pt, the size its first run has of its own: the lines hold 12 and 10, each a
+ // run's own, and do not tell which is the paragraph's. Written at its style's 18pt, in a
+ // line the page's height its letters' tops would be cut.
+ Exported exported = export(DocumentPageZone.builder().zone(DocumentHeaderFooterZone.HEADER).height(40)
+ .padding(new DocumentInsets(4, 0, 0, 0))
+ .content(page -> new ParagraphBuilder().textStyle(DocumentTextStyle.DEFAULT.withSize(18))
+ .inlineText("A ", DocumentTextStyle.DEFAULT.withSize(12))
+ .inlineText("B ", DocumentTextStyle.DEFAULT.withSize(10))
+ .inlineText("C").autoSize(12, 6).build())
+ .build());
Exported unfitted = export(zone(DocumentHeaderFooterZone.HEADER, 40, new DocumentInsets(4, 0, 0, 0), "Acme",
DocumentTextStyle.DEFAULT.withSize(18)));
+ assertThat(exported.headerLine().getRuns()).extracting(XWPFRun::getFontSizeAsDouble).containsExactly(12.0, 10.0, 18.0);
assertThat(lineOf(exported.headerLine())).as("the 18pt style's line")
.isCloseTo(lineOf(unfitted.headerLine()), within(0.05));
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .singleElement().asString()
+ .endsWith("a paragraph's text is written at 18pt — the size the page fits it to is not measured");
}
@Test
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java
index 1b567b732..9db93ba31 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java
@@ -191,8 +191,14 @@ void aZoneParagraphsOwnLossesAreNamed() throws Exception {
.as("a prefix Word does not write stands the text off, and its letters are lost")
.containsExactly(FOOTER + OFF + "; a paragraph's bulletOffset letters, \"•\", are not written before "
+ "its first line");
- assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Hi").autoSize(14).build())))
- .containsExactly(FOOTER + "a paragraph's text is written at 8pt, where the page fits it to 14pt");
+ // Auto-sized, its text is written at the size the page fits it to, where its lines tell it.
+ assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Hi").autoSize(14).build()))).isEmpty();
+ // Fitted to 12pt, the size its first run has of its own, its lines do not tell which size is its own.
+ assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new ParagraphBuilder().name("ZoneLine")
+ .textStyle(CHROME).inlineText("A ", CHROME.withSize(12)).inlineText("B ", CHROME.withSize(10))
+ .inlineText("C").autoSize(12).build())))
+ .containsExactly(FOOTER + "a paragraph's text is written at 8pt — the size the page fits it to is not "
+ + "measured");
assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Confidential")
.bookmark(new DocumentBookmarkOptions("Confidential", 0)).build())))
.containsExactly(FOOTER + "a paragraph's outline entry is not written");
@@ -211,15 +217,23 @@ void wherePartsStandPastOneWordSetsAtAnotherWidthIsNotMeasured() throws Exceptio
.containsExactly(FOOTER + "1 of its 3 parts stands off where the page sets them; where 1 of its 3 "
+ "parts stands is not measured; a paragraph's bulletOffset letters, \"•\", are not "
+ "written before its first line");
- // Nor after a part auto-sized to a size the file does not hold; set lower than Word's
- // baseline, the part after it stands off all the same.
+ // A part auto-sized is written at the size the page fits it to, as wide as the page sets
+ // it; set lower than Word's baseline, the parts after it stand off.
assertThat(zoneNotes(DocumentPageZone.footer(40, page -> new RowBuilder().name("Line")
.addParagraph(p -> p.text("Hi").textStyle(CHROME).autoSize(14))
.addParagraph(p -> p.text("Acme").textStyle(CHROME))
.addParagraph(p -> p.text("Co").textStyle(CHROME))
.build())))
- .containsExactly(FOOTER + "2 of its 3 parts stand off where the page sets them; a paragraph's text is "
- + "written at 8pt, where the page fits it to 14pt");
+ .containsExactly(FOOTER + "2 of its 3 parts stand off where the page sets them");
+ // Past a part auto-sized to a size its lines do not tell, where a part on its baseline
+ // stands is not measured.
+ assertThat(zoneNotes(DocumentPageZone.footer(40, page -> new RowBuilder().name("Line")
+ .addParagraph(p -> p.textStyle(CHROME).inlineText("A ", CHROME.withSize(12))
+ .inlineText("B ", CHROME.withSize(10)).inlineText("C").autoSize(12))
+ .addParagraph(p -> p.text("Acme").textStyle(CHROME.withSize(12)))
+ .build())))
+ .containsExactly(FOOTER + "where 1 of its 2 parts stands is not measured; a paragraph's text is "
+ + "written at 8pt — the size the page fits it to is not measured");
// Against the right margin, the part a prefix stands before ends where Word ends it; the
// part before it, Word sets the prefix's width off.
assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line")
From abd5c364fc109ce900348e14ad985ebd29cd455a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 7 Oct 2026 21:42:58 +0100
Subject: [PATCH 2/3] fix(docx): take no fitted size from lines of other text,
and name an auto-sized paragraph by what was written
A page zone the page draws with other content on its first page than it is
written with takes no lines from that page (ZonePlacement.asLaid,
zoneTextAsWritten): a footer reading "End", fitted from page 1's longer
line, was written at that line's size and not named.
Where the pieces of a paragraph read as markdown are not found in its lines
and the page dropped a mark, fittedSize says the size is not told: an
Arabic heading-only line, shaped before the marks are read, was written at
its heading's size, unnamed.
The note reads what was written (fittedWritten); fittedSizeUntold answers
before writing, for a zone line's height and its parts' widths. A heading's
note names its size at Word's half point. laidOutIn is strict only;
cell matching reads the share through scaleIn.
---
CHANGELOG.md | 39 +++--
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 5 +-
render-docx/README.md | 3 +-
.../semantic/docx/DocxLayoutMetrics.java | 12 +-
.../backend/semantic/docx/DocxMarkdown.java | 19 ++-
.../semantic/docx/DocxSemanticBackend.java | 127 ++++++++++++----
.../semantic/docx/DocxAutoSizeTest.java | 141 ++++++++++++++----
.../semantic/docx/DocxMarkdownTest.java | 45 +++---
.../docx/DocxNodeFieldLedgerTest.java | 5 +-
.../docx/DocxParagraphReportTest.java | 2 +-
.../semantic/docx/DocxZoneLineTest.java | 33 +++-
.../semantic/docx/DocxZoneReportTest.java | 2 +-
13 files changed, 311 insertions(+), 124 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff3cf016e..88b760d98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,22 +8,26 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
-- **A DOCX export writes an auto-sized paragraph's text at the size the page fits it to.** The page fits a paragraph with `autoSize(...)` to the largest size its line
- holds, smaller or larger than its style's. The export wrote the text at its style's size: Word
- broke a shrunk headline onto more lines than the page's and set everything under it lower, and
- showed a grown one at its style's size. The report named both sizes.
+- **A DOCX export writes an auto-sized paragraph's text at the size the page fits it to.** The
+ page fits a paragraph with `autoSize(...)` to the largest size its line holds, smaller or
+ larger than its style's. The export wrote the text at its style's size: Word broke a shrunk
+ headline onto more lines than the page's and set everything under it lower, and showed a grown
+ one at its style's size. The report named both sizes.
- **The text that takes the paragraph's style is written at the fitted size**, read off the
layout's lines. A run with a style of its own keeps it, as on the page. The paragraph's mark,
- which Word continues from, takes the fitted size too, and a prefix the page sets before the
- lines is measured at it.
+ which Word continues from, takes the fitted size where the text ending it takes the
+ paragraph's style, and a prefix the page sets before the lines is measured at it.
- **Markdown pieces are read at it.** A heading is written at its multiple of the fitted size, as
the page sets it, and named where it stands taller than its line, as any heading is.
- **Every path that writes a paragraph does it:** the body, a table cell, text over the flow, a
side of an overlay's left-and-right pair, a badge's initials and a page zone's line. A zone's
line is the page's, where it was its style's.
- - **Still written at its style's size, and named**, where the layout does not tell the fitted
- size: with no layout, or in lines whose sizes are each a run's own, as a paragraph fitted to
- 12pt beside runs of 12pt and 10pt of their own. The note says the fitted size is not measured.
+ - **Still written at its style's size, and named**, where the layout's lines do not tell the
+ fitted size. None are read: with no layout, or for a page zone the page sets with other text
+ on the first page it draws it than the zone is written with. Or their sizes do not say which
+ is the paragraph's: a paragraph fitted to 12pt beside runs of 12pt and 10pt of their own, or
+ Arabic the page reads as markdown, which it shapes before it reads the marks. The note says
+ the fitted size is not measured.
Measured in Word 16 and LibreOffice on a page of auto-sized paragraphs, each word now stands
within half a point of the page's baseline, at the page's size. A shrunk headline is one line,
@@ -81,7 +85,8 @@ follow semantic versioning; release dates are ISO 8601.
bold at its larger size, a line break where the page starts a line. A linked paragraph's
pieces stay in one link, and Word's outline lists a heading by the text written. An
auto-sized paragraph's pieces were written at its style's size, a heading's at its multiple of
- it, as its text was (since written at the size the page fits it to: see "A DOCX export writes an auto-sized paragraph's text at the size the page fits it to").
+ it, as its text was (since written at the size the page fits it to: see "A DOCX export
+ writes an auto-sized paragraph's text at the size the page fits it to").
- **The page's own lines decide it.** The pieces are written only where the lines the page laid
the paragraph out in hold the pieces' letters in their faces, families, colours and tracking,
at their sizes to a hundredth of a point — or, where the page fits the text to a size of its
@@ -102,7 +107,8 @@ follow semantic versioning; release dates are ISO 8601.
line as tall as the paragraph's own and draws its letters past it; written in that exact line,
Word cuts their tops on screen. An auto-sized paragraph's heading, written at a multiple of its
style's size, may fit the line the page fits the text to, and is named only where it does not
- (since written at its multiple of the fitted size, as the page sets it: see "A DOCX export writes an auto-sized paragraph's text at the size the page fits it to").
+ (since written at its multiple of the fitted size, as the page sets it: see "A DOCX export
+ writes an auto-sized paragraph's text at the size the page fits it to").
- **The font table ships the faces the pieces of a paragraph outside table cells and page zones
are set in** — the paragraphs it reads. It is written before any paragraph, so it reads them
off the text: a session that reads no markdown ships a face it does not use.
@@ -138,7 +144,9 @@ follow semantic versioning; release dates are ISO 8601.
- **The line is taller where it needs to be:**
- for a picture in a zone paragraph, which Word stands on the baseline, so the exact line
does not cut its top;
- - for a part the page fits smaller, which Word writes at its style's size.
+ - for a part the page fits smaller, which Word writes at its style's size (since written at
+ the fitted size where its lines tell it: see "A DOCX export writes an auto-sized
+ paragraph's text at the size the page fits it to").
- **A tallest part the page sets in more lines than one** is written as as many exact lines.
Word grows a footer up from its distance, so a footer of two lines stands a line further from
the edge, its first line on the page's first baseline.
@@ -310,7 +318,9 @@ follow semantic versioning; release dates are ISO 8601.
otherwise than the file, and with no layout, the note says where the parts stand is not
measured;
- names a zone paragraph's right-to-left text written left to right, its prefix's letters,
- the size its text is fitted to, and its outline entry.
+ the size its text is fitted to (since written at it where its lines tell it: see "A DOCX
+ export writes an auto-sized paragraph's text at the size the page fits it to"), and its
+ outline entry.
None of this changes what is written, and no document of the DOCX fidelity corpus has a page
zone. In `DocxNodeFieldLedgerTest` a page field's `padding` and `margin` move from a gap to
@@ -336,7 +346,8 @@ follow semantic versioning; release dates are ISO 8601.
size to the half point, sets them apart. The fitted size is read from the layout's lines; a
prefix the page sets in the paragraph's style counts as its text. Where the lines are not
read or do not tell the size, the note says the fitted size is not measured (since written at
- the fitted size, and named only where it is not measured: see "A DOCX export writes an auto-sized paragraph's text at the size the page fits it to");
+ the fitted size, and named only where it is not measured: see "A DOCX export writes an
+ auto-sized paragraph's text at the size the page fits it to");
- its prefix's letters before its first line. On a path that writes no prefix, it also names
the room the prefix sets lines in by, where that moves a line: a side of a pair Word holds
by its start, and a text box or a badge, but not one line set from the end away from its
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index f13b0bb90..39674a4ae 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -63,7 +63,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no layout, or sizes each a run's own); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level |
+| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, or lines in sizes that do not say which is the paragraph's); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items or a drawn marker, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell, its wrapping not measured), a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps or its wrapping is not measured, the rows the page draws as a marker alone for blank items of a flagged list, which are not written, and what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding above and below the letters are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what Word paints underneath it — the paragraph's shading, the cell's, or else the colour the page paints under the paragraph, a page background included — so the chip agrees with the file it is in and shows the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 40d983578..2607bcc85 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -67,7 +67,7 @@ creation date is real metadata.
| Document node | DOCX output |
|---|---|
-| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — with no layout, or in sizes each a run's own — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container |
+| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, or they are in sizes that do not say which is the paragraph's — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container |
| Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. An item the page reads as markdown is written as the page sets it, as a paragraph is. See "What a list becomes" below for the kinds that stay plain paragraphs |
| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given |
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path |
@@ -1077,7 +1077,8 @@ rather than Word's 36pt — as puts that part's baseline where the page has it,
standing an exact line's baseline four fifths of the way down it. So what a lone part's padding
and margin hold above and below its text is in that distance. The line is taller where a
picture in it needs the room above the baseline, as Word stands a zone's picture on it, and
-where a part the page fits smaller is written at its style's size. A tallest part the page sets
+where a part is written at its style's size because its lines do not tell the size the page
+fits it to. A tallest part the page sets
in more lines than one is as many exact lines; Word grows a footer up from its distance, so a
footer of two lines stands a line further from the edge, its first line on the page's first
baseline. Measured in Word 16.0.20430 and LibreOffice 26.8 on 8pt and 18pt Lato headers and 8pt
diff --git a/render-docx/README.md b/render-docx/README.md
index 98a3b1091..1a32058fe 100644
--- a/render-docx/README.md
+++ b/render-docx/README.md
@@ -139,7 +139,8 @@ What is not written — each one is named in the export report
- **What a paragraph's own fields set where Word cannot hold it**, named in the export report on
the paragraph outside a header or footer (a page zone's are named on the zone):
- the size an auto-sized paragraph's text is fitted to, where the layout does not tell it (no
- layout, or lines in sizes each a run's own); elsewhere the text is written at it;
+ lines read, or lines in sizes that do not say which is the paragraph's); elsewhere the text
+ is written at it;
- the marks of a paragraph the session reads as markdown (the default; `markdown(false)` turns
it off), where they are written as letters: the paragraph is written as the page sets it —
its marks dropped, each piece in the page's face and size — wherever the page's lines show
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index f86b6066d..44acbba6d 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -1163,12 +1163,16 @@ private Map matchComposedText() {
/**
* Whether a fragment's lines set a paragraph's markdown pieces as they are read
- * ({@link DocxMarkdown#laidOutIn}). A fragment whose lines lead with a prefix's letters carries
- * other text than the pieces, and is never offered.
+ * ({@link DocxMarkdown#laidOutIn}) — an auto-sized paragraph's at sizes in proportion
+ * ({@link DocxMarkdown#scaleIn}), the size the page fits it to not known before its lines are
+ * found. A fragment whose lines lead with a prefix's letters carries other text than the
+ * pieces, and is never offered.
*/
private static boolean setsThePieces(ParagraphNode paragraph, List pieces, PlacedFragment fragment) {
- return DocxMarkdown.laidOutIn(pieces, ((ParagraphFragmentPayload) fragment.payload()).lines(), "",
- paragraph.autoSize() != null);
+ List lines = ((ParagraphFragmentPayload) fragment.payload()).lines();
+ return paragraph.autoSize() != null
+ ? !Double.isNaN(DocxMarkdown.scaleIn(pieces, lines, ""))
+ : DocxMarkdown.laidOutIn(pieces, lines, "");
}
/** The fragments of a text still waiting for a paragraph, or {@code null} where none is. */
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxMarkdown.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxMarkdown.java
index 411b376a6..ac9ca88e6 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxMarkdown.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxMarkdown.java
@@ -295,9 +295,8 @@ static String text(List pieces) {
/**
* Whether the page laid the pieces out in its lines: the lines' letters, less a prefix's
* leading them, are the pieces' letters, each in the same face, family, colour and tracking,
- * and at the same size, to {@link #SIZE_CLEARANCE} — or, where the page fits the text to a
- * size of its own, at sizes in the same proportion, its tracking, resolved at that size, not
- * compared. Pieces of no letter
+ * and at the same size, to {@link #SIZE_CLEARANCE}. An auto-sized paragraph's pieces are read
+ * at the size the page fits its text to ({@link #scaleIn}). Pieces of no letter
* — text the parser reads into nothing, which the page sets as nothing — are not taken for the
* page's: written, the paragraph would be blank, and a blank paragraph is what the export
* writes elsewhere for no line at all. White space is not compared: the page drops it where it
@@ -306,18 +305,18 @@ static String text(List pieces) {
* @param pieces the pieces read off the text
* @param lines the lines the page laid the text out in; none never holds the pieces
* @param prefix the prefix the page sets before the first line, empty where it sets none
- * @param fitted whether the page fits the text to a size of its own, an auto-sized paragraph's
*/
- static boolean laidOutIn(List pieces, List lines, String prefix, boolean fitted) {
- return !Double.isNaN(laidOutAt(pieces, lines, prefix, fitted));
+ static boolean laidOutIn(List pieces, List lines, String prefix) {
+ return !Double.isNaN(laidOutAt(pieces, lines, prefix, false));
}
/**
* How many times the size it was read at the page sets text read into pieces, where it fits
* the text to a size of its own: the share its first letter is set at, where the page laid the
- * pieces out at sizes in that proportion ({@link #laidOutIn}); {@code NaN} where it did not.
- * Pieces read at the paragraph's style's size and taken by this share are the size the page
- * fits the paragraph's text to, a heading's a multiple of it.
+ * pieces out as {@link #laidOutIn} asks but at sizes in that proportion, their tracking,
+ * resolved at another size, not compared; {@code NaN} where it did not. Pieces read at the
+ * paragraph's style's size and taken by this share are the size the page fits the paragraph's
+ * text to, a heading's a multiple of it.
*
* @param pieces the pieces read off the text
* @param lines the lines the page laid the text out in
@@ -327,7 +326,7 @@ static double scaleIn(List pieces, List lines, String pref
return laidOutAt(pieces, lines, prefix, true);
}
- /** The share {@link #laidOutIn} finds the pieces set at, 1 unless fitted; {@code NaN} where it finds them not. */
+ /** The share the pieces are found set at, 1 unless fitted; {@code NaN} where they are not found. */
private static double laidOutAt(List pieces, List lines, String prefix, boolean fitted) {
if (lines.isEmpty()) {
return Double.NaN;
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 95c1ae64b..36cb74c13 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -307,6 +307,11 @@ public final class DocxSemanticBackend implements SemanticBackend {
// markdownPieces.
private final java.util.Map> markdownWritten =
new java.util.IdentityHashMap<>();
+ // Auto-sized paragraphs whose text in their style was written at the size the page fits it to
+ // (writtenStyle): their notes do not name the size. A path that writes one otherwise leaves it
+ // out, and it is named.
+ private final java.util.Set fittedWritten =
+ java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
// The space above the next block, in points, in place of everything owed above it: set
// when a column layer follows another in its cell, NaN otherwise.
private double resumeSpacing = Double.NaN;
@@ -771,6 +776,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
tablesCells.clear();
picturesDrawnBeside.clear();
markdownWritten.clear();
+ fittedWritten.clear();
listNumbering.clear();
measuredColumns.clear();
prefixColumns.clear();
@@ -1869,7 +1875,7 @@ private void writeZoneLine(XWPFHeaderFooter target, int zoneIndex, DocumentNode
reportUnwrittenRowPaint(row, true);
}
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
- java.util.Map laid = layout.zoneText(zoneIndex);
+ java.util.Map laid = zoneTextAsWritten(zoneIndex, placement);
for (DocumentNode part : parts) {
appendZonePart(para, part, zoneLinesOf(part, paths, laid));
}
@@ -1910,17 +1916,25 @@ private static List z
*
* Where the page set the zone on the first page it draws it otherwise than the zone is
* written — other text, another face or size, other pictures ({@link DocxZoneParts#readAlike})
- * — the line is not measured, and Word's own.
+ * — the line is not measured, and Word's own, and the lines the page set there tell nothing
+ * of the parts written: not the pieces of their markdown, nor the size an auto-sized one is
+ * fitted to.
*
* @param line the exact line's height in points, NaN where no part's text is laid out
* @param lines how many lines the part of the most takes on the page
* @param distance from the page's top edge to the paragraph's top in a header, from its foot
* to the paragraph's foot in a footer, in points
* @param baseline the baseline Word sets the first line on, measured up from the page's foot
+ * @param asLaid whether the page set the zone on the first page it draws it as it is
+ * written, so its lines there are the parts' own
*/
- private record ZonePlacement(double line, int lines, double distance, double baseline) {
+ private record ZonePlacement(double line, int lines, double distance, double baseline, boolean asLaid) {
+
+ private static final ZonePlacement UNMEASURED =
+ new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, true);
- private static final ZonePlacement UNMEASURED = new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN);
+ private static final ZonePlacement LAID_OTHERWISE =
+ new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, false);
/** Whether the layout laid out the text the line is placed by. */
boolean measured() {
@@ -1942,7 +1956,7 @@ private ZonePlacement zonePlacement(int zoneIndex, boolean header, DocumentPageZ
// The page set the zone otherwise there than it is written: its line is not measured by it.
if (!DocxZoneParts.readAlike(content, zone.getContent().apply(
PageContext.paginated(firstPage + 1, Math.max(firstPage + 1, layout.pageCount()))))) {
- return ZonePlacement.UNMEASURED;
+ return ZonePlacement.LAID_OTHERWISE;
}
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
java.util.Map laid = layout.zoneText(zoneIndex);
@@ -1967,7 +1981,7 @@ private ZonePlacement zonePlacement(int zoneIndex, boolean header, DocumentPageZ
picture = Math.max(picture, DocxZoneParts.tallestPicture(paragraph));
// Written at its style's size where its lines do not tell the size the page fits
// its text to, it needs the style's line, or its letters' tops are cut.
- if (autoSizeLost(paragraph, lines, "its") != null) {
+ if (fittedSizeUntold(paragraph, lines)) {
styled = Math.max(styled, styleLineHeight(paragraph.textStyle()));
}
}
@@ -1987,7 +2001,17 @@ private ZonePlacement zonePlacement(int zoneIndex, boolean header, DocumentPageZ
double distance = DocxTextBands.distanceFromEdge(header,
header ? canvasHeight - tallest.baseline() : tallest.baseline() - (mostLines - 1) * line, line);
double baseline = header ? canvasHeight - distance - above : distance + (mostLines - 1) * line + (line - above);
- return new ZonePlacement(line, mostLines, distance, baseline);
+ return new ZonePlacement(line, mostLines, distance, baseline, true);
+ }
+
+ /**
+ * The fragments the page laid a zone's parts out in, by path within its content: on the first
+ * page it draws the zone on, and none where it set the zone there otherwise than it is written
+ * ({@link ZonePlacement#asLaid}), whose lines are other text's.
+ */
+ private java.util.Map zoneTextAsWritten(
+ int zoneIndex, ZonePlacement placement) {
+ return placement.asLaid() ? layout.zoneText(zoneIndex) : java.util.Map.of();
}
/**
@@ -2062,7 +2086,7 @@ private double zoneRightTab() {
private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content, ZonePlacement placement) {
List parts = DocxZoneParts.of(content);
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
- java.util.Map laid = layout.zoneText(zoneIndex);
+ java.util.Map laid = zoneTextAsWritten(zoneIndex, placement);
// Read where the line is placed by what the page set: not where the page set other text.
boolean measured = placement.measured() && !laid.isEmpty() && !Double.isNaN(canvasLeftMargin);
// Each text part's side of the line: 0 from the left margin, 1 against the right, 2
@@ -2169,7 +2193,8 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
/**
* What a paragraph of a page zone loses of its own on the zone's line: its direction, its
- * prefix's letters, the size its text is fitted to, its markdown marks where they are written
+ * prefix's letters, the size its text is fitted to where its lines do not tell it, its
+ * markdown marks where they are written
* as letters, a markdown heading Word cuts on screen, its outline entry,
* its anchor's bookmark, and where it sets a picture off the baseline. A zone is written into
* a part each kind of page repeats, which is no one place in the document a bookmark could
@@ -2231,7 +2256,7 @@ private static boolean widthKept(DocumentNode part, com.demcha.compose.document.
List lines =
((com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload) fragment.payload()).lines();
return lines.size() == 1 && !(part instanceof ParagraphNode paragraph
- && (setsAPrefixBeforeTheFirstLine(paragraph) || autoSizeLost(paragraph, lines, "its") != null));
+ && (setsAPrefixBeforeTheFirstLine(paragraph) || fittedSizeUntold(paragraph, lines)));
}
/**
@@ -5874,9 +5899,9 @@ private static DocumentTextStyle dominantTextStyle(DocumentGraph graph) {
* editing protocol found the body following Normal in none of {@code ProposalEditorial}'s or
* {@code CobaltRota}'s paragraphs, Normal 14pt or 12pt over body text of 10pt and 10.5pt.
*
- * An auto-sized paragraph's text is weighed at its style's size: the election is made for
- * the whole document before any section is laid out, and the size the page fits it to is not
- * known yet.
+ * An auto-sized paragraph's text is weighed at its style's size: Normal is elected from the
+ * document's graph before the export reads any section's layout, which tells the size the page
+ * fits the text to.
*/
private static void weighTextStyles(DocumentNode node,
java.util.Map weights,
@@ -7560,7 +7585,8 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
/**
* What a paragraph's own fields lose on the way to Word, on any path that writes it in the
- * body: the size an auto-sized paragraph's text is fitted to, the marks of a paragraph the page
+ * body: the size an auto-sized paragraph's text is fitted to where its lines do not tell it,
+ * the marks of a paragraph the page
* reads as markdown, its {@code bulletOffset} and its outline entry. A page zone's paragraphs
* are written apart, and named on the zone's note ({@link #zoneParagraphLost}).
*
@@ -7636,25 +7662,39 @@ private boolean laysOutAPrefix(ParagraphNode node) {
}
/**
- * What an auto-sized paragraph's text loses of its size: the size the file holds it at, to
- * Word's half point, saying the one the page fits it to is not measured, where the layout does
- * not tell it — no layout, or lines in sizes that do not say which is the paragraph's. {@code
- * null} where the lines tell it, and the text is written at it ({@link #writtenStyle}), or
- * nothing laid out takes the paragraph's style — a run with a style of its own keeps its size
- * on the page.
+ * What an auto-sized paragraph's text loses of its size, read off what was written: the size
+ * the file holds it at, to Word's half point, saying the one the page fits it to is not
+ * measured, where its text in its style was not written at the fitted size
+ * ({@link #fittedWritten}) — its lines are not read, or are in sizes that do not say which is
+ * the paragraph's. {@code null} where it was, or nothing laid out takes the paragraph's style
+ * — a run with a style of its own keeps its size on the page.
*
* @param lines the lines the page laid the paragraph out in, empty where they are not read
*/
- private static String autoSizeLost(ParagraphNode node, List lines,
- String whose) {
- if (node.autoSize() == null || !holdsTextInTheParagraphsStyle(node) && !laysOutAPrefix(node, lines)
- || !Double.isNaN(fittedSize(node, lines))) {
+ private String autoSizeLost(ParagraphNode node, List lines,
+ String whose) {
+ if (node.autoSize() == null || fittedWritten.contains(node)
+ || !holdsTextInTheParagraphsStyle(node) && !laysOutAPrefix(node, lines)) {
return null;
}
return whose + " text is written at " + pointsOf(wordsSize(node.textStyle().size()))
+ "pt — the size the page fits it to is not measured";
}
+ /**
+ * Whether an auto-sized paragraph is to be written at its style's size where the page fits it
+ * to another: its text in its style laid out in lines that do not tell the fitted size
+ * ({@link #fittedSize}), as {@link #writtenStyle} reads them. Read before the paragraph is
+ * written, by what it holds a line for.
+ *
+ * @param lines the lines the page laid the paragraph out in, empty where they are not read
+ */
+ private static boolean fittedSizeUntold(ParagraphNode node,
+ List lines) {
+ return node.autoSize() != null && (holdsTextInTheParagraphsStyle(node) || laysOutAPrefix(node, lines))
+ && Double.isNaN(fittedSize(node, lines));
+ }
+
/**
* The characters markdown syntax is made of — emphasis, code, a heading, a link, a quote, an
* escape — counted alike in a text and in the lines the page lays it out in: the page drops
@@ -7711,7 +7751,7 @@ private static List pagePieces(String text, DocumentTextStyl
&& markdownMarksIn(DocxMarkdown.text(pieces)) == markdownMarksIn(text)) {
return null;
}
- return DocxMarkdown.laidOutIn(pieces, lines, prefix, false) ? pieces : null;
+ return DocxMarkdown.laidOutIn(pieces, lines, prefix) ? pieces : null;
}
/**
@@ -7736,7 +7776,8 @@ private String markdownHeadingCut(ParagraphNode node, List pieces, DocumentTextStyle sty
for (DocxMarkdown.Piece piece : pieces) {
if (piece.style().size() > style.size()
&& styleLineHeight(piece.style()) > line + HEADING_CLEARANCE) {
- return whose + " markdown heading is written at " + pointsOf(piece.style().size()) + "pt in a line "
+ // At the size the file holds, to Word's half point.
+ return whose + " markdown heading is written at " + pointsOf(wordsSize(piece.style().size())) + "pt in a line "
+ pointsOf(line) + "pt tall, as tall as the " + owner + " own line on the page: the page draws its "
+ "letters past the line, and Word cuts their tops on screen";
}
@@ -7913,19 +7955,31 @@ private static boolean setFromTheEndAwayFromItsPrefix(ParagraphNode node) {
* its laid-out lines; {@code NaN} when they are not read, or do not tell it.
*
* A paragraph the page reads as markdown sets a heading at a multiple of that size: the
- * share it sets the pieces it reads at ({@link DocxMarkdown#scaleIn}) tells it. Plain text
- * otherwise is laid out in one size, which is it; in more, the size is not told. A run with a
+ * share it sets the pieces it reads at ({@link DocxMarkdown#scaleIn}) tells it, and where the
+ * pieces are not found in its lines though the page dropped a mark, the size is not told.
+ * Plain text otherwise is laid out in one size, which is it; in more, the size is not told. A run with a
* style of its own is laid out at that style's size, so the paragraph's size is one no such
* run has; where the lines hold a single size, it is that one.
*/
private static double fittedSize(ParagraphNode node, List lines) {
DocumentTextStyle style = node.textStyle();
if (DocxMarkdown.mayRead(node) && style != null && style.size() > 0) {
- double scale = DocxMarkdown.scaleIn(DocxMarkdown.read(node.text(), style), lines,
- setsAPrefixBeforeTheFirstLine(node) ? node.bulletOffset() : "");
+ String prefix = setsAPrefixBeforeTheFirstLine(node) ? node.bulletOffset() : "";
+ double scale = DocxMarkdown.scaleIn(DocxMarkdown.read(node.text(), style), lines, prefix);
if (scale > 0) {
return style.size() * scale;
}
+ // Its pieces not found in them, the lines are the text as authored only where the page
+ // kept every mark. Where it dropped one it read the text into pieces — Arabic, which it
+ // shapes before it reads the marks, among them — a heading's at a multiple of the
+ // paragraph's size, and one size there may be the heading's.
+ int laidOut = -markdownMarksIn(prefix);
+ for (com.demcha.compose.document.layout.payloads.ParagraphLine line : lines) {
+ laidOut += markdownMarksIn(line.text());
+ }
+ if (laidOut < markdownMarksIn(node.text())) {
+ return Double.NaN;
+ }
}
java.util.Set laidOut = new java.util.LinkedHashSet<>();
for (com.demcha.compose.document.layout.payloads.ParagraphLine line : lines) {
@@ -7957,8 +8011,9 @@ private static double fittedSize(ParagraphNode node, List pieces = badgePieces(paragraph);
if (pieces != null) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAutoSizeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAutoSizeTest.java
index 9f5addaa7..c12f78719 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAutoSizeTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAutoSizeTest.java
@@ -16,6 +16,7 @@
import com.demcha.compose.document.style.ClipPolicy;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
import com.demcha.compose.document.style.DocumentTextIndent;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.table.DocumentTableCell;
@@ -24,6 +25,8 @@
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.apache.xmlbeans.SimpleValue;
+import org.apache.xmlbeans.XmlObject;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
@@ -32,23 +35,24 @@
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.groups.Tuple.tuple;
/**
* An auto-sized paragraph's text is written at the size the page fits it to, smaller or larger
* than its style's, on every path that writes a paragraph: the file holds what the page draws.
* A run with a style of its own keeps it, as on the page. Where the page's lines do not tell the
- * size — no layout, or lines in sizes each a run's own — the text is written at its style's size,
- * and named.
+ * size — no lines read, or lines in sizes that do not say which is the paragraph's — the text is
+ * written at its style's size, and named.
*/
class DocxAutoSizeTest {
private static final String HEADLINE = "A headline far too long for one line at its size";
private static final DocumentTextStyle TEN = DocumentTextStyle.DEFAULT.withSize(10);
private static final double CONTENT = 180;
+ private static final String W = "declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' ";
+ private static final String UNMEASURED = "the size the page fits it to is not measured";
@Test
void aParagraphIsWrittenAtTheSizeThePageFitsItTo() throws Exception {
@@ -76,8 +80,7 @@ void aRunWithAStyleOfItsOwnKeepsItsSizeAndOneWithNoneTakesTheFittedSize() throws
.inlineText("Hi ", DocumentTextStyle.DEFAULT.withSize(12)).inlineText("there").autoSize(24)));
assertThat(export.paragraphWith("there").getRuns()).filteredOn(run -> !run.text().isEmpty())
.extracting(XWPFRun::text, XWPFRun::getFontSizeAsDouble)
- .containsExactly(org.assertj.core.groups.Tuple.tuple("Hi ", 12.0),
- org.assertj.core.groups.Tuple.tuple("there", 24.0));
+ .containsExactly(tuple("Hi ", 12.0), tuple("there", 24.0));
assertThat(export.notes()).isEmpty();
// A paragraph ending in a chip has its own style on the mark, not the chip's: at the fitted size.
@@ -110,7 +113,54 @@ void aPrefixIsMeasuredAtTheSizeThePageSetsItIn() throws Exception {
}
@Test
- void everyPathThatWritesAParagraphWritesItAtTheSizeThePageFitsItTo() throws Exception {
+ void markdownPiecesAreWrittenAtTheSizeThePageFitsThemTo() throws Exception {
+ // Tracked in a share of the size, the pieces' tracking is the one the page resolves at the
+ // fitted size.
+ DocumentTextStyle tracked = DocumentTextStyle.builder().size(24)
+ .letterSpacing(DocumentLetterSpacing.ofFontSize(0.05)).build();
+ Export export = export(page -> page.addParagraph(p -> p.name("Revenue")
+ .text("Revenue **grew** this quarter by a long way").textStyle(tracked).autoSize(24, 6)));
+ double fitted = export.firstSize("Revenue");
+ assertThat(fitted).isLessThan(24);
+ assertThat(export.paragraphWith("grew").getRuns()).filteredOn(run -> !run.text().isEmpty())
+ .extracting(XWPFRun::text, XWPFRun::isBold, XWPFRun::getFontSizeAsDouble)
+ .containsExactly(tuple("Revenue ", false, wordsSize(fitted)), tuple("grew", true, wordsSize(fitted)),
+ tuple(" this quarter by a long way", false, wordsSize(fitted)));
+ assertThat(export.notes()).isEmpty();
+
+ // Composed in a table cell, matched to its lines as the page reads it.
+ Export cell = export(page -> page.add(new TableBuilder().name("Sums").columns(DocumentTableColumn.fixed(120))
+ .rowCells(DocumentTableCell.node(new ParagraphBuilder().name("Total").text("Total **due** now")
+ .textStyle(TEN).autoSize(20, 6).build()))
+ .build()));
+ double total = cell.sizeOf("due");
+ assertThat(total).isNotEqualTo(10.0);
+ assertThat(cell.document().getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0).getRuns())
+ .extracting(XWPFRun::text, XWPFRun::isBold, XWPFRun::getFontSizeAsDouble)
+ .containsExactly(tuple("Total ", false, wordsSize(total)), tuple("due", true, wordsSize(total)),
+ tuple(" now", false, wordsSize(total)));
+ assertThat(cell.notes()).isEmpty();
+
+ // A heading at a multiple of a fitted size off Word's half point is named at the size the
+ // file holds it at.
+ Export heading = export(page -> page.addParagraph(p -> p.name("Second")
+ .text("## A second level heading far too long_x").textStyle(DocumentTextStyle.DEFAULT.withSize(24))
+ .autoSize(new com.demcha.compose.document.style.DocumentTextAutoSize(24.5, 4.5, 1.0))));
+ double laid = heading.firstSize("Second");
+ assertThat(laid * 2).as("off the half point").isNotEqualTo(Math.rint(laid * 2));
+ assertThat(heading.paragraphWith("second").getRuns()).filteredOn(run -> run.text().contains("second"))
+ .singleElement().satisfies(run -> assertThat(run.getFontSizeAsDouble()).isEqualTo(wordsSize(laid)));
+ assertThat(heading.notes()).singleElement().asString()
+ .contains("its markdown heading is written at " + points(wordsSize(laid)) + "pt in a line ");
+ }
+
+ private static String points(double size) {
+ return java.math.BigDecimal.valueOf(size).stripTrailingZeros().toPlainString();
+ }
+
+ @Test
+ void theOtherPathsThatWriteAParagraphWriteItAtTheSizeThePageFitsItTo() throws Exception {
+ // The body is above; a page zone's line is DocxZoneLineTest's.
// A paragraph composed in a table cell.
Export cell = export(page -> page.add(new TableBuilder().name("Sums").columns(DocumentTableColumn.fixed(120))
.rowCells(DocumentTableCell.node(new ParagraphBuilder().name("Total").text("Total due this month")
@@ -133,6 +183,7 @@ void everyPathThatWritesAParagraphWritesItAtTheSizeThePageFitsItTo() throws Exce
assertThat(pair.firstSize("Title")).isEqualTo(14.0);
assertThat(pair.paragraphWith("ENGINEER").getRuns()).filteredOn(run -> "ENGINEER".equals(run.text()))
.singleElement().satisfies(run -> assertThat(run.getFontSizeAsDouble()).isEqualTo(14.0));
+ assertThat(pair.notes()).isEmpty();
// Text over the flow, in a text box.
Export box = export(page -> page.add(new ShapeContainerBuilder().name("Sidebar")
@@ -145,19 +196,22 @@ void everyPathThatWritesAParagraphWritesItAtTheSizeThePageFitsItTo() throws Exce
.build()).addParagraph("Masthead"));
assertThat(box.firstSize("Monogram")).isEqualTo(30.0);
assertThat(box.xml()).contains("");
- assertThat(sizesBefore(box.xml(), "LM")).isNotEmpty().containsOnly(30.0);
+ assertThat(sizesOf(box.document(), "LM")).isNotEmpty().containsOnly(30.0);
+ assertThat(box.notes()).as("the box's own note, and nothing of the size").isNotEmpty()
+ .noneMatch(note -> note.contains("is written at"));
// A badge's initials, in its shape.
Export badge = export(page -> page.add(badge("JR")));
double initials = badge.firstSize("Initials");
assertThat(initials).isGreaterThan(8);
assertThat(badge.xml()).contains("");
- assertThat(sizesBefore(badge.xml(), "JR")).isNotEmpty().containsOnly(wordsSize(initials));
+ assertThat(sizesOf(badge.document(), "JR")).isNotEmpty().containsOnly(wordsSize(initials));
assertThat(badge.notes()).isEmpty();
// Initials the page reads as markdown, in one face at the fitted size, stay in the shape.
Export marked = export(page -> page.add(badge("*JR*")));
- assertThat(marked.xml()).contains("").contains("").doesNotContain("*JR*");
- assertThat(sizesBefore(marked.xml(), "JR")).isNotEmpty().containsOnly(wordsSize(marked.firstSize("Initials")));
+ assertThat(marked.xml()).contains("").doesNotContain("*JR*");
+ assertThat(italicOf(marked.document(), "JR")).isNotEmpty().containsOnly(true);
+ assertThat(sizesOf(marked.document(), "JR")).isNotEmpty().containsOnly(wordsSize(marked.firstSize("Initials")));
assertThat(marked.notes()).isEmpty();
}
@@ -179,24 +233,27 @@ void whereItsLinesDoNotTellTheSizeTheTextIsWrittenAtItsStylesAndNamed() throws E
.inlineText("B ", DocumentTextStyle.DEFAULT.withSize(11)).inlineText("C").autoSize(14)));
assertThat(export.paragraphWith("A B C").getRuns()).filteredOn(run -> !run.text().isEmpty())
.extracting(XWPFRun::getFontSizeAsDouble).containsExactly(14.0, 11.0, 8.0);
- assertThat(export.notes()).containsExactly("written as a paragraph; its text is written at 8pt — the size the "
- + "page fits it to is not measured");
+ assertThat(export.notes()).containsExactly("written as a paragraph; its text is written at 8pt — " + UNMEASURED);
- // Read as markdown in other letters than the pieces, over lines of two sizes — a heading's
- // and the body's — the lines do not tell which is its own either.
- Export arabic = export(page -> page.addParagraph(p -> p.text("# مرحبا_\nسطر *ب*")
- .textStyle(DocumentTextStyle.builder().fontName(FontName.AMIRI).size(10).build()).autoSize(20, 6)));
- assertThat(arabic.notes()).singleElement().asString().startsWith("written as a paragraph; its text is written "
- + "at 10pt — the size the page fits it to is "
- + "not measured");
+ // Read as markdown in other letters than the pieces — Arabic, which the page shapes before
+ // it reads the marks — its lines do not tell which size is its own: over two lines, a
+ // heading's and the body's; over one, a heading's alone, twice the fitted size.
+ DocumentTextStyle amiri = DocumentTextStyle.builder().fontName(FontName.AMIRI).size(10).build();
+ for (String text : List.of("# مرحبا_\nسطر *ب*", "# مرحبا_")) {
+ Export arabic = export(page -> page.addParagraph(HEADLINE + " " + HEADLINE)
+ .addParagraph(p -> p.text(text).textStyle(amiri).autoSize(20, 6)));
+ assertThat(arabic.notes()).as(text).singleElement().asString()
+ .startsWith("written as a paragraph; its text is written at 10pt — " + UNMEASURED);
+ assertThat(sizesOf(arabic.document(), text.replace("\n", ""))).as(text).containsOnly(10.0);
+ }
// With no layout, nothing tells it.
- XWPFDocument unlaid = DocxExports.withoutLayout(240, 600, 30, page -> page
- .addParagraph(p -> p.text(HEADLINE + " " + HEADLINE).textStyle(TEN))
- .addParagraph(p -> p.text("Hi").textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6)));
- assertThat(unlaid.getParagraphs().stream().filter(paragraph -> paragraph.getText().equals("Hi")).findFirst()
- .orElseThrow().getRuns()).singleElement()
- .satisfies(run -> assertThat(run.getFontSizeAsDouble()).isEqualTo(24.0));
+ Consumer unlaid = page -> page.addParagraph(p -> p.text(HEADLINE + " " + HEADLINE).textStyle(TEN))
+ .addParagraph(p -> p.text("Hi").textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6));
+ assertThat(sizesOf(DocxExports.withoutLayout(240, 600, 30, unlaid), "Hi")).containsExactly(24.0);
+ assertThat(DocxExports.reportWithoutLayout(240, 600, 30, unlaid).bySubject().get("ParagraphNode"))
+ .extracting(DocxExportReport.Note::detail)
+ .containsExactly("written as a paragraph; its text is written at 24pt — " + UNMEASURED);
}
private static double wordsSize(double size) {
@@ -207,17 +264,37 @@ private static double markSize(XWPFParagraph paragraph) {
return ((Number) paragraph.getCTP().getPPr().getRPr().getSzArray(0).getVal()).doubleValue() / 2;
}
- /** The sizes the runs reading a text are written at, wherever in the body they stand — a text box's or a shape's too. */
- private static List sizesBefore(String xml, String text) {
- Matcher matcher = Pattern.compile("(?:(?!).)*" + Pattern.quote(text) + "",
- Pattern.DOTALL).matcher(xml);
+ /** The runs reading a text, wherever in the body they stand: a text box's and a shape's too. */
+ private static List runsReading(XWPFDocument document, String text) {
+ List runs = new ArrayList<>();
+ for (XmlObject run : document.getDocument().getBody().selectPath(W + ".//w:r")) {
+ StringBuilder letters = new StringBuilder();
+ for (XmlObject letter : run.selectPath(W + "./w:t")) {
+ letters.append(((SimpleValue) letter).getStringValue());
+ }
+ if (letters.toString().equals(text)) {
+ runs.add(run);
+ }
+ }
+ return runs;
+ }
+
+ /** The sizes the runs reading a text are written at, wherever in the body they stand. */
+ private static List sizesOf(XWPFDocument document, String text) {
List sizes = new ArrayList<>();
- while (matcher.find()) {
- sizes.add(Integer.parseInt(matcher.group(1)) / 2.0);
+ for (XmlObject run : runsReading(document, text)) {
+ for (XmlObject size : run.selectPath(W + "./w:rPr/w:sz/@w:val")) {
+ sizes.add(Double.parseDouble(((SimpleValue) size).getStringValue()) / 2);
+ }
}
return sizes;
}
+ /** Whether each run reading a text, wherever in the body it stands, is written italic. */
+ private static List italicOf(XWPFDocument document, String text) {
+ return runsReading(document, text).stream().map(run -> run.selectPath(W + "./w:rPr/w:i").length > 0).toList();
+ }
+
private record Export(XWPFDocument document, DocxExportReport report, Map> fragments) {
XWPFParagraph paragraphWith(String text) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMarkdownTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMarkdownTest.java
index 275d68395..abf11f5d1 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMarkdownTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMarkdownTest.java
@@ -83,56 +83,59 @@ void thePiecesAreThePagesWhereItsLinesHoldThemSoAndNotOtherwise() {
List pieces = DocxMarkdown.read("Some **bold** text", BODY);
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some", TextDecoration.DEFAULT, 10),
span(" ", TextDecoration.DEFAULT, 10), span("bold", TextDecoration.BOLD, 10),
- span(" text", TextDecoration.DEFAULT, 10))), "", false)).isTrue();
+ span(" text", TextDecoration.DEFAULT, 10))), "")).isTrue();
// Broken over two lines, the space at the break dropped.
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some ", TextDecoration.DEFAULT, 10),
- span("bold", TextDecoration.BOLD, 10)), line(span("text", TextDecoration.DEFAULT, 10))), "", false)).isTrue();
+ span("bold", TextDecoration.BOLD, 10)), line(span("text", TextDecoration.DEFAULT, 10))), "")).isTrue();
// An auto-sized paragraph's text, at a size its style does not hold, in proportion.
List fitted = List.of(line(span("A *b*", TextDecoration.BOLD, 14)),
line(span("c", TextDecoration.DEFAULT, 7)));
- assertThat(DocxMarkdown.laidOutIn(DocxMarkdown.read("# A *b*\nc", BODY), fitted, "", true)).isTrue();
- assertThat(DocxMarkdown.laidOutIn(DocxMarkdown.read("# A *b*\nc", BODY), fitted, "", false))
- .as("in proportion, where the page fits no size of its own").isFalse();
+ assertThat(DocxMarkdown.scaleIn(DocxMarkdown.read("# A *b*\nc", BODY), fitted, ""))
+ .as("the share they are set at").isCloseTo(0.7, org.assertj.core.data.Offset.offset(1e-9));
+ assertThat(DocxMarkdown.laidOutIn(DocxMarkdown.read("# A *b*\nc", BODY), fitted, ""))
+ .as("in proportion, not at their sizes").isFalse();
+ assertThat(DocxMarkdown.laidOutIn(DocxMarkdown.read("# A *b*\nc", BODY.withSize(7)), fitted, ""))
+ .as("read at the size the page fits the text to").isTrue();
// A prefix the page sets before the first line leads its letters.
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("• ", TextDecoration.DEFAULT, 10),
span("Some ", TextDecoration.DEFAULT, 10), span("bold", TextDecoration.BOLD, 10),
- span(" text", TextDecoration.DEFAULT, 10))), "• ", false)).isTrue();
+ span(" text", TextDecoration.DEFAULT, 10))), "• ")).isTrue();
// Marks alone the page sets as nothing, which are not taken for the page's.
assertThat(DocxMarkdown.read("***", BODY)).isEmpty();
- assertThat(DocxMarkdown.laidOutIn(List.of(), List.of(line()), "", false)).isFalse();
+ assertThat(DocxMarkdown.laidOutIn(List.of(), List.of(line()), "")).isFalse();
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some **bold** text", TextDecoration.DEFAULT, 10))),
- "", false)).as("the marks laid out: the session reads no markdown").isFalse();
- assertThat(DocxMarkdown.laidOutIn(List.of(), List.of(line(span("***", TextDecoration.DEFAULT, 10))), "", false))
+ "")).as("the marks laid out: the session reads no markdown").isFalse();
+ assertThat(DocxMarkdown.laidOutIn(List.of(), List.of(line(span("***", TextDecoration.DEFAULT, 10))), ""))
.as("marks alone laid out").isFalse();
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some bold text", TextDecoration.DEFAULT, 10))),
- "", false)).as("another face").isFalse();
- assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some ", TextDecoration.DEFAULT, 10),
- span("bold", TextDecoration.BOLD, 12), span(" text", TextDecoration.DEFAULT, 10))), "", true))
- .as("sizes out of proportion").isFalse();
+ "")).as("another face").isFalse();
+ assertThat(DocxMarkdown.scaleIn(pieces, List.of(line(span("Some ", TextDecoration.DEFAULT, 10),
+ span("bold", TextDecoration.BOLD, 12), span(" text", TextDecoration.DEFAULT, 10))), ""))
+ .as("sizes out of proportion").isNaN();
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some ", TextDecoration.DEFAULT, 10),
span("bold", TextDecoration.BOLD, 10), span(" text", TextDecoration.DEFAULT, 10, FontName.COURIER,
- Color.BLACK))), "", false)).as("another family").isFalse();
+ Color.BLACK))), "")).as("another family").isFalse();
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some ", TextDecoration.DEFAULT, 10),
span("bold", TextDecoration.BOLD, 10), span(" text", TextDecoration.DEFAULT, 10, BODY.fontName(),
- Color.RED))), "", false)).as("another colour").isFalse();
+ Color.RED))), "")).as("another colour").isFalse();
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some ", TextDecoration.DEFAULT, 10),
span("bold", TextDecoration.BOLD, 10), new ParagraphTextSpan(" text",
new TextStyle(BODY.fontName(), 10, TextDecoration.DEFAULT, BODY.color().color(), 0.5), 25, 10,
- null, null, false))), "", false)).as("another tracking").isFalse();
+ null, null, false))), "")).as("another tracking").isFalse();
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some ", TextDecoration.DEFAULT, 10),
- span("bolt", TextDecoration.BOLD, 10), span(" text", TextDecoration.DEFAULT, 10))), "", false))
+ span("bolt", TextDecoration.BOLD, 10), span(" text", TextDecoration.DEFAULT, 10))), ""))
.as("another letter").isFalse();
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("Some ", TextDecoration.DEFAULT, 10),
- span("bold", TextDecoration.BOLD, 10))), "", false)).as("a letter short").isFalse();
+ span("bold", TextDecoration.BOLD, 10))), "")).as("a letter short").isFalse();
assertThat(DocxMarkdown.laidOutIn(pieces, List.of(line(span("- Some ", TextDecoration.DEFAULT, 10),
- span("bold", TextDecoration.BOLD, 10), span(" text", TextDecoration.DEFAULT, 10))), "• ", false))
+ span("bold", TextDecoration.BOLD, 10), span(" text", TextDecoration.DEFAULT, 10))), "• "))
.as("a prefix of other letters").isFalse();
- assertThat(DocxMarkdown.laidOutIn(pieces, List.of(), "", false)).as("no lines").isFalse();
+ assertThat(DocxMarkdown.laidOutIn(pieces, List.of(), "")).as("no lines").isFalse();
List withAPicture = new ArrayList<>(line(span("Some ", TextDecoration.DEFAULT, 10),
span("bold", TextDecoration.BOLD, 10), span(" text", TextDecoration.DEFAULT, 10)).spans());
withAPicture.add(new ParagraphShapeSpan(List.of(), 4, 4, null, 0, null));
- assertThat(DocxMarkdown.laidOutIn(pieces, List.of(new ParagraphLine("", 0, 10, 10, 8, 2, withAPicture)), "", false))
+ assertThat(DocxMarkdown.laidOutIn(pieces, List.of(new ParagraphLine("", 0, 10, 10, 8, 2, withAPicture)), ""))
.as("anything but text").isFalse();
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
index d9f261822..5354aba26 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
@@ -210,8 +210,9 @@ private record Entry(Fate fate, String note) {
+ "pair whose left holds the level",
"padding:WRITTEN", "margin:WRITTEN",
"autoSize:REPORTED:where the layout does not tell the size the page fits the text in the "
- + "paragraph's style to — no layout, or lines in sizes each a run's own — the text written at its "
- + "style's, not measured; any other is written, at the size the page fits it to",
+ + "paragraph's style to — no lines read, or lines in sizes that do not say which is the "
+ + "paragraph's — the text written at its style's, not measured; any other is written, at the size "
+ + "the page fits it to",
"verticalAlign:WRITTEN", "anchor:WRITTEN", "direction:WRITTEN");
node(PathNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "segments:WRITTEN",
"fillColor:WRITTEN", "fillPaint:REPORTED", "stroke:WRITTEN", "strokePaint:REPORTED",
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
index f062202a6..b400489ff 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
@@ -75,7 +75,7 @@ void anAutoSizedParagraphsTextIsNamedOnlyWhereItsLinesDoNotTellTheSizeThePageFit
}
@Test
- void onlyTheTextThatTakesTheParagraphsStyleIsFitted() throws Exception {
+ void aParagraphOfRunsInStylesOfTheirOwnHasNoFittedSizeToLose() throws Exception {
// A run with a style of its own is laid out at its own size, as it is written: with no
// text in the paragraph's style, nothing is fitted to lose.
assertThat(paragraphNotes(page -> page.addParagraph(p -> p.textStyle(TEN)
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
index c4363a3c1..27d09e9ef 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
@@ -227,7 +227,8 @@ void aPartFittedSmallerIsWrittenAtTheSizeThePageFitsItToInThePagesLine() throws
.textStyle(DocumentTextStyle.DEFAULT.withSize(18)).autoSize(18, 6).build())
.build());
double written = exported.headerLine().getRuns().get(0).getFontSizeAsDouble();
- assertThat(written).as("fitted smaller than its style's 18pt").isLessThan(18);
+ assertThat(written).as("fitted smaller than its style's 18pt, to Word's half point")
+ .isLessThan(18).isEqualTo(Math.round(exported.size("running") * 2) / 2.0);
Exported unfitted = export(zone(DocumentHeaderFooterZone.HEADER, 40, new DocumentInsets(4, 0, 0, 0), "Acme",
DocumentTextStyle.DEFAULT.withSize(written)));
@@ -236,6 +237,25 @@ void aPartFittedSmallerIsWrittenAtTheSizeThePageFitsItToInThePagesLine() throws
assertThat(exported.report().bySubject()).doesNotContainKey("page zone");
}
+ @Test
+ void aPartThePageSetsOtherwiseOnItsFirstPageTakesNoSizeFromIt() throws Exception {
+ // Written for no page in particular, it reads "End"; the page set a longer line on page 1,
+ // fitted smaller. Its lines there are other text's, and tell nothing of the size "End" is
+ // fitted to: it is written at its style's size, and named.
+ Exported exported = export(session -> session.chrome().zone(DocumentPageZone.footer(40,
+ page -> new ParagraphBuilder().text(page.isLast() ? "End"
+ : "Continued on the next page of this report, where its totals are")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(10)).autoSize(14, 6).build())), true);
+ assertThat(exported.size("Continued")).as("page 1's line, fitted smaller").isLessThan(10);
+
+ assertThat(exported.footerLine().getRuns()).singleElement()
+ .satisfies(run -> assertThat(run.getFontSizeAsDouble()).isEqualTo(10.0));
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("a footer written as one line of Word's footer; whether its text stands where the "
+ + "page sets it is not measured; a paragraph's text is written at 10pt — the size "
+ + "the page fits it to is not measured");
+ }
+
@Test
void aPartWhoseLinesDoNotTellTheSizeThePageFitsItToIsGivenItsStylesLine() throws Exception {
// Fitted to 12pt, the size its first run has of its own: the lines hold 12 and 10, each a
@@ -604,8 +624,17 @@ XWPFParagraph footerLine() {
return document.getFooterList().get(0).getParagraphs().get(0);
}
+ /** The size the page sets a word's first letter in, on the first page it draws it: its font's, unscaled. */
+ double size(String word) {
+ return text.get(firstLetterOf(word)).getFontSize();
+ }
+
/** Where the page sets a word's baseline, from its top, on the first page it draws it. */
double baseline(String word) {
+ return text.get(firstLetterOf(word)).getYDirAdj();
+ }
+
+ private int firstLetterOf(String word) {
StringBuilder letters = new StringBuilder();
for (int start = 0; start < text.size(); start++) {
letters.setLength(0);
@@ -613,7 +642,7 @@ XWPFParagraph footerLine() {
letters.append(text.get(index).getUnicode());
}
if (letters.toString().equals(word)) {
- return text.get(start).getYDirAdj();
+ return start;
}
}
throw new AssertionError("the page draws no " + word);
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java
index 9db93ba31..5bc4c8d66 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java
@@ -191,7 +191,7 @@ void aZoneParagraphsOwnLossesAreNamed() throws Exception {
.as("a prefix Word does not write stands the text off, and its letters are lost")
.containsExactly(FOOTER + OFF + "; a paragraph's bulletOffset letters, \"•\", are not written before "
+ "its first line");
- // Auto-sized, its text is written at the size the page fits it to, where its lines tell it.
+ // Auto-sized, it is not named where its lines tell the size the page fits it to, which is written.
assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Hi").autoSize(14).build()))).isEmpty();
// Fitted to 12pt, the size its first run has of its own, its lines do not tell which size is its own.
assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new ParagraphBuilder().name("ZoneLine")
From b214b87a1f9c8a6da1efb662bdc19095349456c5 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 7 Oct 2026 22:47:46 +0100
Subject: [PATCH 3/3] fix(docx): decide a zone's lines part by part, and tell
no fitted size for a paragraph placed twice
A page zone the page draws otherwise on its first page took no lines for
any of its parts, so a part drawn as written beside one that changes
lost its markdown pieces: `**Acme**` was written with its asterisks.
ZonePlacement.laidOtherwise now holds only the parts drawn otherwise
(DocxZoneParts.partsReadOtherwise); the others keep their lines.
A paragraph added at more than one place is fitted at each apart, and
the layout index holds one place's lines: it was written at that size
everywhere, unnamed. DocxLayoutMetrics.placedMoreThanOnce counts the
places, table cells included, and the size is not told there.
fittedStyle returns null where the size is not told, and the writers
record by it rather than by the identity of the style they write.
---
CHANGELOG.md | 27 ++-
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
render-docx/README.md | 4 +-
.../semantic/docx/DocxLayoutMetrics.java | 47 ++++-
.../semantic/docx/DocxSemanticBackend.java | 181 ++++++++++--------
.../backend/semantic/docx/DocxZoneParts.java | 24 +++
.../semantic/docx/DocxAutoSizeTest.java | 77 ++++++--
.../docx/DocxNodeFieldLedgerTest.java | 6 +-
.../docx/DocxParagraphReportTest.java | 3 +
.../semantic/docx/DocxZoneLineTest.java | 26 ++-
.../semantic/docx/DocxZonePartsTest.java | 20 ++
12 files changed, 310 insertions(+), 109 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88b760d98..37880a70f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,18 +16,26 @@ follow semantic versioning; release dates are ISO 8601.
- **The text that takes the paragraph's style is written at the fitted size**, read off the
layout's lines. A run with a style of its own keeps it, as on the page. The paragraph's mark,
which Word continues from, takes the fitted size where the text ending it takes the
- paragraph's style, and a prefix the page sets before the lines is measured at it.
+ paragraph's style or a chip ends it, and a prefix the page sets before the lines is measured
+ at it.
- **Markdown pieces are read at it.** A heading is written at its multiple of the fitted size, as
- the page sets it, and named where it stands taller than its line, as any heading is.
+ the page sets it, and named where it stands taller than its line, as any heading is. A
+ heading's note now names its size as Word holds it, to the half point — any paragraph's
+ heading, and a list item's.
- **Every path that writes a paragraph does it:** the body, a table cell, text over the flow, a
side of an overlay's left-and-right pair, a badge's initials and a page zone's line. A zone's
line is the page's, where it was its style's.
- **Still written at its style's size, and named**, where the layout's lines do not tell the
- fitted size. None are read: with no layout, or for a page zone the page sets with other text
- on the first page it draws it than the zone is written with. Or their sizes do not say which
- is the paragraph's: a paragraph fitted to 12pt beside runs of 12pt and 10pt of their own, or
- Arabic the page reads as markdown, which it shapes before it reads the marks. The note says
- the fitted size is not measured.
+ fitted size. The note says the fitted size is not measured.
+ - None are read: with no layout, for a paragraph composed in a table cell that no line of
+ its table carries, and for a part of a page zone the layout shows none of, or sets
+ otherwise on the first page it draws the zone — other text, face, size or pictures. A part
+ the page sets as written there keeps its lines, and its markdown is read from them.
+ - Their sizes do not say which is the paragraph's: a paragraph fitted to 12pt beside runs of
+ 12pt and 10pt of their own, or Arabic the page reads as markdown, which it shapes before
+ it reads the marks.
+ - They are one place's: one paragraph added at more than one place, which the page fits at
+ each apart.
Measured in Word 16 and LibreOffice on a page of auto-sized paragraphs, each word now stands
within half a point of the page's baseline, at the page's size. A shrunk headline is one line,
@@ -90,7 +98,10 @@ follow semantic versioning; release dates are ISO 8601.
- **The page's own lines decide it.** The pieces are written only where the lines the page laid
the paragraph out in hold the pieces' letters in their faces, families, colours and tracking,
at their sizes to a hundredth of a point — or, where the page fits the text to a size of its
- own, at sizes in the same proportion. A session that reads no markdown lays the marks out, and
+ own, at sizes in the same proportion (since read at the fitted size and compared exactly, a
+ table cell's lines matched in proportion: see "A DOCX export writes an auto-sized
+ paragraph's text at the size the page fits it to"). A session that reads no markdown lays
+ the marks out, and
the text is written as it stands, as before; so is text the parser changes nothing of, an
underscore inside a word.
- **The faces are the page's.** Where the session reads markdown, its parser sets every piece in
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 39674a4ae..91870f258 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -63,7 +63,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, or lines in sizes that do not say which is the paragraph's); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level |
+| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items or a drawn marker, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell, its wrapping not measured), a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps or its wrapping is not measured, the rows the page draws as a marker alone for blank items of a flagged list, which are not written, and what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding above and below the letters are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what Word paints underneath it — the paragraph's shading, the cell's, or else the colour the page paints under the paragraph, a page background included — so the chip agrees with the file it is in and shows the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 2607bcc85..842cdc9ab 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -67,7 +67,7 @@ creation date is real metadata.
| Document node | DOCX output |
|---|---|
-| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, or they are in sizes that do not say which is the paragraph's — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container |
+| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, they are in sizes that do not say which is the paragraph's, or the paragraph is added at more than one place, which the page fits at each apart — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container |
| Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. An item the page reads as markdown is written as the page sets it, as a paragraph is. See "What a list becomes" below for the kinds that stay plain paragraphs |
| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given |
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path |
diff --git a/render-docx/README.md b/render-docx/README.md
index 1a32058fe..7b4d51fe8 100644
--- a/render-docx/README.md
+++ b/render-docx/README.md
@@ -139,8 +139,8 @@ What is not written — each one is named in the export report
- **What a paragraph's own fields set where Word cannot hold it**, named in the export report on
the paragraph outside a header or footer (a page zone's are named on the zone):
- the size an auto-sized paragraph's text is fitted to, where the layout does not tell it (no
- lines read, or lines in sizes that do not say which is the paragraph's); elsewhere the text
- is written at it;
+ lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added
+ at more than one place); elsewhere the text is written at it;
- the marks of a paragraph the session reads as markdown (the default; `markdown(false)` turns
it off), where they are written as letters: the paragraph is written as the page sets it —
its marks dropped, each piece in the page's face and size — wherever the page's lines show
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index 44acbba6d..27c2fc5e5 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -51,9 +51,11 @@ final class DocxLayoutMetrics {
/** What an export with no compiled layout uses: every question answers "unknown". */
static final DocxLayoutMetrics EMPTY =
- new DocxLayoutMetrics(new IdentityHashMap<>(), Map.of(), Map.of(), List.of(), 0);
+ new DocxLayoutMetrics(new IdentityHashMap<>(), java.util.Set.of(), Map.of(), Map.of(), List.of(), 0);
private final Map paths;
+ // Nodes one instance of which stands at more than one place — see placedMoreThanOnce.
+ private final java.util.Set repeated;
private final Map> fragments;
private final Map placed;
// Every fragment, in the order the page paints them.
@@ -78,11 +80,13 @@ final class DocxLayoutMetrics {
private Map nodesByPath;
private DocxLayoutMetrics(Map paths,
+ java.util.Set repeated,
Map> fragments,
Map placed,
List painted,
int pageCount) {
this.paths = paths;
+ this.repeated = repeated;
this.fragments = fragments;
this.placed = placed;
this.painted = painted;
@@ -104,10 +108,15 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) {
for (int index = 0; index < graph.roots().size(); index++) {
indexPaths(graph.roots().get(index), null, index, paths);
}
+ java.util.Set seen = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
+ java.util.Set repeated = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
+ for (DocumentNode root : graph.roots()) {
+ countPlaces(root, seen, repeated);
+ }
if (layout == null) {
// No measurements, but the paths still name the nodes — which is what a
// diagnostic note needs to say where in the document it came from.
- return new DocxLayoutMetrics(paths, Map.of(), Map.of(), List.of(), 0);
+ return new DocxLayoutMetrics(paths, repeated, Map.of(), Map.of(), List.of(), 0);
}
Map> fragments = new HashMap<>();
for (PlacedFragment fragment : layout.fragments()) {
@@ -117,7 +126,39 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) {
for (PlacedNode node : layout.nodes()) {
placed.putIfAbsent(node.path(), node);
}
- return new DocxLayoutMetrics(paths, fragments, placed, layout.fragments(), layout.totalPages());
+ return new DocxLayoutMetrics(paths, repeated, fragments, placed, layout.fragments(), layout.totalPages());
+ }
+
+ /** Walks every place a node stands — in the flow, and in a table's composed cells — adding those seen twice. */
+ private static void countPlaces(DocumentNode node, java.util.Set seen,
+ java.util.Set repeated) {
+ if (node == null) {
+ return;
+ }
+ if (!seen.add(node)) {
+ repeated.add(node);
+ }
+ if (node instanceof TableNode table) {
+ for (List row : table.rows()) {
+ for (DocumentTableCell cell : row) {
+ if (cell != null) {
+ countPlaces(cell.content(), seen, repeated);
+ }
+ }
+ }
+ }
+ for (DocumentNode child : node.children()) {
+ countPlaces(child, seen, repeated);
+ }
+ }
+
+ /**
+ * Whether one instance of a node stands at more than one place: added twice to the flow, or
+ * in the flow and in a table's composed cell. The layout lays out each place apart, at a width
+ * of its own, and this index, keyed by the node, finds the lines of only one of them.
+ */
+ boolean placedMoreThanOnce(DocumentNode node) {
+ return repeated.contains(node);
}
/**
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 36cb74c13..b9c6b0c4d 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -308,7 +308,7 @@ public final class DocxSemanticBackend implements SemanticBackend {
private final java.util.Map> markdownWritten =
new java.util.IdentityHashMap<>();
// Auto-sized paragraphs whose text in their style was written at the size the page fits it to
- // (writtenStyle): their notes do not name the size. A path that writes one otherwise leaves it
+ // (fittedStyle): their notes do not name the size. A path that writes one otherwise leaves it
// out, and it is named.
private final java.util.Set fittedWritten =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
@@ -1875,9 +1875,9 @@ private void writeZoneLine(XWPFHeaderFooter target, int zoneIndex, DocumentNode
reportUnwrittenRowPaint(row, true);
}
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
- java.util.Map laid = zoneTextAsWritten(zoneIndex, placement);
+ java.util.Map laid = layout.zoneText(zoneIndex);
for (DocumentNode part : parts) {
- appendZonePart(para, part, zoneLinesOf(part, paths, laid));
+ appendZonePart(para, part, zoneLinesOf(part, paths, laid, placement));
}
}
@@ -1885,13 +1885,16 @@ private void writeZoneLine(XWPFHeaderFooter target, int zoneIndex, DocumentNode
* The lines the page laid a part of a zone out in on the first page it draws the zone on,
* found by the part's path within the zone's content.
*
- * @return the lines, empty where the layout shows none of the part
+ * @return the lines, empty where the layout shows none of the part, or set it there otherwise
+ * than it is written ({@link ZonePlacement#laidOtherwise}): another's lines tell nothing
+ * of it
*/
private static List zoneLinesOf(
DocumentNode part, java.util.Map paths,
- java.util.Map laid) {
+ java.util.Map laid, ZonePlacement placement) {
com.demcha.compose.document.layout.PlacedFragment fragment = laid.get(paths.get(part));
- return fragment == null || !(fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload text)
+ return fragment == null || placement.laidOtherwise().contains(part)
+ || !(fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload text)
? List.of() : text.lines();
}
@@ -1916,25 +1919,29 @@ private static List z
*
* Where the page set the zone on the first page it draws it otherwise than the zone is
* written — other text, another face or size, other pictures ({@link DocxZoneParts#readAlike})
- * — the line is not measured, and Word's own, and the lines the page set there tell nothing
- * of the parts written: not the pieces of their markdown, nor the size an auto-sized one is
- * fitted to.
- *
- * @param line the exact line's height in points, NaN where no part's text is laid out
- * @param lines how many lines the part of the most takes on the page
- * @param distance from the page's top edge to the paragraph's top in a header, from its foot
- * to the paragraph's foot in a footer, in points
- * @param baseline the baseline Word sets the first line on, measured up from the page's foot
- * @param asLaid whether the page set the zone on the first page it draws it as it is
- * written, so its lines there are the parts' own
- */
- private record ZonePlacement(double line, int lines, double distance, double baseline, boolean asLaid) {
+ * — the line is not measured, and Word's own. A part the page set there otherwise has lines
+ * of another's, which tell nothing of it: not the size an auto-sized one is fitted to, nor the
+ * pieces of its markdown. A part it set there as written keeps them.
+ *
+ * @param line the exact line's height in points, NaN where no part's text is laid out
+ * @param lines how many lines the part of the most takes on the page
+ * @param distance from the page's top edge to the paragraph's top in a header, from its
+ * foot to the paragraph's foot in a footer, in points
+ * @param baseline the baseline Word sets the first line on, measured up from the page's foot
+ * @param laidOtherwise the parts the page set on the first page it draws the zone otherwise
+ * than they are written ({@link DocxZoneParts#partsReadOtherwise}), whose
+ * lines there are not their own
+ */
+ private record ZonePlacement(double line, int lines, double distance, double baseline,
+ java.util.Set laidOtherwise) {
private static final ZonePlacement UNMEASURED =
- new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, true);
+ new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, java.util.Set.of());
- private static final ZonePlacement LAID_OTHERWISE =
- new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, false);
+ /** Not measured, its parts named laid otherwise among them. */
+ static ZonePlacement laidOtherwise(java.util.Set parts) {
+ return new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, parts);
+ }
/** Whether the layout laid out the text the line is placed by. */
boolean measured() {
@@ -1953,10 +1960,12 @@ private ZonePlacement zonePlacement(int zoneIndex, boolean header, DocumentPageZ
if (Double.isNaN(canvasHeight) || firstPage < 0) {
return ZonePlacement.UNMEASURED;
}
- // The page set the zone otherwise there than it is written: its line is not measured by it.
- if (!DocxZoneParts.readAlike(content, zone.getContent().apply(
- PageContext.paginated(firstPage + 1, Math.max(firstPage + 1, layout.pageCount()))))) {
- return ZonePlacement.LAID_OTHERWISE;
+ // The page set the zone otherwise there than it is written: its line is not measured by it,
+ // and a part set otherwise takes nothing from its lines there.
+ DocumentNode drawn = zone.getContent().apply(
+ PageContext.paginated(firstPage + 1, Math.max(firstPage + 1, layout.pageCount())));
+ if (!DocxZoneParts.readAlike(content, drawn)) {
+ return ZonePlacement.laidOtherwise(DocxZoneParts.partsReadOtherwise(content, drawn));
}
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
java.util.Map laid = layout.zoneText(zoneIndex);
@@ -2001,17 +2010,7 @@ private ZonePlacement zonePlacement(int zoneIndex, boolean header, DocumentPageZ
double distance = DocxTextBands.distanceFromEdge(header,
header ? canvasHeight - tallest.baseline() : tallest.baseline() - (mostLines - 1) * line, line);
double baseline = header ? canvasHeight - distance - above : distance + (mostLines - 1) * line + (line - above);
- return new ZonePlacement(line, mostLines, distance, baseline, true);
- }
-
- /**
- * The fragments the page laid a zone's parts out in, by path within its content: on the first
- * page it draws the zone on, and none where it set the zone there otherwise than it is written
- * ({@link ZonePlacement#asLaid}), whose lines are other text's.
- */
- private java.util.Map zoneTextAsWritten(
- int zoneIndex, ZonePlacement placement) {
- return placement.asLaid() ? layout.zoneText(zoneIndex) : java.util.Map.of();
+ return new ZonePlacement(line, mostLines, distance, baseline, java.util.Set.of());
}
/**
@@ -2086,7 +2085,7 @@ private double zoneRightTab() {
private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content, ZonePlacement placement) {
List parts = DocxZoneParts.of(content);
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
- java.util.Map laid = zoneTextAsWritten(zoneIndex, placement);
+ java.util.Map laid = layout.zoneText(zoneIndex);
// Read where the line is placed by what the page set: not where the page set other text.
boolean measured = placement.measured() && !laid.isEmpty() && !Double.isNaN(canvasLeftMargin);
// Each text part's side of the line: 0 from the left margin, 1 against the right, 2
@@ -2180,7 +2179,7 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
}
for (DocumentNode part : texts) {
if (part instanceof ParagraphNode paragraph) {
- lost.addAll(zoneParagraphLost(paragraph, zoneLinesOf(part, paths, laid)));
+ lost.addAll(zoneParagraphLost(paragraph, zoneLinesOf(part, paths, laid, placement)));
}
}
if (!lost.isEmpty()) {
@@ -2252,7 +2251,7 @@ private static double wordsWidthOf(com.demcha.compose.document.layout.PlacedFrag
* half-point size: one line, with no prefix it does not write and at the size the page fits
* its text to.
*/
- private static boolean widthKept(DocumentNode part, com.demcha.compose.document.layout.PlacedFragment fragment) {
+ private boolean widthKept(DocumentNode part, com.demcha.compose.document.layout.PlacedFragment fragment) {
List lines =
((com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload) fragment.payload()).lines();
return lines.size() == 1 && !(part instanceof ParagraphNode paragraph
@@ -7665,16 +7664,16 @@ private boolean laysOutAPrefix(ParagraphNode node) {
* What an auto-sized paragraph's text loses of its size, read off what was written: the size
* the file holds it at, to Word's half point, saying the one the page fits it to is not
* measured, where its text in its style was not written at the fitted size
- * ({@link #fittedWritten}) — its lines are not read, or are in sizes that do not say which is
- * the paragraph's. {@code null} where it was, or nothing laid out takes the paragraph's style
- * — a run with a style of its own keeps its size on the page.
+ * ({@link #fittedWritten}) — its lines are not read, are in sizes that do not say which is
+ * the paragraph's, or are one place's of several ({@link #fittedStyle}). {@code null} where it
+ * was, or nothing laid out takes the paragraph's style — a run with a style of its own keeps
+ * its size on the page.
*
* @param lines the lines the page laid the paragraph out in, empty where they are not read
*/
private String autoSizeLost(ParagraphNode node, List lines,
String whose) {
- if (node.autoSize() == null || fittedWritten.contains(node)
- || !holdsTextInTheParagraphsStyle(node) && !laysOutAPrefix(node, lines)) {
+ if (node.autoSize() == null || fittedWritten.contains(node) || !laysOutInItsStyle(node, lines)) {
return null;
}
return whose + " text is written at " + pointsOf(wordsSize(node.textStyle().size()))
@@ -7683,16 +7682,26 @@ private String autoSizeLost(ParagraphNode node, List lines) {
- return node.autoSize() != null && (holdsTextInTheParagraphsStyle(node) || laysOutAPrefix(node, lines))
- && Double.isNaN(fittedSize(node, lines));
+ private boolean fittedSizeUntold(ParagraphNode node,
+ List lines) {
+ return node.autoSize() != null && laysOutInItsStyle(node, lines) && fittedStyle(node, lines) == null;
+ }
+
+ /**
+ * Whether the page lays out anything of a paragraph in the paragraph's own style, the size an
+ * auto-sized one is fitted to: its plain text or a run with no style of its own, or the prefix
+ * it sets before its lines.
+ *
+ * @param lines the lines the page laid the paragraph out in, empty where they are not read
+ */
+ private static boolean laysOutInItsStyle(ParagraphNode node,
+ List lines) {
+ return holdsTextInTheParagraphsStyle(node) || laysOutAPrefix(node, lines);
}
/**
@@ -7721,7 +7730,7 @@ private static boolean fittedSizeUntold(ParagraphNode node,
*
* @param lines the lines the page laid the paragraph out in, empty where they are not read
*/
- private static List markdownPieces(ParagraphNode node,
+ private List markdownPieces(ParagraphNode node,
List lines) {
if (!DocxMarkdown.mayRead(node)) {
return null;
@@ -7858,10 +7867,7 @@ private static String marksDropped(String whose, List line.text().isBlank())) {
return null;
}
- int laidOut = -prefixMarks;
- for (com.demcha.compose.document.layout.payloads.ParagraphLine line : lines) {
- laidOut += markdownMarksIn(line.text());
- }
+ int laidOut = markdownMarksIn(lines) - prefixMarks;
return laidOut < authored
? whose + " markdown marks are written as letters, where the page sets the text they mark and drops them"
: null;
@@ -7877,6 +7883,15 @@ private static boolean parserDropsAMark(String text) {
&& markdownMarksIn(DocxMarkdown.text(DocxMarkdown.read(text, DocumentTextStyle.DEFAULT))) < markdownMarksIn(text);
}
+ /** The markdown marks the page's lines hold, a prefix's among them. */
+ private static int markdownMarksIn(List lines) {
+ int marks = 0;
+ for (com.demcha.compose.document.layout.payloads.ParagraphLine line : lines) {
+ marks += markdownMarksIn(line.text());
+ }
+ return marks;
+ }
+
private static int markdownMarksIn(String text) {
int marks = 0;
for (int index = 0; index < text.length(); index++) {
@@ -7973,11 +7988,7 @@ private static double fittedSize(ParagraphNode node, List lines) {
+ private DocumentTextStyle fittedStyle(ParagraphNode node,
+ List lines) {
DocumentTextStyle style = node.textStyle();
- if (node.autoSize() == null || style == null) {
- return style;
+ if (node.autoSize() == null || style == null || layout.placedMoreThanOnce(node)) {
+ return null;
}
double fitted = fittedSize(node, lines);
- return Double.isNaN(fitted) ? style : style.withSize(fitted);
+ return Double.isNaN(fitted) ? null : style.withSize(fitted);
+ }
+
+ /**
+ * The style a paragraph's text is written in where it takes the paragraph's: an auto-sized
+ * paragraph's at the size the page fits it to, where its lines tell it ({@link #fittedStyle});
+ * its own otherwise — an auto-sized one's then named ({@link #autoSizeLost}). A run with a
+ * style of its own keeps it, on the page and in the file.
+ *
+ * @param lines the lines the page laid the paragraph out in, empty where they are not read
+ */
+ private DocumentTextStyle writtenStyle(ParagraphNode node,
+ List lines) {
+ DocumentTextStyle fitted = fittedStyle(node, lines);
+ return fitted != null ? fitted : node.textStyle();
}
/**
@@ -8682,8 +8707,9 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean
java.util.Optional line = layout.firstLine(node);
boolean wroteARun = false;
PictureReach pictures = PictureReach.NONE;
- DocumentTextStyle written = writtenStyle(node, lines);
- if (written != node.textStyle()) {
+ DocumentTextStyle fitted = fittedStyle(node, lines);
+ DocumentTextStyle written = fitted != null ? fitted : node.textStyle();
+ if (fitted != null) {
fittedWritten.add(node);
}
// The mark closes the last line, so it is sized as the text that ends it.
@@ -8771,7 +8797,7 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean
}
styleTheMark(para, markStyle);
// A paragraph the layout laid out none of — composed in a table cell, a page zone's, or
- // one of an export without a layout — has its style's line.
+ // one of an export without a layout — has the line of the style its text is written in.
double pageLine = pictures.pageLine() > 0 ? pictures.pageLine()
: pictures.pageLine() == 0 ? styleLineHeight(written) : 0;
if (pageLine > 0 && pictures.reach() >= pageLine - PICTURE_FILLS_ITS_LINE) {
@@ -10116,8 +10142,9 @@ private static String badgeTextOf(ParagraphNode paragraph) {
private String badgeParagraphXml(XWPFDocument document, ParagraphNode paragraph) {
XWPFParagraph para = detachedParagraph(document);
para.setAlignment(ParagraphAlignment.CENTER);
- DocumentTextStyle style = writtenStyle(paragraph, layout.lines(paragraph));
- if (style != paragraph.textStyle()) {
+ DocumentTextStyle fitted = fittedStyle(paragraph, layout.lines(paragraph));
+ DocumentTextStyle style = fitted != null ? fitted : paragraph.textStyle();
+ if (fitted != null) {
fittedWritten.add(paragraph);
}
String text = badgeTextOf(paragraph);
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java
index 8441465b9..15e4f7e65 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java
@@ -13,8 +13,11 @@
import com.demcha.compose.document.node.RowNode;
import com.demcha.compose.document.style.DocumentTextStyle;
+import java.util.Collections;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Objects;
+import java.util.Set;
/**
* The parts of a page zone's line, as a Word header or footer writes them: what they are, what
@@ -57,6 +60,27 @@ static boolean readAlike(DocumentNode written, DocumentNode drawn) {
return true;
}
+ /**
+ * The parts of content written that content built for a page does not read as, one by one
+ * ({@link #readAlike}): every part where the two hold another number of parts, or the page's
+ * is not known. A part read alike was laid out on that page as it is written.
+ *
+ * @param written the content as written
+ * @param drawn the content as built for the page, or {@code null} where it is not known
+ * @return the parts read otherwise, by identity; none where the two read alike
+ */
+ static Set partsReadOtherwise(DocumentNode written, DocumentNode drawn) {
+ Set otherwise = Collections.newSetFromMap(new IdentityHashMap<>());
+ List parts = of(written);
+ List others = drawn == null ? List.of() : of(drawn);
+ for (int index = 0; index < parts.size(); index++) {
+ if (parts.size() != others.size() || !partAlike(parts.get(index), others.get(index))) {
+ otherwise.add(parts.get(index));
+ }
+ }
+ return otherwise;
+ }
+
private static boolean partAlike(DocumentNode part, DocumentNode other) {
if (part instanceof ParagraphNode paragraph) {
return other instanceof ParagraphNode drawn
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAutoSizeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAutoSizeTest.java
index c12f78719..9671e3cf7 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAutoSizeTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAutoSizeTest.java
@@ -12,6 +12,7 @@
import com.demcha.compose.document.layout.payloads.ParagraphTextSpan;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.node.ParagraphNode;
import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.style.ClipPolicy;
import com.demcha.compose.document.style.DocumentColor;
@@ -43,8 +44,8 @@
* An auto-sized paragraph's text is written at the size the page fits it to, smaller or larger
* than its style's, on every path that writes a paragraph: the file holds what the page draws.
* A run with a style of its own keeps it, as on the page. Where the page's lines do not tell the
- * size — no lines read, or lines in sizes that do not say which is the paragraph's — the text is
- * written at its style's size, and named.
+ * size — no lines read, lines in sizes that do not say which is the paragraph's, or one place's of
+ * the several a paragraph is added at — the text is written at its style's size, and named.
*/
class DocxAutoSizeTest {
@@ -71,9 +72,35 @@ void aParagraphIsWrittenAtTheSizeThePageFitsItTo() throws Exception {
assertThat(grown.firstSize("Hi")).isEqualTo(24.0);
assertThat(grown.paragraphWith("Hi").getRuns()).singleElement()
.satisfies(run -> assertThat(run.getFontSizeAsDouble()).isEqualTo(24.0));
+ assertThat(markSize(grown.paragraphWith("Hi"))).isEqualTo(24.0);
assertThat(grown.notes()).isEmpty();
}
+ @Test
+ void textThePageLaysOutWithEveryMarkIsWrittenAtTheOneSizeItsLinesHold() throws Exception {
+ // A session that reads no markdown lays the marks out, in the one size it fits the text to.
+ Export authored = export(false, page -> page.addParagraph(p -> p.name("Draft")
+ .text("**Draft** status of the report for the quarter").textStyle(DocumentTextStyle.DEFAULT.withSize(24))
+ .autoSize(24, 6)));
+ double fitted = authored.firstSize("Draft");
+ assertThat(fitted).isLessThan(24);
+ assertThat(authored.paragraphWith("Draft").getRuns()).singleElement().satisfies(run -> {
+ assertThat(run.text()).isEqualTo("**Draft** status of the report for the quarter");
+ assertThat(run.getFontSizeAsDouble()).isEqualTo(wordsSize(fitted));
+ });
+ assertThat(authored.notes()).isEmpty();
+
+ // So does one that reads it, where the parser keeps the mark: an underscore inside a word, in
+ // Arabic, whose letters the page shapes before it reads the marks.
+ Export arabic = export(page -> page.addParagraph(HEADLINE + " " + HEADLINE).addParagraph(p -> p.name("Kept")
+ .text("مرحبا_بالعالم").textStyle(DocumentTextStyle.builder().fontName(FontName.AMIRI).size(10).build())
+ .autoSize(20, 6)));
+ double kept = arabic.firstSize("Kept");
+ assertThat(kept).isNotEqualTo(10.0);
+ assertThat(sizesOf(arabic.document(), "مرحبا_بالعالم")).containsOnly(halfPoints(kept));
+ assertThat(arabic.notes()).isEmpty();
+ }
+
@Test
void aRunWithAStyleOfItsOwnKeepsItsSizeAndOneWithNoneTakesTheFittedSize() throws Exception {
Export export = export(page -> page.addParagraph(p -> p.name("Greeting").textStyle(TEN)
@@ -142,7 +169,8 @@ void markdownPiecesAreWrittenAtTheSizeThePageFitsThemTo() throws Exception {
assertThat(cell.notes()).isEmpty();
// A heading at a multiple of a fitted size off Word's half point is named at the size the
- // file holds it at.
+ // file holds it at. Fitted on a grid of whole points from 24.5, the size is a half point,
+ // and the second level's heading, half as large again, a quarter off one.
Export heading = export(page -> page.addParagraph(p -> p.name("Second")
.text("## A second level heading far too long_x").textStyle(DocumentTextStyle.DEFAULT.withSize(24))
.autoSize(new com.demcha.compose.document.style.DocumentTextAutoSize(24.5, 4.5, 1.0))));
@@ -196,7 +224,7 @@ void theOtherPathsThatWriteAParagraphWriteItAtTheSizeThePageFitsItTo() throws Ex
.build()).addParagraph("Masthead"));
assertThat(box.firstSize("Monogram")).isEqualTo(30.0);
assertThat(box.xml()).contains("");
- assertThat(sizesOf(box.document(), "LM")).isNotEmpty().containsOnly(30.0);
+ assertThat(sizesOf(box.document(), "LM")).isNotEmpty().containsOnly(halfPoints(30));
assertThat(box.notes()).as("the box's own note, and nothing of the size").isNotEmpty()
.noneMatch(note -> note.contains("is written at"));
@@ -205,13 +233,13 @@ void theOtherPathsThatWriteAParagraphWriteItAtTheSizeThePageFitsItTo() throws Ex
double initials = badge.firstSize("Initials");
assertThat(initials).isGreaterThan(8);
assertThat(badge.xml()).contains("");
- assertThat(sizesOf(badge.document(), "JR")).isNotEmpty().containsOnly(wordsSize(initials));
+ assertThat(sizesOf(badge.document(), "JR")).isNotEmpty().containsOnly(halfPoints(initials));
assertThat(badge.notes()).isEmpty();
// Initials the page reads as markdown, in one face at the fitted size, stay in the shape.
Export marked = export(page -> page.add(badge("*JR*")));
assertThat(marked.xml()).contains("").doesNotContain("*JR*");
assertThat(italicOf(marked.document(), "JR")).isNotEmpty().containsOnly(true);
- assertThat(sizesOf(marked.document(), "JR")).isNotEmpty().containsOnly(wordsSize(marked.firstSize("Initials")));
+ assertThat(sizesOf(marked.document(), "JR")).isNotEmpty().containsOnly(halfPoints(marked.firstSize("Initials")));
assertThat(marked.notes()).isEmpty();
}
@@ -244,13 +272,23 @@ void whereItsLinesDoNotTellTheSizeTheTextIsWrittenAtItsStylesAndNamed() throws E
.addParagraph(p -> p.text(text).textStyle(amiri).autoSize(20, 6)));
assertThat(arabic.notes()).as(text).singleElement().asString()
.startsWith("written as a paragraph; its text is written at 10pt — " + UNMEASURED);
- assertThat(sizesOf(arabic.document(), text.replace("\n", ""))).as(text).containsOnly(10.0);
+ assertThat(sizesOf(arabic.document(), text.replace("\n", ""))).as(text).containsOnly(halfPoints(10));
}
+ // One paragraph added at two places is fitted at each apart; its lines are one place's.
+ ParagraphNode shared = new ParagraphBuilder().name("Shared").text("Shared heading text here").textStyle(TEN)
+ .autoSize(30, 6).build();
+ Export twice = export(page -> page.addParagraph(HEADLINE + " " + HEADLINE).add(shared)
+ .add(new TableBuilder().name("Narrow").columns(DocumentTableColumn.fixed(60))
+ .rowCells(DocumentTableCell.node(shared)).build()));
+ assertThat(sizesOf(twice.document(), "Shared heading text here")).hasSize(2).containsOnly(halfPoints(10));
+ assertThat(twice.notes()).hasSize(2)
+ .allSatisfy(note -> assertThat(note).endsWith("its text is written at 10pt — " + UNMEASURED));
+
// With no layout, nothing tells it.
Consumer unlaid = page -> page.addParagraph(p -> p.text(HEADLINE + " " + HEADLINE).textStyle(TEN))
.addParagraph(p -> p.text("Hi").textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6));
- assertThat(sizesOf(DocxExports.withoutLayout(240, 600, 30, unlaid), "Hi")).containsExactly(24.0);
+ assertThat(sizesOf(DocxExports.withoutLayout(240, 600, 30, unlaid), "Hi")).containsExactly(halfPoints(24));
assertThat(DocxExports.reportWithoutLayout(240, 600, 30, unlaid).bySubject().get("ParagraphNode"))
.extracting(DocxExportReport.Note::detail)
.containsExactly("written as a paragraph; its text is written at 24pt — " + UNMEASURED);
@@ -279,17 +317,25 @@ private static List runsReading(XWPFDocument document, String text) {
return runs;
}
- /** The sizes the runs reading a text are written at, wherever in the body they stand. */
- private static List sizesOf(XWPFDocument document, String text) {
- List sizes = new ArrayList<>();
+ /**
+ * The sizes the runs reading a text are written at, wherever in the body they stand, as the
+ * file states them: in Word's half points ({@link #halfPoints}).
+ */
+ private static List sizesOf(XWPFDocument document, String text) {
+ List sizes = new ArrayList<>();
for (XmlObject run : runsReading(document, text)) {
for (XmlObject size : run.selectPath(W + "./w:rPr/w:sz/@w:val")) {
- sizes.add(Double.parseDouble(((SimpleValue) size).getStringValue()) / 2);
+ sizes.add(((SimpleValue) size).getStringValue());
}
}
return sizes;
}
+ /** A size in points as Word states it, in half points to the nearest. */
+ private static String halfPoints(double size) {
+ return Long.toString(Math.round(size * 2));
+ }
+
/** Whether each run reading a text, wherever in the body it stands, is written italic. */
private static List italicOf(XWPFDocument document, String text) {
return runsReading(document, text).stream().map(run -> run.selectPath(W + "./w:rPr/w:i").length > 0).toList();
@@ -334,10 +380,15 @@ private static java.util.stream.Stream spans(java.util.stream
}
private static Export export(Consumer content) throws Exception {
+ return export(true, content);
+ }
+
+ private static Export export(boolean markdown, Consumer content) throws Exception {
AtomicReference report = new AtomicReference<>();
byte[] docx;
Map> fragments;
- try (DocumentSession session = GraphCompose.document().pageSize(240, 600).margin(DocumentInsets.of(30)).create()) {
+ try (DocumentSession session = GraphCompose.document().pageSize(240, 600).margin(DocumentInsets.of(30))
+ .markdown(markdown).create()) {
session.pageFlow(content::accept);
fragments = session.layoutGraph().fragments().stream()
.collect(java.util.stream.Collectors.groupingBy(PlacedFragment::path));
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
index 5354aba26..d1c3096b6 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
@@ -210,9 +210,9 @@ private record Entry(Fate fate, String note) {
+ "pair whose left holds the level",
"padding:WRITTEN", "margin:WRITTEN",
"autoSize:REPORTED:where the layout does not tell the size the page fits the text in the "
- + "paragraph's style to — no lines read, or lines in sizes that do not say which is the "
- + "paragraph's — the text written at its style's, not measured; any other is written, at the size "
- + "the page fits it to",
+ + "paragraph's style to — no lines read, lines in sizes that do not say which is the "
+ + "paragraph's, or one paragraph added at more than one place — the text written at its "
+ + "style's, not measured; any other is written, at the size the page fits it to",
"verticalAlign:WRITTEN", "anchor:WRITTEN", "direction:WRITTEN");
node(PathNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "segments:WRITTEN",
"fillColor:WRITTEN", "fillPaint:REPORTED", "stroke:WRITTEN", "strokePaint:REPORTED",
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
index b400489ff..05cd9d513 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
@@ -49,6 +49,9 @@ void anAutoSizedParagraphsTextIsNamedOnlyWhereItsLinesDoNotTellTheSizeThePageFit
assertThat(firstSpan(shrunk).textStyle().size()).as("the page fits it smaller").isLessThan(24);
assertThat(paragraphNotes(shrunk)).isEmpty();
assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Hi").textStyle(TEN).autoSize(24)))).isEmpty();
+ // So is one fitted to its own style's size.
+ assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Hi")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6)))).isEmpty();
// So is a prefix the page sets in the paragraph's style, where every run keeps its own.
assertThat(paragraphNotes(page -> page.addParagraph(p -> p
.textStyle(DocumentTextStyle.DEFAULT.withSize(24)).inlineText(HEADLINE, TEN)
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
index 27d09e9ef..ee35db467 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
@@ -256,6 +256,27 @@ void aPartThePageSetsOtherwiseOnItsFirstPageTakesNoSizeFromIt() throws Exception
+ "the page fits it to is not measured");
}
+ @Test
+ void aPartThePageSetsAsWrittenKeepsItsLinesBesideOneItSetsOtherwise() throws Exception {
+ // Beside a part the page sets with other text on page 1, a part it sets as written there
+ // is still read from its own lines: its markdown written as the page sets it.
+ Exported exported = export(session -> session.chrome().zone(DocumentPageZone.footer(40,
+ page -> new RowBuilder().name("Line")
+ .addParagraph(p -> p.text("**Acme**").textStyle(CHROME))
+ .addParagraph(p -> p.text(page.isLast() ? "End" : "Continued on the next page of this report")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(10)).autoSize(14, 6))
+ .build())), true);
+
+ assertThat(exported.footerLine().getRuns()).filteredOn(run -> "Acme".equals(run.text())).singleElement()
+ .satisfies(run -> assertThat(run.isBold()).isTrue());
+ assertThat(exported.footerLine().getText()).doesNotContain("*");
+ assertThat(exported.footerLine().getRuns()).filteredOn(run -> "End".equals(run.text())).singleElement()
+ .satisfies(run -> assertThat(run.getFontSizeAsDouble()).isEqualTo(10.0));
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .singleElement().asString().doesNotContain("markdown")
+ .endsWith("a paragraph's text is written at 10pt — the size the page fits it to is not measured");
+ }
+
@Test
void aPartWhoseLinesDoNotTellTheSizeThePageFitsItToIsGivenItsStylesLine() throws Exception {
// Fitted to 12pt, the size its first run has of its own: the lines hold 12 and 10, each a
@@ -624,7 +645,10 @@ XWPFParagraph footerLine() {
return document.getFooterList().get(0).getParagraphs().get(0);
}
- /** The size the page sets a word's first letter in, on the first page it draws it: its font's, unscaled. */
+ /**
+ * The size the page sets a word's first letter in, on the first page it draws it: its font's,
+ * unscaled — {@code getFontSizeInPt} rounds to a whole point.
+ */
double size(String word) {
return text.get(firstLetterOf(word)).getFontSize();
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePartsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePartsTest.java
index e0d9a02db..74af7232e 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePartsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePartsTest.java
@@ -57,6 +57,26 @@ void anotherFaceSizeOrFieldDoesNotReadAlike() {
.flexSpacer().build())).as("another count of parts").isFalse();
}
+ @Test
+ void thePartsReadOtherwiseAreNamedOneByOne() {
+ DocumentNode written = new RowBuilder().name("Line")
+ .addParagraph(p -> p.text("Acme").textStyle(style(8)))
+ .addParagraph(p -> p.text("End").textStyle(style(8)))
+ .build();
+ DocumentNode drawn = new RowBuilder().name("Line")
+ .addParagraph(p -> p.text("Acme").textStyle(style(8)))
+ .addParagraph(p -> p.text("Continued").textStyle(style(8)))
+ .build();
+
+ assertThat(DocxZoneParts.partsReadOtherwise(written, drawn)).as("the second part alone")
+ .containsExactly(written.children().get(1));
+ assertThat(DocxZoneParts.partsReadOtherwise(written, written)).as("read alike").isEmpty();
+ assertThat(DocxZoneParts.partsReadOtherwise(written, null)).as("nothing built")
+ .containsExactlyInAnyOrderElementsOf(written.children());
+ assertThat(DocxZoneParts.partsReadOtherwise(written, paragraph("Acme", style(8))))
+ .as("another count of parts").containsExactlyInAnyOrderElementsOf(written.children());
+ }
+
@Test
void aRunsFaceOrAPicturesSizeOrPlaceMakesItReadOtherwise() {
assertThat(DocxZoneParts.readAlike(withPicture(24, InlineImageAlignment.BASELINE, style(8)),