From 3c6c722e1aa6d1e3caf3de9758744a576b7f9363 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 22 Sep 2026 14:54:52 +0100 Subject: [PATCH 1/2] feat(render-docx): give a paragraph the heading role the document declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A twenty-page export opened as one flat run of paragraphs: nothing in the Navigation Pane, nothing in the outline view, and a generated table of contents that came back empty. Word builds all three from heading styles, and the export wrote none. The outline level a document states when it declares a bookmark now becomes Word's own HeadingN style. The style carries the outline level and nothing else — the paragraph already has the look its author gave it, and a heading style that also set a font and a size would restyle every heading on the way out. Word knows its built-in headings by the pair HeadingN and "heading N", so both are written; with one of them it is a custom style that happens to be called Heading and the Navigation Pane stays empty. Only the levels a document uses are defined, and a level past Word's nine is clamped. The role is read from what the document declared and never inferred from type size: a heading guessed from a large first line turns a subtitle into a chapter and leaves a small real heading as body text. --- CHANGELOG.md | 23 ++- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 107 +++++++++++++ .../semantic/docx/DocxHeadingRoleTest.java | 149 ++++++++++++++++++ 5 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHeadingRoleTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 80838205d..b38bf383e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **An exported Word document has an outline.** A reader opened a twenty-page export and + found one flat run of paragraphs: nothing in the Navigation Pane, nothing in the outline + view, and a generated table of contents that came back empty. Word builds all three from + heading *styles*, and the export wrote none. The outline level a document states when it + declares a bookmark now becomes Word's own `HeadingN` style — verified through Word 16.0's + own heading enumeration, which lists the document's chapters and their nesting. +

+ The style carries the outline level and nothing else. The paragraph already has the look + its author gave it, and a heading style that also set a font and a size would restyle every + heading on the way out — describing the page is the job, not redesigning it. Word knows its + built-in headings by the pair `HeadingN` and `heading N`, so both are written: with one of + them it is a custom style that happens to be called Heading and the Navigation Pane stays + empty. Only the levels a document uses are defined, and a level past Word's nine is clamped + rather than written as a style that does not exist. +

+ The role is read from what the document declared and never inferred from type size. A + heading guessed from a large first line turns a subtitle into a chapter and leaves a small + real heading as body text, and both are wrong in a document someone then edits. + - **A link in the document is a link in the exported Word file.** Every one was dropped: a reader opened an export, found the text of a link with nothing behind it, and a reference to another section that went nowhere — which is most of the point of handing a document @@ -26,9 +45,7 @@ follow semantic versioning; release dates are ISO 8601. neither side resolves anything at write time.

