diff --git a/CHANGELOG.md b/CHANGELOG.md index 435b3fbb0..19083a4a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A paragraph breaks its lines where the page does, though Word sets its size to the half + point.** Word states a type size in half points, so `EngineeringResume`'s 7.8pt profile was set + at 8pt and took a line more, and its 6.9pt skills, set at 7pt, broke "SQL" onto a line of its + own: each column stood 8 to 9pt low. A paragraph set flush left is now given a measure as much + wider or narrower as Word sets its text, through its right indent; the glyphs are left as + Word sets them. The measure grows by the share of the line that grows most, so a 7.35pt + title, set at 7.5, still fits over 7.1pt prose set at 7, and every line keeps a point to + spare; a paragraph of several lines is held a point short of the full share where its lines + allow, so a word the page broke off by a fraction of a point stays broken off; a paragraph of + one line is never narrowed. + In Word, `EngineeringResume`'s median drift falls from 7.9pt to 0.6, and the corpus's lines + more than 2pt off fall from 324 to 249; line by line no line moves further from the page in + Word or LibreOffice. + - **`NavySidebar` stands where the page sets it in Word.** Its median drift in Word falls from 5.2pt to 0.1pt, every line within 2pt of the page. - Its portrait is a 127pt ring round a 123.8pt photo, in a column 123.8pt wide. The photo was diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index b66a03a49..adc35793b 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -287,10 +287,16 @@ bold in both. Word states a type size in half-points, so a size set to the tenth is written to the nearest half: 9.4pt as 9.5, 7.8pt as 8. A line of it is that much wider or narrower than on -the page. Measured over twenty CV presets, about one line in eight breaks at another word in -Word or LibreOffice than on the page; in LibreOffice about a fifth of those break there -because of the rounding, the rest because of how the editor sets text. The run is not scaled across to make up for it: a -scale would stay on the text a reader types next. +the page. The glyphs are not scaled to make up for it, since a scale would stay on the text +a reader types next. A paragraph set flush left is given a measure that much wider or +narrower instead, through its right indent, so its lines break at the words the page breaks +them; a reader's new text wraps at that measure. The measure grows by the share of the line +that grows most for its size — pictures and tracking keep their own width — and in a paragraph +of several lines stays a point short of that, unless a line needs the room, so a word the page +broke off by a fraction of a point is not pulled back up. A paragraph of one line is never +narrowed. A centred or right-aligned paragraph keeps +the page's measure, as moving its other edge would move its lines. A list's items, a line pair's +line, text over the flow and a header's or footer's line keep the page's measure too. ## Named styles, so the document can be restyled 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 be4e4cab6..ffff79263 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 @@ -4581,19 +4581,144 @@ private double availableWidth() { * or move a centred or right-aligned line off where the page sets it.

* * @param room the width the paragraph's text is written in, in points + * @return whether the paragraph was given room past its box */ - private void letTheLineStandOut(XWPFParagraph para, ParagraphNode node, double room) { + private boolean letTheLineStandOut(XWPFParagraph para, ParagraphNode node, double room) { if (node.align() == TextAlign.CENTER || node.align() == TextAlign.RIGHT) { - return; + return false; } double overhang = layout.unbrokenWidth(node) - room; if (!Double.isFinite(overhang) || !(overhang > 0.01)) { - return; + return false; } CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr(); CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd(); long right = twipsOf(indent.isSetRight() ? indent.getRight() : null); indent.setRight(BigInteger.valueOf(right - toTwips(overhang + EDITOR_SLACK_POINTS))); + return true; + } + + /** + * Sets a paragraph's lines in a measure as much wider or narrower than the page's as Word + * sets its text, so they break at the words the page breaks them. + * + *

Word states a type size in half points, so a size the page sets to the tenth is set a + * little larger or smaller, and its lines that much wider or narrower: {@code + * EngineeringResume}'s 6.9pt skills, set at 7pt, broke "SQL" onto a line of its own, and its + * 7.8pt profile, set at 8pt, took a line more, each column standing 8 to 9pt low under it. + * The glyphs are left as Word sets them — a scale would stay on the text a reader types + * next — and the right indent gives the line the same share more room, or takes it.

