diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dad04d20..ea28ddc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A centred or right-aligned line that fills its box keeps to one line in Word.** Word sets + a size in half points, so a 6.8pt line is set at 7pt, 2.9% wider, and no longer fits a box it + fills. A flush-left paragraph was already given that much more measure; a centred or + right-aligned one of one line now is too, at both edges or at its left, so the line stays + where the page sets it. In a table cell the left edge gives no more than its indent, since + Word draws no text past a cell's left edge, and a line short of that moves by the rest. + `VioletGrid`'s "INFORMATION ARCHITECTURE" broke onto two lines in its skill tile, and the + tile's text stood 7.5pt low under it. Lines more than 2pt off in Word fall from 2 to none + across the corpus. In LibreOffice, which still sets `VioletGrid` on two pages, its median + falls from 5.2pt to 0.4pt. - **A table or a row keeps its own margins in DOCX.** Its left margin is now written into its indent (`w:tblInd`), as a paragraph's is: it was dropped everywhere, and `PaymentsInvoice`'s bank details stood 36.7pt left of the page's in Word. A table indented in a cell takes that diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 25f65d6e6..4d035fbe2 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -309,9 +309,15 @@ of several lines stays a point short of that, unless a line needs the room, so a broke off by a fraction of a point is not pulled back up. A paragraph of one line is never narrowed, and has room for a line three hundredths wider than its own: Word sets a face other than the page's, or emboldens one whose file does not say it is bold, a little wider, and a -line of one broken in Word is a line more. 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. +line of one broken in Word is a line more. A centred or right-aligned paragraph of one line +whose line, as Word sets it and three hundredths wider, does not fit its box is given that +room: at both edges, half each, or at its left edge, so the line stays where the page sets +it. In a table cell, where Word draws no text past the cell's left edge, the left indent +gives no more than it has, and the right edge gives the rest, the line moving by that much. +Any other centred or right-aligned paragraph keeps the page's measure: in one of several +lines a wider measure takes another word onto a line, and a short line's box already has +room for it. 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/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv index 39a9b350b..4978b614e 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -1727,8 +1727,9 @@ cv-violet_grid 0:b96067918254 -0.26 uxresearch cv-violet_grid 0:9058674fd905 -0.21 userinterviews,surveys, cv-violet_grid 0:0cdd38014d79 -0.22 personas,journeymaps, cv-violet_grid 0:61e4ba28d469 -0.22 competitiveanalysis -cv-violet_grid 0:db5c6756c4b6 7.59 sitemaps,userflows,card -cv-violet_grid 0:6c7ad1834a60 7.58 sorting,contentstrategy +cv-violet_grid 0:3de6be58cb9f -0.26 informationarchitecture +cv-violet_grid 0:db5c6756c4b6 -0.21 sitemaps,userflows,card +cv-violet_grid 0:6c7ad1834a60 -0.22 sorting,contentstrategy cv-violet_grid 0:187737d59250 -0.26 wireframing cv-violet_grid 0:2286e5aa328a -0.21 lowtohigh-fidelity cv-violet_grid 0:5c4b21cb1af8 -0.26 prototyping diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index 3f048ec40..313c98f10 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv @@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 86 3.27 3.87 86 cv-teal_pulse 1 1 57 57 0.22 0.64 0 cv-terracotta_rail 1 1 102 102 0.44 0.94 0 cv-timeline_minimal 2 2 89 86 0.45 0.50 0 -cv-violet_grid 1 2 80 76 5.21 21.41 39 +cv-violet_grid 1 2 80 77 0.36 21.41 37 invoice-classic 1 1 56 56 0.12 0.30 0 invoice-classic_long 2 2 166 166 0.20 0.27 0 invoice-consulting 1 1 92 92 0.67 0.96 0 diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv index b14091dfd..739dd25e9 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -1633,8 +1633,9 @@ cv-violet_grid 0:b96067918254 -0.26 uxresearch cv-violet_grid 0:9058674fd905 -0.21 userinterviews,surveys, cv-violet_grid 0:0cdd38014d79 -0.22 personas,journeymaps, cv-violet_grid 0:61e4ba28d469 -0.22 competitiveanalysis -cv-violet_grid 0:db5c6756c4b6 7.59 sitemaps,userflows,card -cv-violet_grid 0:6c7ad1834a60 7.58 sorting,contentstrategy +cv-violet_grid 0:3de6be58cb9f -0.26 informationarchitecture +cv-violet_grid 0:db5c6756c4b6 -0.21 sitemaps,userflows,card +cv-violet_grid 0:6c7ad1834a60 -0.22 sorting,contentstrategy cv-violet_grid 0:187737d59250 -0.26 wireframing cv-violet_grid 0:2286e5aa328a -0.21 lowtohigh-fidelity cv-violet_grid 0:5c4b21cb1af8 -0.26 prototyping diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index b94487410..6c64f5844 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv @@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 86 3.27 3.87 86 cv-teal_pulse 1 1 57 57 0.22 0.64 0 cv-terracotta_rail 1 1 102 102 0.44 0.94 0 cv-timeline_minimal 2 2 89 86 0.45 0.50 0 -cv-violet_grid 1 2 80 76 5.21 21.41 39 +cv-violet_grid 1 2 80 77 0.36 21.41 37 invoice-classic 1 1 56 56 0.17 0.32 0 invoice-classic_long 2 2 166 166 0.22 0.27 0 invoice-consulting 1 1 92 92 0.67 1.01 0 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 6878c2617..01e5bd24b 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -1726,8 +1726,9 @@ cv-violet_grid 0:b96067918254 -0.23 uxresearch cv-violet_grid 0:9058674fd905 -0.28 userinterviews,surveys, cv-violet_grid 0:0cdd38014d79 -0.36 personas,journeymaps, cv-violet_grid 0:61e4ba28d469 -0.28 competitiveanalysis -cv-violet_grid 0:db5c6756c4b6 7.52 sitemaps,userflows,card -cv-violet_grid 0:6c7ad1834a60 7.47 sorting,contentstrategy +cv-violet_grid 0:3de6be58cb9f -0.23 informationarchitecture +cv-violet_grid 0:db5c6756c4b6 -0.28 sitemaps,userflows,card +cv-violet_grid 0:6c7ad1834a60 -0.36 sorting,contentstrategy cv-violet_grid 0:187737d59250 -0.23 wireframing cv-violet_grid 0:2286e5aa328a -0.28 lowtohigh-fidelity cv-violet_grid 0:5c4b21cb1af8 -0.23 prototyping diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index df8f19b25..c60657f98 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 85 0.53 1.18 0 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 0.36 0.48 0 -cv-violet_grid 1 1 80 77 0.22 1.09 2 +cv-violet_grid 1 1 80 78 0.23 0.37 0 invoice-classic 1 1 56 56 0.22 0.36 0 invoice-classic_long 2 2 166 166 0.27 0.34 0 invoice-consulting 1 1 92 92 0.70 1.02 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 8566239d9..aa129343a 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 @@ -4707,25 +4707,93 @@ private boolean letTheLineStandOut(XWPFParagraph para, ParagraphNode node, doubl * 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.
+ *A paragraph set flush left has its measure moved at its right edge. A centred or + * right-aligned one is given room only for a line of its own that Word sets past its box + * or within three hundredths of it ({@link #ONE_LINE_FACE_SLACK}), at both edges, half each, + * or at its left, so the line stays where the page sets it — in a cell, where Word draws no + * text past the left edge, the right edge gives what the left indent cannot: + * {@code VioletGrid}'s 6.8pt "INFORMATION ARCHITECTURE", set at 7pt and centred in a tile + * it fills, broke onto two lines, and the tile's text stood 7.5pt low under it. A centred + * paragraph of several lines is left as it is — a wider measure there takes another word + * onto a line Word already breaks elsewhere than the page — and so is a short line, whose + * box has room for it: measured, {@code MerchantInvoice}'s centred amounts, their cells' + * indents taken past the cells' edges, stood 0.12pt off their place. 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)) { + boolean flushLeft = node.align() != TextAlign.CENTER && node.align() != TextAlign.RIGHT; + if (!Double.isFinite(room) || !flushLeft && layout.lineCount(node) != 1) { return; } - double more = wordsMeasure(node, room) - room; + double more = (flushLeft ? wordsMeasure(node, room) : oneLinesMeasure(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))); + long twips = Math.round(more * POINT_TO_TWIP); + // The edge the text does not lean on moves: a centred line's two, half each. + long fromTheLeft = node.align() == TextAlign.RIGHT ? twips + : node.align() == TextAlign.CENTER ? twips / 2 + : 0; + long left = twipsOf(indent.isSetLeft() ? indent.getLeft() : null); + if (currentCell != null) { + // Word draws no text past a cell's left edge (see leftIndentTwips): what the left + // indent cannot give, the right one does, and the line moves by that much. A hanging + // first line already starts that much further left. + long hanging = twipsOf(indent.isSetHanging() ? indent.getHanging() : null); + fromTheLeft = Math.min(fromTheLeft, Math.max(0, left - hanging)); + } + long fromTheRight = twips - fromTheLeft; + if (fromTheLeft != 0) { + indent.setLeft(BigInteger.valueOf(left - fromTheLeft)); + } + if (fromTheRight != 0) { + long right = twipsOf(indent.isSetRight() ? indent.getRight() : null); + indent.setRight(BigInteger.valueOf(right - fromTheRight)); + } + } + + /** + * The measure a paragraph of one line needs in Word, in points: its box, or its line as wide + * as Word sets it and a few hundredths more ({@link #ONE_LINE_FACE_SLACK}), whichever is + * wider. + */ + private double oneLinesMeasure(ParagraphNode node, double room) { + double set = 0; + for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) { + // A line of pictures alone is set at their size, in Word as on the page. + boolean text = line.spans().stream().anyMatch(span -> span.width() > 0 + && span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan run + && run.textStyle() != null && run.textStyle().size() > 0); + if (text) { + set = Math.max(set, widthAtWordsSize(line)); + } + } + return Math.max(room, set * (1 + ONE_LINE_FACE_SLACK)); + } + + /** + * How wide Word sets a line the page laid out: its text grown or shrunk to Word's size, its + * tracking, pictures and shapes as the page has them. + */ + private static double widthAtWordsSize(com.demcha.compose.document.layout.payloads.ParagraphLine line) { + double set = 0; + for (com.demcha.compose.document.layout.payloads.ParagraphSpan span : line.spans()) { + if (!(span.width() > 0)) { + continue; + } + 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; + } else { + set += span.width(); + } + } + return set; } /** How far inside the page's measure, grown or shrunk as Word sets it, Word's is held, in points. */ @@ -4771,22 +4839,15 @@ private double wordsMeasure(ParagraphNode node, double room) { 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 (span.width() > 0) { + asked += span.width(); + text |= span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan run + && run.textStyle() != null && run.textStyle().size() > 0; } } + double set = widthAtWordsSize(line); if (text) { share = Double.isNaN(share) ? set / asked : Math.max(share, set / asked); fits = Math.max(fits, set); 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 index 265a1ce4f..81536eae2 100644 --- 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 @@ -75,6 +75,116 @@ void aCentredLineIsLeftWhereThePageSetsIt() throws Exception { assertThat(indentOf(7.8, TextAlign.CENTER, LONG)).isZero(); } + @Test + void aShortCentredLineIsLeftWhereThePageSetsIt() throws Exception { + // Its box has room for it as Word sets it, so neither edge moves. + try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page + .addParagraph(p -> p.text(SHORT).textStyle(DocumentTextStyle.DEFAULT.withSize(7.8)) + .align(TextAlign.CENTER)))) { + XWPFParagraph paragraph = text(document, "Platform engineer"); + + assertThat(leftIndent(paragraph)).isZero(); + assertThat(rightIndent(paragraph)).isZero(); + } + } + + @Test + void aCentredLineThatFillsItsBoxIsGivenRoomAtBothEdgesAlike() throws Exception { + // VioletGrid's 6.8pt "INFORMATION ARCHITECTURE" fills its tile; set at 7pt it broke in two. + double room = fillingRoom(HEADING, 6.8); + try (XWPFDocument document = filledBy(HEADING, 6.8, TextAlign.CENTER, room)) { + XWPFParagraph heading = text(document, "INFORMATION"); + double measure = room - (leftIndent(heading) + rightIndent(heading)) / 20.0; + + assertThat(leftIndent(heading)).as("both edges move").isNegative(); + assertThat(Math.abs(leftIndent(heading) - rightIndent(heading))).as("alike, so its centre stays") + .isLessThanOrEqualTo(1); + assertThat(measure).as("the line fits at Word's 7pt").isGreaterThan(widthAlone(HEADING, 6.8) * 7 / 6.8); + } + } + + @Test + void aRightAlignedLineThatFillsItsBoxIsGivenRoomAtItsLeft() throws Exception { + double room = fillingRoom(HEADING, 6.8); + try (XWPFDocument document = filledBy(HEADING, 6.8, TextAlign.RIGHT, room)) { + XWPFParagraph heading = text(document, "INFORMATION"); + + assertThat(rightIndent(heading)).as("its edge stays").isZero(); + assertThat(room - leftIndent(heading) / 20.0).as("the line fits at Word's 7pt") + .isGreaterThan(widthAlone(HEADING, 6.8) * 7 / 6.8); + } + } + + @Test + void aRightAlignedLineFillingACellTakesNoTextPastTheCellsLeftEdge() throws Exception { + // Word draws no text left of a cell's edge: the room the left indent cannot give comes + // from the right. + double column = fillingRoom(HEADING, 6.8); + try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page + .addRow("Tile", row -> row + .columns(DocumentRowColumn.fixed(column), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text(HEADING).textStyle(DocumentTextStyle.DEFAULT.withSize(6.8)) + .align(TextAlign.RIGHT)) + .addParagraph("Main")))) { + var cell = document.getTables().get(0).getRow(0).getCell(0); + XWPFParagraph heading = cell.getParagraphs().stream() + .filter(p -> p.getText().startsWith("INFORMATION")).findFirst().orElseThrow(); + long cellWidth = DocxTwips.of(cell.getCTTc().getTcPr().getTcW().getW()); + + assertThat(leftIndent(heading)).as("nothing past the cell's left edge").isGreaterThanOrEqualTo(0); + assertThat((cellWidth - leftIndent(heading) - rightIndent(heading)) / 20.0) + .as("the line still fits at Word's 7pt").isGreaterThan(widthAlone(HEADING, 6.8) * 7 / 6.8); + } + } + + @Test + void aRightAlignedParagraphOfSeveralLinesIsLeftWhereThePageSetsIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page + .addParagraph(p -> p.text(LONG).textStyle(DocumentTextStyle.DEFAULT.withSize(7.8)) + .align(TextAlign.RIGHT)))) { + XWPFParagraph paragraph = text(document, "Platform engineer"); + + assertThat(leftIndent(paragraph)).isZero(); + assertThat(rightIndent(paragraph)).isZero(); + } + } + + @Test + void aCellsLeftIndentGivesWhatItHasAndTheRightTheRest() throws Exception { + // Padded 2.5pt, the paragraph keeps half a point of indent past the editor's slack. + double column = fillingRoom(HEADING, 6.8) + 2.5; + try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page + .addRow("Tile", row -> row + .columns(DocumentRowColumn.fixed(column), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text(HEADING).textStyle(DocumentTextStyle.DEFAULT.withSize(6.8)) + .padding(new DocumentInsets(0, 0, 0, 2.5)).align(TextAlign.RIGHT)) + .addParagraph("Main")))) { + var cell = document.getTables().get(0).getRow(0).getCell(0); + XWPFParagraph heading = cell.getParagraphs().stream() + .filter(p -> p.getText().startsWith("INFORMATION")).findFirst().orElseThrow(); + long cellWidth = DocxTwips.of(cell.getCTTc().getTcPr().getTcW().getW()); + + assertThat(leftIndent(heading)).as("all the half point it had, and no more").isZero(); + assertThat(rightIndent(heading)).as("the rest from the right").isNegative(); + assertThat((cellWidth - leftIndent(heading) - rightIndent(heading)) / 20.0) + .as("the line still fits at Word's 7pt").isGreaterThan(widthAlone(HEADING, 6.8) * 7 / 6.8); + } + } + + @Test + void aCentredLineOfAPictureAloneIsLeftAsItIs() throws Exception { + // A picture is written at its size and takes the same room in Word. + try (XWPFDocument document = DocxExports.withLayout(150.5 + 40, 300, 20, page -> page + .addParagraph(p -> p.rich(rich -> rich.image( + com.demcha.compose.document.image.DocumentImageData.fromBytes(pngBytes()), 150, 20)) + .align(TextAlign.CENTER)))) { + XWPFParagraph picture = document.getParagraphs().get(0); + + assertThat(leftIndent(picture)).isZero(); + assertThat(rightIndent(picture)).isZero(); + } + } + @Test void theMeasureIsTheOneTheLineThatGrowsMostNeeds() throws Exception { // EngineeringResume's projects: a 7.35pt title, set at 7.5, fills most of the first @@ -254,6 +364,27 @@ private static long rightIndent(XWPFParagraph paragraph) { return indent == null || !indent.isSetRight() ? 0 : DocxTwips.of(indent.getRight()); } + private static final String HEADING = "INFORMATION ARCHITECTURE"; + + /** A measure the line fills at the page's size, with less than Word's size needs to spare. */ + private static double fillingRoom(String line, double size) { + return widthAlone(line, size) + 0.5; + } + + private static XWPFDocument filledBy(String line, double size, TextAlign align, double room) throws Exception { + return DocxExports.withLayout(room + 40, 300, 20, page -> page + .addParagraph(p -> p.text(line).textStyle(DocumentTextStyle.DEFAULT.withSize(size)).align(align))); + } + + private static double widthAlone(String text, double size) { + return laidOut(500, p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(size))).get(0).width(); + } + + private static long leftIndent(XWPFParagraph paragraph) { + CTInd indent = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getInd(); + return indent == null || !indent.isSetLeft() ? 0 : DocxTwips.of(indent.getLeft()); + } + 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.";