Measured by opening the export in Word 16.0: its own PDF carries two live links, one to - the address and one jumping within the document. What is **not** written is the outline — - `bookmarkOptions` builds a PDF outline tree, and Word's Navigation Pane comes from heading - styles, so promoting an anchored paragraph to a heading would restyle the document. + the address and one jumping within the document. - **A composed table cell carries whatever it was built from.** `DocumentTableCell.node(...)` lets a cell hold anything the document can hold, and the export wrote paragraphs out of it diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 202a31a79..a0f01a7cf 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -76,7 +76,7 @@ Payload records live in `core` under | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ omitted: the rail is resolved fixed-layout geometry and `DocxSemanticBackend` consumes the semantic tree, never a `LayoutGraph`. A timeline's **content** exports in full — entries, titles, meta and bodies — and the export does not throw; only the drawn line is absent. Marker shapes are geometry and may be omitted for the same reason | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | | Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks | -| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ❌ the outline is not written. Word builds its Navigation Pane from heading styles, and promoting an anchored paragraph to a heading would restyle the document; a `linkTo(...)` anchor navigates instead — see the DOCX recipe | +| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ✅ `DocxSemanticBackend` — the stated outline level becomes Word's own `HeadingN` style, so the Navigation Pane, the outline view and a generated table of contents all see the document's structure. The style carries the outline level and no formatting, so the paragraph keeps the look its author gave it; only the levels the document uses are defined, and one past Word's nine is clamped. The role is never inferred from type size | | Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ❌ | | Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | ✅ `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline, strikethrough to `w:strike`) | | Writing direction — right-to-left paragraphs (`ParagraphBuilder.direction`, `TextDirection`) | ✅ `ParagraphWrapping` resolves the line with the Unicode Bidirectional Algorithm and `PdfParagraphFragmentRenderHandler` draws it reordered — the page is painted, so the engine owns the order | ⚠️ `PptxParagraphFragmentRenderHandler` — a right-to-left line goes through **per-span absolute frames** rather than one flowing frame, each pinned where the layout put it, because a shared frame lets PowerPoint re-flow the runs and undo the resolved order. Every frame this handler emits — plain span and chip text alike — declares its direction (`rtl`), which is what puts a neutral on the correct side. A table cell declares it too, through the overload of `PptxTextFrames.singleRunBox` that takes a direction. A header/footer and a watermark still take the overload that declares nothing, so right-to-left text there shows the original defect. The deviation is that the line is not one editable paragraph, and that the text a reader copies out carries mirrored punctuation (see the mirroring row) | ✅ `DocxSemanticBackend.applyParagraphProperties` writes `w:bidi` (resolving `AUTO` through the same `ParagraphDirection` the page used) and hands Word logical text for its own bidi engine, which orders and joins it. Every run of that paragraph also carries `w:rtl`: `w:bidi` settles which edge the line starts from, `w:rtl` settles how Word resolves the characters inside a run, and a run without it is handled as Latin — measured in Word, `(2026)` closing an Arabic line was drawn as `)2026(` with `w:bidi` alone. Hebrew was unaffected, so the defect needed Arabic, where digits after a letter resolve as an Arabic number. Alignment is mapped through the direction, because Word reads `w:jc`'s left/right as start/end **relative to the paragraph** — written physically, a flush-right right-to-left paragraph came out flush left. Size and weight are written to the complex-script twins (`w:szCs`, `w:bCs`, `w:iCs`) as well as the Latin ones, since Word takes Hebrew and Arabic from those. Column order in a right-to-left table is not mirrored: `w:tblPr/w:bidiVisual` is unwritten | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 9c3040809..2d8bba293 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -46,7 +46,7 @@ PDF never pull POI. | Tables | Word tables, one cell per cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" | | 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, and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | | Images | Embedded pictures at the node's declared size | -| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires. A `bookmarkOptions` outline entry is **not** written: Word's Navigation Pane comes from heading styles, and promoting a paragraph to a heading would restyle the document | +| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | | Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin (cell content limited to atomic children) | | Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below | | Spacers | Empty paragraphs carrying the vertical gap as spacing-after | 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 b74059e9a..17f73513a 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 @@ -16,6 +16,7 @@ import com.demcha.compose.document.node.ContainerNode; import com.demcha.compose.document.output.DocumentMetadata; import com.demcha.compose.document.output.DocumentOutputOptions; +import com.demcha.compose.document.node.DocumentBookmarkOptions; import com.demcha.compose.document.node.DocumentLinkTarget; import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.ExternalLinkTarget; @@ -75,6 +76,7 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLvl; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPBdr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTString; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyles; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STStyleType; @@ -192,6 +194,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // The Word names this export gave the document's anchors, so a link and the bookmark // it points at agree. private DocxBookmarkNames bookmarkNames = new DocxBookmarkNames(); + // The outline levels this document asks for, so the styles part defines those and no + // others. Filled before the styles part is written, which comes before the body. + private java.util.Set headingLevels = java.util.Set.of(); // Where the finished report goes, when the caller configured somewhere for it to go. private final java.util.function.Consumer reportSink; @@ -271,6 +276,7 @@ public byte[] export(DocumentGraph graph, SemanticExportContext context) throws listNumbering.clear(); report = new DocxExportReport.Builder(); bookmarkNames = new DocxBookmarkNames(); + headingLevels = headingLevelsIn(graph); wordFamilies = DocxFontTable.familiesByName(context.customFontFamilies()); documentDefaultStyle = dominantTextStyle(graph); layout = DocxLayoutMetrics.of(graph, context.layoutGraph()); @@ -966,9 +972,40 @@ private void writeStylesPart(XWPFDocument document) { normal.addNewName().setVal(NORMAL_STYLE_ID); applyDefaultRunProperties(normal.addNewRPr(), defaults); + headingLevels.stream().sorted().forEach(level -> writeHeadingStyle(styles, level)); + document.createStyles().setStyles(styles); } + /** + * Defines one of Word's heading styles, as a role and nothing else. + * + *