+ * + *

Only a paragraph set flush left: moving a centred or right-aligned line's other edge + * would move the line off where the page sets it. A list's items, a line pair, text over + * the flow and a header's or footer's line are written elsewhere and keep the page's + * measure.

+ * + * @param room the width the paragraph's text is written in, in points + */ + private void measureAtWordsSize(XWPFParagraph para, ParagraphNode node, double room) { + if (node.align() == TextAlign.CENTER || node.align() == TextAlign.RIGHT || !Double.isFinite(room)) { + return; + } + double more = wordsMeasure(node, room) - room; + if (!Double.isFinite(more) || Math.abs(more) < 0.05) { + return; + } + CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr(); + CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd(); + long right = twipsOf(indent.isSetRight() ? indent.getRight() : null); + indent.setRight(BigInteger.valueOf(right - Math.round(more * POINT_TO_TWIP))); + } + + /** How far inside the page's measure, grown or shrunk as Word sets it, Word's is held, in points. */ + private static final double WORDS_MEASURE_CLEARANCE = 1; + + /** + * The measure Word is to set a paragraph's text in, in points: the page's, as much wider or + * narrower as Word sets the line that grows most, a point short of that in a paragraph of + * several lines unless one of its lines needs more, and never less than a point past the + * widest line. + * + *

Each line the page laid out is weighed by its own text: a line of a 7.35pt title set at + * 7.5 grows, the 7.1pt lines under it set at 7 shrink, and a share averaged over the + * paragraph would narrow the measure the title's line no longer fits. A picture or a shape + * in a line is written at its own size and takes the same room in Word, and tracking is + * written in points, so neither grows with the size.

+ * + *

A line the page broke because its next word did not fit may have missed by a fraction + * of a point, and grown in the same proportion it misses by as little in Word, where it can + * fit: {@code CompactMono}'s "and", 0.1pt from fitting on the page, fitted in Word. Held a + * point short, the measure misses such a word by that much more. The widest line, grown, + * still has a point to spare, which wins where the two meet. A paragraph of one line broke + * no word: it keeps its measure or the share it grows by, whichever is wider, so a line as + * wide as its column is never narrowed onto two — {@code OrangeOps}' phone number broke in + * LibreOffice a point narrower. Without the page's lines — an export with no layout — the + * paragraph's runs are weighed by their letters.

