diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdda5b3f..558f33d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A line pulled up into the line above it, with no space above to take the pull from, is no + longer dropped in Word.** A paragraph's negative top edge comes out of the space owed above + it, and where there is none — the two lines of a lockup in a table cell — it was dropped: + `CobaltRota`'s subtitle, 6.5pt up under its 21pt wordmark, stood that much low, and its + masthead row and the rota under it with it. The pull now comes off the foot of the line of + text written just before, on the same page — one exact line alone in its paragraph, with no + space below it and no run shaded or underlined — as far as its letters leave room where Word + draws them, three quarters of a point clear: the line written shorter, its text lowered by + the four fifths of that Word's baseline would rise. The rest comes off the top of the pulled + line itself, as far as its letters allow; what neither can give is not written. `CobaltRota`'s median drift falls from 8.9pt to 2.4 in Word and in LibreOffice; its + lines more than 2pt off fall from 65 to 45 in Word and from 62 to 47 in LibreOffice. + - **A chip's label taller than the chip no longer makes it taller in Word.** A shape composed in a table cell centres its one line of text in it, and the page lets the line pass the outline where it is taller: `CobaltRota`'s stacked shift chips, 9.2pt outlines round 8.2pt text on a diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index a6eff6a0b..ac6714317 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -201,8 +201,16 @@ a table (not a row) opening a cell still loses that edge. An edge pulling a block up — a paragraph's, a page reference's or a rule's negative top `margin` or `padding` — comes out of the space owed above it, as the page takes it from -there: Word has no negative space above a paragraph. What that space cannot give is not -written. Text laid over the flow in text boxes owes no space, so its edges move nothing. +there: Word has no negative space above a paragraph. What that space cannot give a paragraph +takes from the foot of the line of text written just before it on the same page — one exact +line alone in its paragraph, not one of a stack, with no space below it and no run shaded or +underlined, as shading fills the line and an underline is drawn below the letters — as far as +that line's letters leave room below them where Word draws them, three quarters of a point +clear: the line written shorter, its text lowered by the four fifths of that Word's baseline +would rise. The rest it takes from the top of its own line, where it is one line of text, as +far as its letters leave room above them, its text seated where the page sets it; what neither +can give is not written. Text laid over the flow in text boxes owes no space, so its edges +move nothing. A paragraph's or a list's `lineSpacing` — the gap the page puts between two wrapped lines — goes into the line: Word has one line height for a paragraph and no gap between its lines, 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 76769d220..b5e21e89b 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 @@ -330,6 +330,11 @@ public final class DocxSemanticBackend implements SemanticBackend { // The last paragraph written into the body, so a container can hand it the space it // holds below itself once its children are done. private XWPFParagraph lastBodyParagraph; + // The paragraph writeParagraph wrote last, and the Word paragraph it wrote it in: a paragraph + // pulled up into it takes the pull off its foot (takeFromTheLineAbove), while nothing else has + // been written since — lastBodyParagraph is that same Word paragraph then. + private ParagraphNode lastWrittenNode; + private XWPFParagraph lastWrittenParagraph; // The empty paragraph closing the last table written into the cell being filled, while // nothing has been written after it; see newTable. private XWPFParagraph tableCloser; @@ -718,6 +723,8 @@ private void beginSection(SemanticSection section, int index) { anItemWasWritten = false; forgetTheHang(); lastBodyParagraph = null; + lastWrittenNode = null; + lastWrittenParagraph = null; contentWidth = context.canvas() == null ? Double.MAX_VALUE : context.canvas().innerWidth(); canvasHeight = context.canvas() == null ? Double.NaN : context.canvas().height(); } @@ -3821,15 +3828,129 @@ private static boolean drawn(CTBorder border) { * past its content: the pull was lost, and the rule and the page under it stood 10pt low, * the 16.4pt less the 6.3pt its metadata grid had lost above it. The pull is added * to the edges carried down to the block, as a container's negative edge already is, so - * {@link #newBodyParagraph} nets it against everything owed above, as the page sums it; - * what that cannot give stays unwritten, as before. Text laid over the flow owes no + * {@link #newBodyParagraph} nets it against everything owed above, as the page sums it. + * Where that cannot give it all, a paragraph takes the rest off the foot of the line written + * just before it ({@link #takeFromTheLineAbove}), then off the top of its own line + * ({@link #riseInsideItsOwnLine}); what neither can give stays unwritten. Text laid over the flow owes no * space ({@link #writeOverTheFlow}), so its edges move nothing.

*/ private void standsIntoTheSpaceAbove(DocumentNode node) { double edge = node.margin().top() + node.padding().top(); if (edge < 0) { carriedSpacingBefore += edge; + double shortBy = -(carriedSpacingBefore + pendingSpacingAfter - borderBelow); + if (shortBy > 0.01 && node instanceof ParagraphNode paragraph) { + shortBy -= takeFromTheLineAbove(shortBy, paragraph); + if (shortBy > 0.01) { + riseInsideItsOwnLine(paragraph, shortBy); + } + } + } + } + + /** + * Takes up to {@code points} off the foot of the one line written just before, where its + * letters leave that room, keeping its text where it stood; how much it took is given back to + * the space above the next block. + * + *

A paragraph pulled up into the one above it, with nothing above it to give the pull — + * the two lines of a lockup in a table cell, {@code CobaltRota}'s subtitle 6.5pt up under + * its 21pt wordmark — stood that much low in Word, and the row and the page under it with + * it. The line above is written shorter by what its letters do not reach, and its text, + * which Word would raise with the baseline of a shorter exact line, lowered as much.

+ * + *

Only an exact line of text alone in its paragraph, not one of a stack, with no space + * written below it and no run shaded or underlined: a shaded run fills the line in Word, and + * an underline is drawn below the letters. Where both are laid out, the line must end on the + * page the pulled paragraph starts on: the last line of a page shortened would move where + * Word breaks it.

+ * + * @return how far the line above was shortened, in points + */ + private double takeFromTheLineAbove(double points, ParagraphNode pulled) { + ParagraphNode above = lastWrittenNode; + XWPFParagraph para = lastWrittenParagraph; + if (above == null || para == null || para != lastBodyParagraph || layout.lineCount(above) != 1 + || stackedLineHeights.containsKey(above)) { + return 0; } + com.demcha.compose.document.layout.PlacedNode abovePlaced = layout.placement(above); + com.demcha.compose.document.layout.PlacedNode pulledPlaced = layout.placement(pulled); + if (abovePlaced != null && pulledPlaced != null && abovePlaced.endPage() != pulledPlaced.startPage()) { + return 0; + } + for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) { + // A shaded run fills the line; an underline is drawn below the letters' ink. + if (run.isSetRPr() && (run.getRPr().sizeOfShdArray() > 0 || run.getRPr().sizeOfUArray() > 0 + && run.getRPr().getUArray(0).getVal() != org.openxmlformats.schemas.wordprocessingml.x2006.main.STUnderline.NONE)) { + return 0; + } + } + CTPPr properties = para.getCTP().getPPr(); + CTSpacing spacing = properties != null && properties.isSetSpacing() ? properties.getSpacing() : null; + Long line = spacing != null && spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT + ? writtenTwips(spacing.getLine()) : null; + java.util.Optional laid = layout.firstLine(above); + // The letters' reach off their own baseline, before any seat: the position written on + // the runs below carries the seat, as far as it was written. + double[] ink = laid.isEmpty() ? null : DocxInk.of(laid.get(), measuredFonts()); + if (line == null || ink == null || spacing.isSetAfter()) { + return 0; + } + // The room under the letters where Word draws them: its baseline stands a fifth of the + // line above the foot, raised by the runs' written position; less the margin, and the + // quarter point the lowering below can round away. + List runs = runsIn(para); + long seated = 0; + if (!runs.isEmpty() && runs.get(0).isSetRPr() && runs.get(0).getRPr().sizeOfPositionArray() > 0 + && runs.get(0).getRPr().getPositionArray(0).getVal() instanceof Number number) { + seated = number.longValue(); + } + double wordFoot = (1 - DocxTextBands.BASELINE_SHARE) * line / POINT_TO_TWIP + seated / HALF_POINTS_PER_POINT; + double room = wordFoot - ink[1] - DocxStackedLines.INK_MARGIN - 0.5 / HALF_POINTS_PER_POINT; + long taken = Math.round(Math.min(points, room) * POINT_TO_TWIP); + if (taken <= 0 || taken >= line) { + return 0; + } + spacing.setLine(BigInteger.valueOf(line - taken)); + // Word stands an exact line's baseline four fifths of the way down it: shorter by the + // taken twips, its text would rise by four fifths of them. + long lowered = Math.round(taken / POINT_TO_TWIP * DocxTextBands.BASELINE_SHARE * HALF_POINTS_PER_POINT); + for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) { + org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr runProperties = + run.isSetRPr() ? run.getRPr() : run.addNewRPr(); + org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSignedHpsMeasure position = + runProperties.sizeOfPositionArray() > 0 ? runProperties.getPositionArray(0) : runProperties.addNewPosition(); + long raised = position.getVal() instanceof Number number ? number.longValue() : 0; + position.setVal(BigInteger.valueOf(raised - lowered)); + } + double takenPoints = taken / POINT_TO_TWIP; + carriedSpacingBefore += takenPoints; + return takenPoints; + } + + /** + * Rises a one-line paragraph inside its own line by up to {@code points}, where the room above + * its letters allows, as {@link #riseIntoItsLine} does for a container's first line. + */ + private void riseInsideItsOwnLine(ParagraphNode paragraph, double points) { + if (layout.lineCount(paragraph) != 1 || stackedLineHeights.containsKey(paragraph)) { + return; + } + java.util.OptionalDouble own = layout.lineHeight(paragraph); + java.util.Optional line = layout.firstLine(paragraph); + double[] ink = inkOf(paragraph); + if (own.isEmpty() || line.isEmpty() || ink == null) { + return; + } + double aboveTheLetters = line.get().lineHeight() - line.get().baselineOffsetFromBottom() - ink[0] + - DocxStackedLines.INK_MARGIN; + double taken = Math.min(points, aboveTheLetters); + if (!(taken > 0.01) || taken >= own.getAsDouble()) { + return; + } + stackedLineHeights.put(paragraph, new DocxStackedLines.Line(own.getAsDouble() - taken, -taken, 0)); + carriedSpacingBefore += taken; } /** @@ -4570,6 +4691,8 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { DocxStackedLines.Line stacked = stackedLineHeights.get(node); writeParagraphRuns(para, node, rightToLeft, stacked == null ? 0 : stacked.topAbove(), stacked == null); closeAnchor(para, anchor); + lastWrittenNode = node; + lastWrittenParagraph = para; } /** diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java index 523a70618..68bf04e4c 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java @@ -3,6 +3,7 @@ import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentRowColumn; +import com.demcha.compose.document.style.DocumentTextDecoration; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.font.FontName; import org.apache.poi.xwpf.usermodel.IBodyElement; @@ -91,6 +92,109 @@ void aParagraphPulledUpInsideASectionPulledUpAddsToItsPull() throws Exception { } } + @Test + void aLinePulledUpIntoTheLineAboveInACellTakesItsFoot() throws Exception { + // CobaltRota's lockup: a subtitle pulled up under its wordmark, in a cell with nothing + // above to give the pull. The wordmark's line, its letters standing on the baseline, is + // written 3.5pt shorter, its text lowered by the four fifths of that Word's baseline would + // rise — 6 half points, where three quarters would round to 5 — and the subtitle has no + // space above it. + DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21).build(); + XWPFParagraph[] plain = lockup(wordmark, 0); + XWPFParagraph[] pulled = lockup(wordmark, -3.5); + + double shorter = (DocxTwips.of(lineOf(plain[0])) - DocxTwips.of(lineOf(pulled[0]))) / 20.0; + assertThat(shorter).as("the wordmark's line gives up the pull").isCloseTo(3.5, within(0.06)); + assertThat(positionOf(pulled[0]) - positionOf(plain[0])).as("its text lowered as its baseline would rise") + .isEqualTo(-6); + assertThat(DocxTwips.of(lineOf(pulled[1]))).as("the pulled line whole, the pull taken above it") + .isEqualTo(DocxTwips.of(lineOf(plain[1]))); + } + + @Test + void aLineAboveWithItsTextShadedIsLeftWhole() throws Exception { + // A shaded run fills its exact line in Word: shortened, the chip would lose its foot. + XWPFParagraph plain = decoratedLockup(true, false, 0); + XWPFParagraph pulled = decoratedLockup(true, false, -2); + + assertThat(pulled.getRuns().get(0).getCTR().getRPr().sizeOfShdArray()).as("a shaded run").isPositive(); + assertThat(DocxTwips.of(lineOf(pulled))).as("its line not cut").isEqualTo(DocxTwips.of(lineOf(plain))); + assertThat(positionOf(pulled)).as("nor its text lowered").isEqualTo(positionOf(plain)); + } + + @Test + void aLineAboveWithItsTextUnderlinedIsLeftWhole() throws Exception { + // Word draws an underline below the letters, in the room the pull would take. + XWPFParagraph plain = decoratedLockup(false, true, 0); + XWPFParagraph pulled = decoratedLockup(false, true, -2); + + assertThat(pulled.getRuns().get(0).getCTR().getRPr().sizeOfUArray()).as("an underlined run").isPositive(); + assertThat(DocxTwips.of(lineOf(pulled))).as("its line not cut").isEqualTo(DocxTwips.of(lineOf(plain))); + assertThat(positionOf(pulled)).as("nor its text lowered").isEqualTo(positionOf(plain)); + } + + /** A 21pt wordmark, shaded or underlined, over a 10pt subtitle pulled up by {@code pull}. */ + private static XWPFParagraph decoratedLockup(boolean shaded, boolean underlined, double pull) throws Exception { + DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21) + .decoration(underlined ? DocumentTextDecoration.UNDERLINE : DocumentTextDecoration.DEFAULT).build(); + DocumentTextStyle subtitle = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Head", row -> row + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) + .addSection("Lockup", cell -> cell + .addParagraph(p -> { + if (shaded) { + p.inlineHighlight("HALL", wordmark, + DocumentColor.rgb(255, 230, 150), 0, DocumentInsets.zero()); + } else { + p.text("HALL").textStyle(wordmark); + } + }) + .addParagraph(p -> p.text("QUAYSIDE BAR").textStyle(subtitle) + .margin(new DocumentInsets(pull, 0, 0, 0)))) + .addParagraph(p -> p.text("MONDAY").textStyle(subtitle))))) { + return document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0); + } + } + + @Test + void aPullDeeperThanTheLettersAboveLeaveRoomForRisesInsideItsOwnLine() throws Exception { + // Under 12pt text the line's foot has about 3pt below the letters; the rest of an 8pt + // pull comes out of the top of the pulled line itself. + DocumentTextStyle small = DocumentTextStyle.builder().fontName(FontName.LATO).size(12).build(); + XWPFParagraph[] plain = lockup(small, 0); + XWPFParagraph[] pulled = lockup(small, -8); + + assertThat(DocxTwips.of(lineOf(pulled[0]))).as("the line above gave some").isLessThan(DocxTwips.of(lineOf(plain[0]))); + assertThat(DocxTwips.of(lineOf(pulled[1]))).as("and the pulled line the rest").isLessThan(DocxTwips.of(lineOf(plain[1]))); + } + + /** A wordmark over a 10pt subtitle pulled up by {@code pull}, in a row's cell. */ + private static XWPFParagraph[] lockup(DocumentTextStyle wordmark, double pull) throws Exception { + DocumentTextStyle subtitle = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow("Head", row -> row + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) + .addSection("Lockup", cell -> cell + .addParagraph(p -> p.text("HALL").textStyle(wordmark)) + .addParagraph(p -> p.text("QUAYSIDE BAR").textStyle(subtitle) + .margin(new DocumentInsets(pull, 0, 0, 0)))) + .addParagraph(p -> p.text("MONDAY").textStyle(subtitle))))) { + List paragraphs = document.getTables().get(0).getRow(0).getCell(0).getParagraphs(); + return new XWPFParagraph[]{paragraphs.get(0), paragraphs.get(1)}; + } + } + + private static Object lineOf(XWPFParagraph paragraph) { + return paragraph.getCTP().getPPr().getSpacing().getLine(); + } + + private static int positionOf(XWPFParagraph paragraph) { + var run = paragraph.getRuns().get(0).getCTR(); + return run.isSetRPr() && run.getRPr().sizeOfPositionArray() > 0 + ? ((Number) run.getRPr().getPositionArray(0).getVal()).intValue() : 0; + } + @Test void aRowOpeningAPaddedColumnKeepsThePaddingAboveIt() throws Exception { try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page