From 2b476465d029efd90c1bc3fb096ce47a7aae7eab Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 2 Oct 2026 08:14:18 +0100 Subject: [PATCH 1/2] fix(docx): let a line of pictures alone be as tall as its pictures where they fill the page's line --- CHANGELOG.md | 9 +++ docs/recipes/docx-export.md | 2 +- .../docx-fidelity/word-windows-lines.tsv | 64 ++++++++-------- .../resources/docx-fidelity/word-windows.tsv | 2 +- .../semantic/docx/DocxSemanticBackend.java | 66 +++++++++++++++++ .../semantic/docx/DocxPictureLineTest.java | 73 +++++++++++++++++++ 6 files changed, 182 insertions(+), 34 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b1110dc..1338bac6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **`SlateOrange`'s sidebar stands where the page sets it in Word.** Each skill is a row with a + 12.4pt icon on a line of its own, no text in it, and Word sizes that line itself. The + paragraph's mark, set in the text's 10.5pt, reached below the icon, so each row came out + 0.2pt taller and the tenth 2.3pt low, with the sidebar under them. Where its pictures fill + the page's line, a line of pictures alone now has its mark and its pictures' runs set at a + point; a smaller picture, in a line the page sets at its font's height, keeps the mark as it + was. In Word, lines more than 2pt off fall from 36 to 9, and the p90 drift from 4.99pt to + 3.46. LibreOffice is unchanged. + - **`CobaltRota` stands where the page sets it in Word and LibreOffice.** Its header opens and closes with a row that is only a rule, its thickness an empty cell's line. The page draws the rule's borders across that line; Word and LibreOffice keep them outside it and grew each row diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index de50fd3cc..f5d9ece00 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -176,7 +176,7 @@ it cannot work out for itself: | What | Where it lands | |---|---| -| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | +| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the text's size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps the mark in the text's size. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | | Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them | | Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed | diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv index 49c134963..53e9cbaae 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -1382,38 +1382,38 @@ cv-slate_orange 0:422684be6cf9 0.32 daniel.harper@email.com cv-slate_orange 0:84308bea4540 0.36 linkedin cv-slate_orange 0:a64d6af50109 0.26 austin,texas•opentorelocate cv-slate_orange 0:32c1cf49a2fe 0.04 skills -cv-slate_orange 0:ee41e992b046 3.82 customerserviceexcellence -cv-slate_orange 0:39433b0c8c42 3.95 operations&processsupport -cv-slate_orange 0:68ac2246858d 4.20 project&calendarmanagement -cv-slate_orange 0:d2b0c62842b8 4.48 datamanagement&reporting -cv-slate_orange 0:713ccc3fc2fc 4.61 communication&stakeholders -cv-slate_orange 0:010f2ad55bed 4.86 problemsolving&adaptability -cv-slate_orange 0:02020d943fbc 4.99 sales&relationshipbuilding -cv-slate_orange 0:88cc29c2fda2 5.24 microsoftoffice&googlesuite -cv-slate_orange 0:e37d86244e99 5.37 crmsystems(salesforce,hubspot) -cv-slate_orange 0:d70e5edb4685 5.62 timemanagement&prioritization -cv-slate_orange 0:8bab53aa1bee 2.10 selectedachievements -cv-slate_orange 0:b08c44dac044 6.78 processimprovement -cv-slate_orange 0:c7966365f508 3.45 designedanewtrackingsystemthat -cv-slate_orange 0:3a5ad5d0dc8d 2.95 reducedreportingtimeby30%and -cv-slate_orange 0:74821a85e7b9 2.57 improvedaccuracy. -cv-slate_orange 0:dd9532e202a3 6.92 customersatisfaction -cv-slate_orange 0:dfc9a4fd0657 3.59 maintaineda96%customer -cv-slate_orange 0:5e04b5e4dbf9 3.21 satisfactionratingthroughrespons -cv-slate_orange 0:c119f318eaae 2.71 supportandissueresolution. -cv-slate_orange 0:365853229c97 7.18 revenuecontribution -cv-slate_orange 0:292a456730c2 3.85 consistentlyexceededmonthlysales -cv-slate_orange 0:8eb387deb3e1 3.35 goalsby15–20%throughrelationship -cv-slate_orange 0:77706bc9ed09 2.97 buildingandconsultativeservice. -cv-slate_orange 0:6cb574fa10b6 2.78 languages -cv-slate_orange 0:890744623577 3.51 spanishprofessionalworking -cv-slate_orange 0:21bb36ad0638 3.79 french -cv-slate_orange 0:fbb7b5bf4ca3 3.82 basic -cv-slate_orange 0:676dfdd56ad2 3.56 additionalinformation -cv-slate_orange 0:ef68dac1790b 6.91 availability:full-time -cv-slate_orange 0:6795aec0e50a 7.13 willingtorelocate:yes -cv-slate_orange 0:b8eeb230ca32 4.48 remotework:opento -cv-slate_orange 0:b73078ca247c 4.42 hybrid/remote +cv-slate_orange 0:ee41e992b046 0.58 customerserviceexcellence +cv-slate_orange 0:39433b0c8c42 0.59 operations&processsupport +cv-slate_orange 0:68ac2246858d 0.60 project&calendarmanagement +cv-slate_orange 0:d2b0c62842b8 0.64 datamanagement&reporting +cv-slate_orange 0:713ccc3fc2fc 0.53 communication&stakeholders +cv-slate_orange 0:010f2ad55bed 0.54 problemsolving&adaptability +cv-slate_orange 0:02020d943fbc 0.55 sales&relationshipbuilding +cv-slate_orange 0:88cc29c2fda2 0.56 microsoftoffice&googlesuite +cv-slate_orange 0:e37d86244e99 0.57 crmsystems(salesforce,hubspot) +cv-slate_orange 0:d70e5edb4685 0.58 timemanagement&prioritization +cv-slate_orange 0:8bab53aa1bee 0.04 selectedachievements +cv-slate_orange 0:b08c44dac044 4.50 processimprovement +cv-slate_orange 0:c7966365f508 1.29 designedanewtrackingsystemthat +cv-slate_orange 0:3a5ad5d0dc8d 0.79 reducedreportingtimeby30%and +cv-slate_orange 0:74821a85e7b9 0.29 improvedaccuracy. +cv-slate_orange 0:dd9532e202a3 4.52 customersatisfaction +cv-slate_orange 0:dfc9a4fd0657 1.19 maintaineda96%customer +cv-slate_orange 0:5e04b5e4dbf9 0.81 satisfactionratingthroughrespons +cv-slate_orange 0:c119f318eaae 0.31 supportandissueresolution. +cv-slate_orange 0:365853229c97 4.54 revenuecontribution +cv-slate_orange 0:292a456730c2 1.21 consistentlyexceededmonthlysales +cv-slate_orange 0:8eb387deb3e1 0.83 goalsby15–20%throughrelationship +cv-slate_orange 0:77706bc9ed09 0.33 buildingandconsultativeservice. +cv-slate_orange 0:6cb574fa10b6 0.14 languages +cv-slate_orange 0:890744623577 0.99 spanishprofessionalworking +cv-slate_orange 0:21bb36ad0638 1.15 french +cv-slate_orange 0:fbb7b5bf4ca3 1.18 basic +cv-slate_orange 0:676dfdd56ad2 0.92 additionalinformation +cv-slate_orange 0:ef68dac1790b 4.15 availability:full-time +cv-slate_orange 0:6795aec0e50a 4.13 willingtorelocate:yes +cv-slate_orange 0:b8eeb230ca32 1.48 remotework:opento +cv-slate_orange 0:b73078ca247c 1.42 hybrid/remote cv-slate_orange 0:3c69d6bb4892 0.04 professionalprofile cv-slate_orange 0:36eb8f626c42 0.03 adaptableandresourcefulprofessio cv-slate_orange 0:f4e999c08086 0.00 customerservice,sales,andproject diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index 29946974e..5971c4d59 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -21,7 +21,7 @@ cv-panel 1 1 79 73 0.43 0.94 0 cv-professional_sidebar 1 1 68 64 0.11 0.27 0 cv-serif_headline 1 1 103 103 0.51 1.05 0 cv-sidebar_portrait 1 1 49 49 0.08 0.27 0 -cv-slate_orange 1 1 87 85 0.62 4.99 36 +cv-slate_orange 1 1 87 85 0.54 3.46 9 cv-teal_pulse 1 1 57 57 0.11 0.38 0 cv-terracotta_rail 1 1 102 102 0.42 0.87 0 cv-timeline_minimal 2 2 89 86 1.44 1.57 0 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 c871b4f98..71b144f2d 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 @@ -175,6 +175,12 @@ public final class DocxSemanticBackend implements SemanticBackend { private static final double TWIPS_PER_POINT = 20.0; /** {@code w:sz} and {@code w:szCs} count half-points. */ private static final double HALF_POINTS_PER_POINT = 2.0; + + /** + * How far short of the top of the page's line a picture may reach and still fill it, in + * points (see {@link #letThePicturesSetTheLine}). + */ + private static final double PICTURE_FILLS_ITS_LINE = 0.5; private static final double POINT_TO_TWIP = 20.0; /** The least difference between Word's baseline and the page's that is moved: one half point. */ private static final double LEAST_BASELINE_SHIFT_POINTS = 0.5; @@ -396,6 +402,8 @@ public final class DocxSemanticBackend implements SemanticBackend { // letters' reach (see inkOf). private List measuredFamilies = List.of(); private FontLibrary seatFonts; + // Those fonts' line heights, for a line the layout placed none of (see styleLineHeight). + private com.demcha.compose.document.chart.ChartTextMetrics styleMetrics; // What this export could not carry as authored. Collected whether or not anyone asked // for it: building it costs a list, and deciding later that nobody wanted it is not // something the writers can do halfway through. @@ -660,6 +668,7 @@ private byte[] write(List sections, Path outputFile) throws Exc wordFamilies = DocxFontTable.familiesByName(fonts); measuredFamilies = List.copyOf(fonts); seatFonts = null; + styleMetrics = null; documentDefaultStyle = dominantTextStyle(whole); currentCell = null; currentCellWidth = Double.NaN; @@ -5043,6 +5052,19 @@ private FontLibrary measuredFonts() { return seatFonts; } + /** The height the layout gives a line set in a style, in points; 0 for no style. */ + private double styleLineHeight(DocumentTextStyle style) { + if (style == null) { + return 0; + } + if (styleMetrics == null) { + styleMetrics = new com.demcha.compose.document.layout.ChartTextMetricsSupport( + new com.demcha.compose.engine.measurement.FontLibraryTextMeasurementSystem( + measuredFonts(), com.demcha.compose.engine.render.pdf.PdfFont.class)); + } + return styleMetrics.lineHeight(style); + } + /** * The foot of a shape container's content, measured up from the foot of its page, or * {@code NaN} when it is not laid out on one page. Its bottom padding is owed below it @@ -5534,9 +5556,53 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean heldAbove = 0; } styleTheMark(para, markStyle); + // A paragraph composed in a table cell has no lines laid out: its line is its style's. + double pageLine = pictures.pageLine() > 0 ? pictures.pageLine() + : pictures.pageLine() == 0 ? styleLineHeight(node.textStyle()) : 0; + if (pageLine > 0 && pictures.reach() >= pageLine - PICTURE_FILLS_ITS_LINE) { + letThePicturesSetTheLine(para); + } seatInTheLine(para, node, runsBefore, lineTopAbove + heldAbove, cutToTheInk); } + /** + * Lets a line of pictures and no text be as tall as its pictures, where Word sizes the line. + * + *

A line Word sizes itself — no exact or least height written, as one holding only a + * picture is, since held exact its picture would stand where Word's baseline puts it and + * be cut — is its tallest run's height, and the paragraph's mark is a run: its font's depth + * below the baseline went under the picture. {@code SlateOrange}'s skills, a 12.4pt icon + * beside each label, came out 0.2pt taller each in Word, its tenth 2.3pt low and the column + * under them with it. The mark and the picture runs are set at a point, whose depth is a + * fraction of one.

+ * + *

Only where the pictures fill the page's line ({@link #PICTURE_FILLS_ITS_LINE}): there + * the line is the pictures' height on the page too. A smaller picture stands in a line the + * page makes as tall as its font, and that line is the mark's: {@code VioletGrid}'s bullet + * dots, their lines cut to the dots, set every bullet 5.8pt high.

+ */ + private static void letThePicturesSetTheLine(XWPFParagraph para) { + CTPPr properties = para.getCTP().getPPr(); + if (!para.getText().isEmpty() + || properties != null && properties.isSetSpacing() && properties.getSpacing().isSetLineRule() + && properties.getSpacing().getLineRule() != STLineSpacingRule.AUTO) { + return; + } + if (properties == null) { + properties = para.getCTP().addNewPPr(); + } + org.openxmlformats.schemas.wordprocessingml.x2006.main.CTParaRPr mark = + properties.isSetRPr() ? properties.getRPr() : properties.addNewRPr(); + BigInteger point = BigInteger.valueOf(Math.round(HALF_POINTS_PER_POINT)); + (mark.sizeOfSzArray() > 0 ? mark.getSzArray(0) : mark.addNewSz()).setVal(point); + (mark.sizeOfSzCsArray() > 0 ? mark.getSzCsArray(0) : mark.addNewSzCs()).setVal(point); + for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) { + CTRPr runProperties = run.isSetRPr() ? run.getRPr() : run.addNewRPr(); + (runProperties.sizeOfSzArray() > 0 ? runProperties.getSzArray(0) : runProperties.addNewSz()).setVal(point); + (runProperties.sizeOfSzCsArray() > 0 ? runProperties.getSzCsArray(0) : runProperties.addNewSzCs()).setVal(point); + } + } + /** Whether a paragraph laid out a line holding text, which its seat is read from. */ private boolean holdsText(ParagraphNode node) { return layout.lines(node).stream().anyMatch(line -> line.spans().stream().anyMatch( diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineTest.java new file mode 100644 index 000000000..9483c6227 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineTest.java @@ -0,0 +1,73 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.node.InlineImageAlignment; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.svg.SvgIcon; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +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.CTPPr; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A line holding a picture and no text is as tall in Word as its picture, where the picture + * fills the page's line. + * + *

Word sizes such a line itself, and the paragraph's mark is a run in it: the mark's font + * reached below the picture. {@code SlateOrange}'s skills, an icon beside each label, came out + * 0.2pt taller each, and the tenth 2.3pt low.

+ */ +class DocxPictureLineTest { + + private static final SvgIcon ICON = SvgIcon.parse("" + + ""); + + /** Half points of a one-point font. */ + private static final long ONE_POINT = 2; + + @Test + void anIconFillingItsLineSetsTheLineAlone() throws Exception { + // A 12pt icon in a 10pt style: the page's line is the icon's height. + XWPFParagraph icon = iconCell(12, 10); + + assertThat(markSize(icon)).as("the mark's font is a point").isEqualTo(ONE_POINT); + assertThat(DocxTwips.of(icon.getRuns().get(0).getCTR().getRPr().getSzArray(0).getVal())) + .as("so is the picture's run").isEqualTo(ONE_POINT); + } + + @Test + void aSmallIconKeepsTheLineItsFontGivesIt() throws Exception { + // A 4pt dot in a 12pt style: the page's line is the font's, and so is Word's. + XWPFParagraph dot = iconCell(4, 12); + + assertThat(markSize(dot)).isNotEqualTo(ONE_POINT); + } + + private static long markSize(XWPFParagraph paragraph) { + CTPPr properties = paragraph.getCTP().getPPr(); + if (properties == null || !properties.isSetRPr() || properties.getRPr().sizeOfSzArray() == 0) { + return -1; + } + return DocxTwips.of(properties.getRPr().getSzArray(0).getVal()); + } + + private static XWPFParagraph iconCell(double iconSize, double styleSize) throws Exception { + DocumentTableStyle cell = DocumentTableStyle.builder().padding(DocumentInsets.zero()).build(); + ParagraphBuilder icon = new ParagraphBuilder() + .lineSpacing(0) + .textStyle(DocumentTextStyle.DEFAULT.withSize(styleSize)) + .inlineSvgIcon(ICON, iconSize, InlineImageAlignment.CENTER); + try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(20), DocumentTableColumn.fixed(120)) + .rowCells(DocumentTableCell.node(icon.build()).withStyle(cell), + DocumentTableCell.text("Label").withStyle(cell))))) { + return document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0); + } + } +} From 038cd047f6ecac3992f989e584407af6a3bff544 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 2 Oct 2026 08:39:28 +0100 Subject: [PATCH 2/2] fix(docx): read a picture line's text off every run, a link's included, and measure a face no font holds as unknown An internal link's runs are not among the paragraph's own, so getText() missed their text and set it at a point; holdsOnlyPictures reads every run for a letter, break, tab, symbol or field. A face the measured fonts do not hold measures 0 instead of failing an export written without a layout. --- CHANGELOG.md | 11 +-- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 67 ++++++++++++++----- .../semantic/docx/DocxPictureLineTest.java | 39 ++++++++++- 4 files changed, 97 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1338bac6a..01566aaf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,12 @@ follow semantic versioning; release dates are ISO 8601. - **`SlateOrange`'s sidebar stands where the page sets it in Word.** Each skill is a row with a 12.4pt icon on a line of its own, no text in it, and Word sizes that line itself. The - paragraph's mark, set in the text's 10.5pt, reached below the icon, so each row came out - 0.2pt taller and the tenth 2.3pt low, with the sidebar under them. Where its pictures fill - the page's line, a line of pictures alone now has its mark and its pictures' runs set at a - point; a smaller picture, in a line the page sets at its font's height, keeps the mark as it - was. In Word, lines more than 2pt off fall from 36 to 9, and the p90 drift from 4.99pt to + paragraph's mark, set in the icon cell's style at 10.5pt, reached below the icon, so each row + came out 0.2pt taller and the tenth 2.5pt low, with the sidebar under them. Where its + pictures fill the page's line, a line of pictures alone now has its mark and its pictures' + runs set at a point; a smaller picture, in a line the page sets at its font's height, keeps + the mark as it was, and so does a line holding a letter, a break, a tab, a field or a link's + text. In Word, lines more than 2pt off fall from 36 to 9, and the p90 drift from 4.99pt to 3.46. LibreOffice is unchanged. - **`CobaltRota` stands where the page sets it in Word and LibreOffice.** Its header opens and diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index f5d9ece00..c660a63f8 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -176,7 +176,7 @@ it cannot work out for itself: | What | Where it lands | |---|---| -| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the text's size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps the mark in the text's size. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | +| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | | Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them | | Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed | 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 71b144f2d..e9e969214 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 @@ -181,6 +181,9 @@ public final class DocxSemanticBackend implements SemanticBackend { * points (see {@link #letThePicturesSetTheLine}). */ private static final double PICTURE_FILLS_ITS_LINE = 0.5; + + /** A one-point font size, as Word writes sizes: in half points. */ + private static final long ONE_POINT_IN_HALF_POINTS = 2; private static final double POINT_TO_TWIP = 20.0; /** The least difference between Word's baseline and the page's that is moved: one half point. */ private static final double LEAST_BASELINE_SHIFT_POINTS = 0.5; @@ -5052,7 +5055,11 @@ private FontLibrary measuredFonts() { return seatFonts; } - /** The height the layout gives a line set in a style, in points; 0 for no style. */ + /** + * The height the layout gives a line set in a style, in points; 0 for no style, and for a + * face the measured fonts do not hold — an export whose layout failed is written without + * one, and measuring here must not fail it in its stead. + */ private double styleLineHeight(DocumentTextStyle style) { if (style == null) { return 0; @@ -5062,7 +5069,11 @@ private double styleLineHeight(DocumentTextStyle style) { new com.demcha.compose.engine.measurement.FontLibraryTextMeasurementSystem( measuredFonts(), com.demcha.compose.engine.render.pdf.PdfFont.class)); } - return styleMetrics.lineHeight(style); + try { + return styleMetrics.lineHeight(style); + } catch (RuntimeException unknownFace) { + return 0; + } } /** @@ -5556,7 +5567,8 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean heldAbove = 0; } styleTheMark(para, markStyle); - // A paragraph composed in a table cell has no lines laid out: its line is its style's. + // 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. double pageLine = pictures.pageLine() > 0 ? pictures.pageLine() : pictures.pageLine() == 0 ? styleLineHeight(node.textStyle()) : 0; if (pageLine > 0 && pictures.reach() >= pageLine - PICTURE_FILLS_ITS_LINE) { @@ -5568,13 +5580,13 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean /** * Lets a line of pictures and no text be as tall as its pictures, where Word sizes the line. * - *

A line Word sizes itself — no exact or least height written, as one holding only a - * picture is, since held exact its picture would stand where Word's baseline puts it and - * be cut — is its tallest run's height, and the paragraph's mark is a run: its font's depth - * below the baseline went under the picture. {@code SlateOrange}'s skills, a 12.4pt icon - * beside each label, came out 0.2pt taller each in Word, its tenth 2.3pt low and the column - * under them with it. The mark and the picture runs are set at a point, whose depth is a - * fraction of one.

+ *

A line Word sizes itself — no exact or least height written, as a paragraph the layout + * laid out no line of is written — is its tallest run's height, and the paragraph's mark is + * a run: its font's depth below the baseline went under the picture. {@code SlateOrange}'s + * skills, a 12.4pt icon in a cell of its own beside each label, came out 0.2pt taller each in + * Word, its tenth 2.5pt low and the column under them with it. The mark and the picture runs + * are set at a point, whose depth is a fraction of one. A paragraph holding anything else — + * a letter, a break, a tab, a field, a link's text — keeps its sizes.

* *

Only where the pictures fill the page's line ({@link #PICTURE_FILLS_ITS_LINE}): there * the line is the pictures' height on the page too. A smaller picture stands in a line the @@ -5583,7 +5595,7 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean */ private static void letThePicturesSetTheLine(XWPFParagraph para) { CTPPr properties = para.getCTP().getPPr(); - if (!para.getText().isEmpty() + if (!holdsOnlyPictures(para) || properties != null && properties.isSetSpacing() && properties.getSpacing().isSetLineRule() && properties.getSpacing().getLineRule() != STLineSpacingRule.AUTO) { return; @@ -5593,14 +5605,39 @@ private static void letThePicturesSetTheLine(XWPFParagraph para) { } org.openxmlformats.schemas.wordprocessingml.x2006.main.CTParaRPr mark = properties.isSetRPr() ? properties.getRPr() : properties.addNewRPr(); - BigInteger point = BigInteger.valueOf(Math.round(HALF_POINTS_PER_POINT)); + BigInteger point = BigInteger.valueOf(ONE_POINT_IN_HALF_POINTS); (mark.sizeOfSzArray() > 0 ? mark.getSzArray(0) : mark.addNewSz()).setVal(point); (mark.sizeOfSzCsArray() > 0 ? mark.getSzCsArray(0) : mark.addNewSzCs()).setVal(point); for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) { - CTRPr runProperties = run.isSetRPr() ? run.getRPr() : run.addNewRPr(); - (runProperties.sizeOfSzArray() > 0 ? runProperties.getSzArray(0) : runProperties.addNewSz()).setVal(point); - (runProperties.sizeOfSzCsArray() > 0 ? runProperties.getSzCsArray(0) : runProperties.addNewSzCs()).setVal(point); + CTRPr size = run.isSetRPr() ? run.getRPr() : run.addNewRPr(); + (size.sizeOfSzArray() > 0 ? size.getSzArray(0) : size.addNewSz()).setVal(point); + (size.sizeOfSzCsArray() > 0 ? size.getSzCsArray(0) : size.addNewSzCs()).setVal(point); + } + } + + /** + * Whether a paragraph's runs, a link's included, hold pictures and nothing else: no + * letter, break, tab, symbol or field. An internal link's runs are not among the + * paragraph's own, so its text is not in {@link XWPFParagraph#getText()}. + */ + private static boolean holdsOnlyPictures(XWPFParagraph para) { + if (para.getCTP().sizeOfFldSimpleArray() > 0) { + return false; + } + boolean picture = false; + for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) { + if (run.sizeOfBrArray() > 0 || run.sizeOfTabArray() > 0 || run.sizeOfSymArray() > 0 + || run.sizeOfFldCharArray() > 0 || run.sizeOfInstrTextArray() > 0) { + return false; + } + for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTText text : run.getTArray()) { + if (text.getStringValue() != null && !text.getStringValue().isEmpty()) { + return false; + } + } + picture |= run.sizeOfDrawingArray() > 0 || run.sizeOfPictArray() > 0; } + return picture; } /** Whether a paragraph laid out a line holding text, which its seat is read from. */ diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineTest.java index 9483c6227..fdf924a5a 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineTest.java @@ -2,12 +2,15 @@ import com.demcha.compose.document.dsl.ParagraphBuilder; import com.demcha.compose.document.node.InlineImageAlignment; +import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextDecoration; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.svg.SvgIcon; import com.demcha.compose.document.table.DocumentTableCell; import com.demcha.compose.document.table.DocumentTableColumn; import com.demcha.compose.document.table.DocumentTableStyle; +import com.demcha.compose.font.FontName; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.junit.jupiter.api.Test; @@ -21,7 +24,7 @@ * *

Word sizes such a line itself, and the paragraph's mark is a run in it: the mark's font * reached below the picture. {@code SlateOrange}'s skills, an icon beside each label, came out - * 0.2pt taller each, and the tenth 2.3pt low.

+ * 0.2pt taller each, and the tenth 2.5pt low.

*/ class DocxPictureLineTest { @@ -49,6 +52,40 @@ void aSmallIconKeepsTheLineItsFontGivesIt() throws Exception { assertThat(markSize(dot)).isNotEqualTo(ONE_POINT); } + @Test + void anIconBesideALinksTextKeepsTheTextsSize() throws Exception { + // Written with no layout, the line is Word's to size. An internal link's runs are not the + // paragraph's own, and its text is still text. + try (XWPFDocument document = DocxExports.withoutLayout(400, 300, 20, page -> page + .addParagraph(p -> p.lineSpacing(0) + .textStyle(DocumentTextStyle.DEFAULT.withSize(10)) + .inlineSvgIcon(ICON, 12, InlineImageAlignment.CENTER) + .inlineLinkTo("Projects", "projects")) + .addParagraph(p -> p.text("Projects").anchor("projects")))) { + XWPFParagraph paragraph = document.getParagraphs().get(0); + var link = paragraph.getCTP().getHyperlinkArray(0).getRArray(0); + + assertThat(link.getTArray(0).getStringValue()).isEqualTo("Projects"); + assertThat(link.isSetRPr() && link.getRPr().sizeOfSzArray() > 0 + && DocxTwips.of(link.getRPr().getSzArray(0).getVal()) == ONE_POINT) + .as("the link's text is not set at a point").isFalse(); + assertThat(markSize(paragraph)).isNotEqualTo(ONE_POINT); + } + } + + @Test + void aLineOfPicturesInAFaceNoFontHoldsStillExports() throws Exception { + // No layout and no measured fonts: the line's height is not known, and the export goes on. + DocumentTextStyle unknown = new DocumentTextStyle(FontName.of("NoSuchFace"), 10, + DocumentTextDecoration.DEFAULT, DocumentColor.rgb(0, 0, 0)); + try (XWPFDocument document = DocxExports.withoutLayout(400, 300, 20, page -> page + .addParagraph(p -> p.lineSpacing(0) + .textStyle(unknown) + .inlineSvgIcon(ICON, 12, InlineImageAlignment.CENTER)))) { + assertThat(document.getParagraphs()).isNotEmpty(); + } + } + private static long markSize(XWPFParagraph paragraph) { CTPPr properties = paragraph.getCTP().getPPr(); if (properties == null || !properties.isSetRPr() || properties.getRPr().sizeOfSzArray() == 0) {