+ */ + private double wordsMeasure(ParagraphNode node, double room) { + double share = Double.NaN; + double fits = 0; + int broken = -1; + for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) { + broken++; + double asked = 0; + double set = 0; + boolean text = false; + for (com.demcha.compose.document.layout.payloads.ParagraphSpan span : line.spans()) { + if (!(span.width() > 0)) { + continue; + } + asked += span.width(); + if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan run + && run.textStyle() != null && run.textStyle().size() > 0) { + double tracking = run.textStyle().letterSpacing() * run.text().codePointCount(0, run.text().length()); + set += (span.width() - tracking) * wordsSize(run.textStyle().size()) / run.textStyle().size() + tracking; + text = true; + } else { + set += span.width(); + } + } + if (text) { + share = Double.isNaN(share) ? set / asked : Math.max(share, set / asked); + fits = Math.max(fits, set); + } + } + if (Double.isNaN(share)) { + return room * lettersShare(node); + } + if (Math.abs(share - 1) < 1e-9) { + // Word sets every line at the page's size: the page's measure is Word's. + return room; + } + if (broken < 1) { + return Math.max(room, Math.max(room * share, fits)); + } + return Math.max(room * share - WORDS_MEASURE_CLEARANCE, fits + WORDS_MEASURE_CLEARANCE); + } + + /** How much wider Word sets a paragraph's runs, weighed by their letters, 1 for as wide. */ + private double lettersShare(ParagraphNode node) { + double asked = 0; + double set = 0; + for (InlineRun run : node.inlineRuns()) { + InlineTextRun text = textOf(run); + DocumentTextStyle style = text == null ? null : text.textStyle() == null ? node.textStyle() : text.textStyle(); + if (style != null && style.size() > 0) { + asked += text.text().length() * style.size(); + set += text.text().length() * wordsSize(style.size()); + } + } + if (node.inlineRuns().isEmpty() && node.textStyle() != null && node.textStyle().size() > 0) { + // No text runs: the paragraph's own text, in its own style. + asked = node.textStyle().size(); + set = wordsSize(asked); + } + return asked > 0 ? set / asked : 1; + } + + /** The size Word sets a size in, to the half point, as {@code w:sz} states it. */ + private static double wordsSize(double size) { + return Math.max(1, Math.round(size * HALF_POINTS_PER_POINT)) / HALF_POINTS_PER_POINT; } /** @@ -4993,8 +5118,12 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { insetLeft = outerLeft; insetRight = outerRight; } - letTheLineStandOut(para, node, room); + boolean standsOut = letTheLineStandOut(para, node, room); boolean rightToLeft = applyParagraphProperties(para, node); + // A line that stands out was given its room, and a couple of points more. + if (!rightToLeft && !standsOut) { + measureAtWordsSize(para, node, room); + } applyHeadingRole(para, node); int anchor = openAnchor(para, node.anchor()); // A line of a stack starts where its letters and the ones above leave room, not where diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java new file mode 100644 index 000000000..c891e5801 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java @@ -0,0 +1,258 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphLine; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentRowColumn; +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.CTInd; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A paragraph's lines are set in a measure as much wider or narrower than the page's as Word + * sets its text. + * + *

Word states a type size in half points. {@code EngineeringResume}'s 7.8pt profile, set at + * 8pt, took a line more than on the page, and its 6.9pt skills broke a word onto a line of its + * own: each column stood 8 to 9pt low under them.

+ */ +class DocxWordSizeMeasureTest { + + /** The body's width on a 400pt page with 20pt margins. */ + private static final double ROOM = 360; + + @Test + void aSizeWordSetsLargerWidensTheMeasureByAsMuch() throws Exception { + assertThat(indentOf(7.8, TextAlign.LEFT, SHORT)) + .as("one line: the measure 8/7.8 of the page's, in full") + .isEqualTo(-Math.round(ROOM * (8 / 7.8 - 1) * 20)); + } + + @Test + void aSizeWordSetsSmallerNarrowsAParagraphOfSeveralLines() throws Exception { + assertThat(indentOf(9.2, TextAlign.LEFT, LONG)).as("9/9.2 of the page's, a point short of it") + .isEqualTo(Math.round((ROOM * (1 - 9 / 9.2) + 1) * 20)); + } + + @Test + void aParagraphOfOneLineIsNeverNarrowed() throws Exception { + // It broke no word to keep out, and narrowed it could break onto two. + assertThat(indentOf(9.2, TextAlign.LEFT, SHORT)).isZero(); + } + + @Test + void aSizeWordStatesAsItIsLeavesTheMeasureAlone() throws Exception { + assertThat(indentOf(9.5, TextAlign.LEFT, LONG)).isZero(); + } + + @Test + void aCentredLineIsLeftWhereThePageSetsIt() throws Exception { + assertThat(indentOf(7.8, TextAlign.CENTER, LONG)).isZero(); + } + + @Test + void theMeasureIsTheOneTheLineThatGrowsMostNeeds() throws Exception { + // EngineeringResume's projects: a 7.35pt title, set at 7.5, fills most of the first + // line, and 7.1pt prose, set at 7, the lines under it. Averaged over the paragraph's + // letters the measure would narrow, and the title's line would no longer fit. + String title = "GraphCompose (Java 21, PDFBox, Maven, JMH) - Declarative Java PDF layout"; + String prose = " engine. Semantic templates, snapshot testing, and the pipelines that run on them ".repeat(4); + java.util.function.Consumer paragraph = + p -> p.rich(rich -> rich.size(title, 7.35).size(prose, 7.1)); + double letters = (title.length() * 7.5 + prose.length() * 7.0) / (title.length() * 7.35 + prose.length() * 7.1); + assertThat(letters).as("the letters' average narrows").isLessThan(1); + for (double room = 300; room < 360; room += 0.25) { + double first = wordsWidth(laidOut(room, paragraph).get(0)); + if (!(first > room * letters)) { + continue; + } + // A measure where the title's line, as Word sets it, is wider than the averaged one. + double width = room; + try (XWPFDocument document = DocxExports.withLayout(room + 40, 400, 20, page -> page.addParagraph(paragraph))) { + double measure = width - rightIndent(text(document, "GraphCompose")) / 20.0; + + assertThat(measure).as("the title's line fits, a point to spare").isGreaterThanOrEqualTo(first + 0.99); + } + return; + } + throw new AssertionError("no measure where the averaged share would cut the title's line"); + } + + @Test + void aRunOfItsOwnSizeIsWrittenAtTheSizeTheMeasureIsTakenFor() throws Exception { + // The body sets Normal at 12pt; the small print states its own size on its run. + try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page + .addParagraph(p -> p.text("A body paragraph long enough to set the document's own size.") + .textStyle(DocumentTextStyle.DEFAULT.withSize(12))) + .addParagraph(p -> p.text("Platform engineer with ten years of document pipelines.") + .textStyle(DocumentTextStyle.DEFAULT.withSize(7.8))))) { + XWPFParagraph small = text(document, "Platform engineer"); + + assertThat(DocxTwips.of(small.getRuns().get(0).getCTR().getRPr().getSzArray(0).getVal())) + .as("8pt, in half points").isEqualTo(16L); + assertThat(rightIndent(small)).isEqualTo(-Math.round(ROOM * (8 / 7.8 - 1) * 20)); + } + } + + @Test + void aParagraphInACellIsGivenItsShareOfTheCellsMeasure() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page + .addRow("Page", row -> row + .columns(DocumentRowColumn.fixed(200), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Platform engineer with ten years of document pipelines.") + .textStyle(DocumentTextStyle.DEFAULT.withSize(7.8))) + .addParagraph("Main")))) { + XWPFParagraph cell = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().stream() + .filter(p -> p.getText().startsWith("Platform engineer")).findFirst().orElseThrow(); + + assertThat(-rightIndent(cell)).as("wider, by its share of a column no wider than 200pt") + .isPositive() + .isLessThanOrEqualTo(Math.round(200 * (8 / 7.8 - 1) * 20)); + } + } + + @Test + void aLineThePageBrokeJustShortOfItsNextWordStaysBroken() throws Exception { + // CompactMono's "and" was 0.1pt from fitting on the page; given the measure in + // proportion, Word fitted it. Find a measure as close, then hold Word's clear of it. + String text = "Built backend services and production document rendering pipelines processing two " + + "million documents per month and cutting render latency from seconds to milliseconds."; + for (double width = 300; width < 360; width += 0.05) { + double room = width; + List lines = laidOut(room, text); + double[] tie = lineAndNextWord(lines); + if (tie == null || !(tie[1] - room > 0) || !(tie[1] - room < 0.2)) { + continue; + } + try (XWPFDocument document = DocxExports.withLayout(room + 40, 400, 20, page -> page + .addParagraph(p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(7.95))))) { + double measure = room - rightIndent(text(document, "Built backend")) / 20.0; + double grows = 8 / 7.95; + + assertThat(rightIndent(text(document, "Built backend"))).as("wider than the page's").isNegative(); + assertThat(measure).as("every line fits").isGreaterThanOrEqualTo(tie[0] * grows); + assertThat(measure).as("the next word does not, by a point") + .isLessThanOrEqualTo(tie[1] * grows - 0.99); + } + return; + } + throw new AssertionError("no measure within 0.2pt of a line and its next word"); + } + + @Test + void everyLineHoldingAPictureStillFits() throws Exception { + // A picture is written at its own size: it takes the room it takes on the page. Over a + // run of measures one finds the picture's line all but full. + String prose = "Built backend services and production document rendering pipelines that process " + + "two million documents a month for hiring, billing and reporting teams."; + java.util.function.Consumer paragraph = p -> p.rich(rich -> rich + .image(com.demcha.compose.document.image.DocumentImageData.fromBytes(pngBytes()), 24, 8) + .size(" " + prose, 7.1)); + double tightest = Double.POSITIVE_INFINITY; + for (double room = 300; room < 330; room += 0.25) { + double widest = laidOut(room, paragraph).stream().mapToDouble(DocxWordSizeMeasureTest::wordsWidth).max().orElseThrow(); + double width = room; + try (XWPFDocument document = DocxExports.withLayout(room + 40, 400, 20, page -> page.addParagraph(paragraph))) { + double measure = width - rightIndent(text(document, "Built backend")) / 20.0; + tightest = Math.min(tightest, measure - widest); + } + } + assertThat(tightest).as("the least room any line, picture and all, is left in Word's measure") + .isGreaterThanOrEqualTo(0.99); + } + + /** A laid-out line's width as Word sets it: text at its half-point size, pictures as they are. */ + private static double wordsWidth(ParagraphLine line) { + double width = 0; + for (var span : line.spans()) { + if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan text) { + double size = text.textStyle().size(); + width += text.width() * Math.round(size * 2) / 2.0 / size; + } else { + width += span.width(); + } + } + return width; + } + + private static byte[] pngBytes() { + try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(24, 8, java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out); + return out.toByteArray(); + } catch (java.io.IOException failure) { + throw new IllegalStateException(failure); + } + } + + /** + * The widest line, and the narrowest line with a space and its next line's first word, as + * the page lays them out; a word's width is its own line's, laid out alone. + */ + private static double[] lineAndNextWord(List lines) { + double widest = 0; + double nearest = Double.POSITIVE_INFINITY; + double space = widthAlone("a a") - 2 * widthAlone("a"); + for (int index = 0; index < lines.size(); index++) { + widest = Math.max(widest, lines.get(index).width()); + if (index + 1 < lines.size()) { + String next = lines.get(index + 1).text().strip().split("\\s+")[0]; + nearest = Math.min(nearest, lines.get(index).width() + space + widthAlone(next)); + } + } + return lines.size() < 2 ? null : new double[]{widest, nearest}; + } + + private static double widthAlone(String text) { + return laidOut(500, text).get(0).width(); + } + + private static List laidOut(double room, String text) { + return laidOut(room, p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(7.95))); + } + + private static List laidOut(double room, + java.util.function.Consumer paragraph) { + try (DocumentSession session = GraphCompose.document().pageSize(room + 40, 400) + .margin(DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page.addParagraph(paragraph)); + return session.layoutGraph().fragments().stream() + .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload) + .flatMap(fragment -> ((ParagraphFragmentPayload) fragment.payload()).lines().stream()) + .toList(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } + + private static long rightIndent(XWPFParagraph paragraph) { + CTInd indent = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getInd(); + return indent == null || !indent.isSetRight() ? 0 : DocxTwips.of(indent.getRight()); + } + + private static final String SHORT = "Platform engineer with ten years of document pipelines."; + private static final String LONG = SHORT + " Built resilient layout engines, template systems and the " + + "snapshot-tested libraries that replace brittle production scripts across many teams."; + + /** The right indent of a paragraph of the text given, at a size and an alignment. */ + private static long indentOf(double size, TextAlign align, String words) throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page + .addParagraph(p -> p.text(words).textStyle(DocumentTextStyle.DEFAULT.withSize(size)).align(align)))) { + return rightIndent(text(document, "Platform engineer")); + } + } + + private static XWPFParagraph text(XWPFDocument document, String start) { + return document.getParagraphs().stream() + .filter(p -> p.getText().strip().startsWith(start)) + .findFirst().orElseThrow(); + } +}