The style carries an outline level and no formatting at all. That is the point: a + * heading in this export is a statement about structure, made by the document + * when it asked for an outline entry, and the paragraph already carries the look its + * author gave it. A heading style that also set a font and a size would restyle every + * heading in the document on the way out — the export would be redesigning the page + * rather than describing it.

+ * + *

Word recognises its built-in headings by the pair: the id {@code HeadingN} and the + * name {@code heading N}. Written with only one of them, the style is a custom style + * that happens to be called Heading, the Navigation Pane stays empty, and "promote to + * heading 2" in Word does something else.

+ * + * @param styles the styles part being built + * @param level the zero-based outline level, as the document states it + */ + private static void writeHeadingStyle(CTStyles styles, int level) { + int ordinal = level + 1; + CTStyle heading = styles.addNewStyle(); + heading.setType(STStyleType.PARAGRAPH); + heading.setStyleId("Heading" + ordinal); + heading.addNewName().setVal("heading " + ordinal); + heading.addNewBasedOn().setVal(NORMAL_STYLE_ID); + heading.addNewQFormat(); + heading.addNewPPr().addNewOutlineLvl().setVal(BigInteger.valueOf(level)); + } + private void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defaults) { if (defaults.fontName() != null) { // All four slots, exactly as XWPFRun.setFontFamily writes them on a run. @@ -1026,6 +1063,75 @@ private String wordFamilyOf(FontName fontName) { return definition == null ? family.name() : definition.wordFamily(); } + /** Word has nine heading levels; a document asking for a tenth is clamped to the ninth. */ + private static final int MAX_HEADING_LEVEL = 8; + + /** + * Gives a paragraph the heading role the document asked for, and only the role. + * + *

Word builds its Navigation Pane, its table of contents and its outline view from + * heading styles, not from bookmarks — so an export that wrote a bookmark and + * stopped there produced a document that could be jumped to by name and had no + * structure at all to move around in.

+ * + *

The role comes from the outline level the document stated when it declared the + * bookmark. It is never inferred from how the paragraph looks: a heading guessed from + * font size turns a large first line into a chapter and a small real heading into body + * text, and both are wrong in a document a person then edits.

+ * + *

The paragraph keeps its own formatting. The style it points at carries an outline + * level and nothing else, so what changes is what Word knows about the paragraph, not + * how it is drawn.

+ */ + private void applyHeadingRole(XWPFParagraph para, ParagraphNode node) { + Integer level = headingLevelOf(node); + if (level == null) { + return; + } + CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr(); + CTString style = properties.isSetPStyle() ? properties.getPStyle() : properties.addNewPStyle(); + style.setVal("Heading" + (level + 1)); + } + + /** + * The outline levels this document actually asks for. + * + *

Collected before the styles part is written, because that part comes first in the + * package and a style a paragraph refers to has to exist. Only the levels in use are + * defined: nine heading styles in a document with two headings is nine entries in + * Word's style gallery that nothing in the document uses.

+ * + *

A heading is read from what the document states — the outline level it + * asked for when it declared a bookmark — and never inferred from how big the text is. + * A large paragraph is a large paragraph; a document that never asked for an outline + * does not get one invented from its typography.

+ */ + private static java.util.Set headingLevelsIn(DocumentGraph graph) { + java.util.Set levels = new java.util.TreeSet<>(); + for (DocumentNode root : graph.roots()) { + collectHeadingLevels(root, levels); + } + return levels; + } + + private static void collectHeadingLevels(DocumentNode node, java.util.Set levels) { + if (node instanceof ParagraphNode paragraph) { + Integer level = headingLevelOf(paragraph); + if (level != null) { + levels.add(level); + } + } + for (DocumentNode child : node.children()) { + collectHeadingLevels(child, levels); + } + } + + /** @return the paragraph's outline level, clamped to Word's nine, or null when it is not a heading */ + private static Integer headingLevelOf(ParagraphNode node) { + DocumentBookmarkOptions bookmark = node.bookmarkOptions(); + return bookmark == null ? null : Math.min(bookmark.level(), MAX_HEADING_LEVEL); + } + /** * The text style the document is mostly written in. * @@ -1275,6 +1381,7 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node) private void writeParagraph(XWPFDocument document, ParagraphNode node) { XWPFParagraph para = newBodyParagraph(document); boolean rightToLeft = applyParagraphProperties(para, node); + applyHeadingRole(para, node); int anchor = openAnchor(para, node.anchor()); writeParagraphRuns(para, node, rightToLeft); closeAnchor(para, anchor); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHeadingRoleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHeadingRoleTest.java new file mode 100644 index 000000000..94b805269 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHeadingRoleTest.java @@ -0,0 +1,149 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.style.DocumentTextStyle; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A heading is a statement about structure, and it comes from the document saying so. + * + *

Word builds its Navigation Pane, its table of contents and its outline view from + * heading styles. An export that wrote a bookmark and stopped there produced a + * document that could be jumped to by name and had no structure to move around in at all — + * a twenty-page report opening as one flat run of paragraphs.

+ * + *

The role is read from the outline level the document stated when it declared the + * bookmark, never from how the paragraph looks. A heading guessed from font size turns a + * large first line into a chapter and a small real heading into body text, and both are + * wrong in a document a person then edits.

+ * + * @author Artem Demchyshyn + */ +class DocxHeadingRoleTest { + + @Test + void aDeclaredOutlineLevelBecomesTheMatchingHeadingStyle() throws Exception { + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Chapter").bookmark(new DocumentBookmarkOptions("Chapter", 0))) + .addParagraph(p -> p.text("Section").bookmark(new DocumentBookmarkOptions("Section", 1))) + .addParagraph(p -> p.text("Body")))) { + + assertThat(styleOf(document, "Chapter")).isEqualTo("Heading1"); + assertThat(styleOf(document, "Section")).isEqualTo("Heading2"); + assertThat(styleOf(document, "Body")) + .as("a paragraph that claimed nothing stays body text") + .isNull(); + } + } + + @Test + void wordRecognisesTheStyleAsItsOwnHeading() throws Exception { + // Word knows its built-in headings by the pair: the id HeadingN and the name + // "heading N". With only one of them the style is a custom style that happens to be + // called Heading, and the Navigation Pane stays empty. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Chapter").bookmark(new DocumentBookmarkOptions("Chapter", 0))))) { + + CTStyle heading = styleById(document, "Heading1"); + assertThat(heading).isNotNull(); + assertThat(heading.getName().getVal()).isEqualTo("heading 1"); + assertThat(heading.getPPr().getOutlineLvl().getVal().intValue()) + .as("the outline level is what puts it in the Navigation Pane") + .isZero(); + } + } + + @Test + void theHeadingStyleCarriesNoFormattingOfItsOwn() throws Exception { + // The paragraph already carries the look its author gave it. A heading style that + // also set a font and a size would restyle every heading on the way out — the + // export would be redesigning the page rather than describing it. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Chapter") + .textStyle(DocumentTextStyle.DEFAULT.withSize(21)) + .bookmark(new DocumentBookmarkOptions("Chapter", 0))))) { + + CTStyle heading = styleById(document, "Heading1"); + assertThat(heading.isSetRPr()).as("no run properties").isFalse(); + assertThat(heading.getPPr().isSetSpacing()).as("no spacing").isFalse(); + assertThat(heading.getBasedOn().getVal()) + .as("everything else is inherited, so Normal still reaches it") + .isEqualTo("Normal"); + } + } + + @Test + void onlyTheLevelsTheDocumentUsesAreDefined() throws Exception { + // Nine heading styles in a document with one heading is eight entries in Word's + // gallery that nothing refers to. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Chapter").bookmark(new DocumentBookmarkOptions("Chapter", 0))))) { + + assertThat(headingIds(document)).containsExactly("Heading1"); + } + } + + @Test + void aLevelPastWordsNineIsClampedRatherThanWrittenAsNothing() throws Exception { + // ST_DecimalNumber would take it, but Word has nine levels and a tenth reads as a + // style that does not exist — which is a paragraph with no role at all. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Deep").bookmark(new DocumentBookmarkOptions("Deep", 12))))) { + + assertThat(styleOf(document, "Deep")).isEqualTo("Heading9"); + assertThat(headingIds(document)).containsExactly("Heading9"); + } + } + + @Test + void sizeAloneNeverMakesAHeading() throws Exception { + // The rule the plan states, pinned: a big paragraph beside a small one claims + // nothing about structure, and the export must not decide otherwise. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("Large") + .textStyle(DocumentTextStyle.DEFAULT.withSize(28))) + .addParagraph(p -> p.text("Small") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9))))) { + + assertThat(styleOf(document, "Large")).isNull(); + assertThat(headingIds(document)).isEmpty(); + } + } + + private static String styleOf(XWPFDocument document, String text) { + XWPFParagraph para = document.getParagraphs().stream() + .filter(p -> text.equals(p.getText())) + .findFirst() + .orElseThrow(() -> new AssertionError("no paragraph reading " + text)); + return para.getCTP().getPPr() == null || !para.getCTP().getPPr().isSetPStyle() + ? null + : para.getCTP().getPPr().getPStyle().getVal(); + } + + private static CTStyle styleById(XWPFDocument document, String id) { + return document.getStyles().getCtStyles().getStyleList().stream() + .filter(style -> id.equals(style.getStyleId())) + .findFirst() + .orElse(null); + } + + private static List headingIds(XWPFDocument document) { + return document.getStyles().getCtStyles().getStyleList().stream() + .map(CTStyle::getStyleId) + .filter(id -> id != null && id.startsWith("Heading")) + .toList(); + } + + private static XWPFDocument exported(Consumer content) throws Exception { + return DocxExports.withLayout(400, 600, 20, content); + } +} From 6ea338c2e15126ccfc055f24a767f2191acaab70 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 22 Sep 2026 15:35:49 +0100 Subject: [PATCH 2/2] docs(docx): the recipe still said the export writes no heading styles --- docs/recipes/docx-export.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 2d8bba293..4f7c1b865 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -134,9 +134,13 @@ restate it stay silent, so changing `Normal` in Word changes the body the way a expects. A run whose font, size or colour differs keeps saying so, so headings, chips and accents are unaffected. -There is one `Normal` and no generated heading styles yet: a heading still carries its -own direct formatting rather than a named `Heading 1`. Restyling the body works; restyling -"all headings" in one go does not. +A paragraph that declared an outline level — `bookmark(new DocumentBookmarkOptions(name, +level))` — also carries Word's own `HeadingN` style, which is what fills the Navigation +Pane, the outline view and a generated table of contents. The style states the outline +level and no formatting, so the paragraph keeps the look its author gave it and "restyle +all headings" in Word still reaches it. Only the levels the document uses are defined, and +a level past Word's nine is clamped. A heading is never inferred from type size: a large +first line claims nothing about structure. ## What a list becomes