diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a544bbc..c645c3a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A value set from its start keeps its start in Word.** Two texts one layer holds on one line — + a label and its value, a title and its dates — are written as one Word line split by a tab + stop, and the stop was always a right tab at the right text's end. A value aligned left in a + layer placed from the left starts at its column whatever its length; after a right tab it + started wherever the editor's slightly narrower setting of it ended up: `LumaStudioInvoice`'s + IBAN stood 2.6pt right of the bank details over it. Such a value is now held by a left tab at + its start; a value set against its end, as a date at the right of a band is, keeps its right + tab. In Word the values of `LumaStudioInvoice`'s and `ConsultingInvoice`'s detail rows start + within 0.1pt of the page's. - **Text seated off its baseline sits there in Word, and text hanging below its band takes its overhang from the gap under it.** A paragraph set with `TextVerticalAlign.TOP`, `CENTER` or `BOTTOM` was written on its baseline: `LumaStudioInvoice`'s "INVOICE" is set against the top diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 432cd5a25..13b612195 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -507,11 +507,14 @@ tint it was flattened to. Recorded, like the other two. spare on the side its text does not lean on. A block in `addAligned(...)` is held in the same way, to where the alignment puts it. Drawing is drawn as a shape where the page draws it (see "What is skipped") and does not count as content. -- **Text at the left and the right of one band → one line with a right tab.** A +- **Text at the left and the right of one band → one line split by a tab.** A container or a layer stack holding exactly two single-line paragraphs, level with one another and apart across the page — a CV entry's title and its dates — is one Word paragraph: the left paragraph's runs, a tab, the right one's, with a - right-aligned tab stop where the right text ends on the page. Its line runs from the + right-aligned tab stop where the right text ends on the page. A right text set from + its start — aligned left in a layer placed from the left, with room past its end, as + a bank detail's value is — takes a left-aligned stop where it starts instead, so an + editor setting it a little narrower moves its end, not its start. Its line runs from the top of the higher text to the bottom of the lower; text standing out of its band takes that much from the gap on that side, as it does on the page. A Word paragraph cannot reach above its cell, so text standing above a cell of a table's first row diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java index 74c225e69..ea002f157 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java @@ -4,10 +4,13 @@ import com.demcha.compose.document.layout.PlacedNode; import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.EllipseNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.node.LayerStackNode; import com.demcha.compose.document.node.LineNode; import com.demcha.compose.document.node.ParagraphNode; import com.demcha.compose.document.node.PathNode; import com.demcha.compose.document.node.PolygonNode; +import com.demcha.compose.document.node.ShapeContainerNode; import com.demcha.compose.document.node.ShapeNode; import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.node.TextDirection; @@ -22,8 +25,10 @@ * line, as two layers of one container. Word has no layers, so the export wrote the layers * one after the other, and the dates came out on a line of their own under the title: an * extra line in every entry, enough to push a one-page CV onto a second page. A line with - * text at the left and more at the right is what Word builds with a right-aligned tab stop, - * so that is what the pair is written as: the left paragraph's runs, a tab, the right one's.
+ * text at the left and more at the right is what Word builds with a tab stop, so that is what + * the pair is written as: the left paragraph's runs, a tab, the right one's. The stop is a + * right-aligned one at the right text's end, or a left-aligned one at its start when the text + * is set from its start (see {@link Pair#fromItsStart}). * *An overlay is taken as such a pair when it holds exactly two paragraphs, each laid out
* on one line, level with one another on the page and apart across it, and nothing else but
@@ -35,9 +40,10 @@ final class DocxLinePair {
private static final double EDGE = 0.5;
/**
- * The room the two texts must leave between them, in points. An editor sets text a little
- * wider or narrower than the page does, and a right-aligned tab the left text runs past
- * sends the right one onto a line of its own — under an exact line height, out of sight.
+ * The room the two texts must leave between them, in points, and a text set from its start
+ * after its end. An editor sets text a little wider or narrower than the page does: a tab
+ * the left text runs past, or a right text run past the line's end, sends a word onto a line
+ * of its own — under an exact line height, out of sight.
*/
private static final double MIN_GAP = 4;
@@ -50,7 +56,13 @@ private DocxLinePair() {
* @param left the paragraph at the left
* @param right the paragraph at the right
* @param leftOffset where the left paragraph starts, from the overlay's left edge
- * @param tabStop where the right paragraph ends, from the overlay's left edge
+ * @param tabStop where the right paragraph's tab stop stands, from the overlay's left
+ * edge: where its text starts when {@code fromItsStart}, where it ends
+ * otherwise
+ * @param fromItsStart whether the right text is set from its start — left-aligned in a
+ * layer placed from the left, with room past its end — so a left tab
+ * stop holds its start where the page puts it, and an editor setting it
+ * narrower or wider moves its end, as on the page, not its start
* @param line the height the two lines take together, from the top of the higher
* to the bottom of the lower
* @param above from the overlay's top down to that top — negative where the text
@@ -59,7 +71,7 @@ private DocxLinePair() {
* text hangs below it
*/
record Pair(ParagraphNode left, ParagraphNode right, double leftOffset, double tabStop,
- double line, double above, double below) {
+ boolean fromItsStart, double line, double above, double below) {
}
/**
@@ -117,14 +129,44 @@ static Pair of(DocumentNode overlay, DocxLayoutMetrics layout) {
double textTop = Math.max(first.placementY() + first.placementHeight(),
second.placementY() + second.placementHeight());
double textBottom = Math.min(first.placementY(), second.placementY());
- return new Pair(firstIsLeft ? text.get(0) : text.get(1), firstIsLeft ? text.get(1) : text.get(0),
+ ParagraphNode rightText = firstIsLeft ? text.get(1) : text.get(0);
+ // Held by its start, its end runs free: an editor setting it wider breaks its last word
+ // onto a line of its own unless the overlay leaves room past it. Held by its end, it
+ // cannot, as before.
+ boolean fromItsStart = setFromItsStart(overlay, rightText)
+ && box.placementX() + box.placementWidth() - right[1] >= MIN_GAP;
+ return new Pair(firstIsLeft ? text.get(0) : text.get(1), rightText,
Math.max(0, left[0] - box.placementX()),
- right[1] - box.placementX(),
+ (fromItsStart ? right[0] : right[1]) - box.placementX(),
+ fromItsStart,
textTop - textBottom,
boxTop - textTop,
textBottom - box.placementY());
}
+ /**
+ * Whether a paragraph's text is set from its start: aligned left in a layer placed from the
+ * overlay's left. A bank detail's value set at a column across the card starts there
+ * whatever its length; written after a right tab at its end, as a date at the right of a band
+ * is, it started wherever the editor's narrower setting of it ended up — "GB36 SRLG …" a few
+ * points right of the values over it.
+ */
+ private static boolean setFromItsStart(DocumentNode overlay, ParagraphNode paragraph) {
+ if (paragraph.align() != TextAlign.LEFT) {
+ return false;
+ }
+ List The line starts where the left paragraph does and runs from the top of the higher
* text to the bottom of the lower, so the band keeps its place in the flow: the space
@@ -4061,7 +4062,9 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine
}
CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
CTTabStop tab = (properties.isSetTabs() ? properties.getTabs() : properties.addNewTabs()).addNewTab();
- tab.setVal(STTabJc.RIGHT);
+ // A right text set from its start holds that start; one set against its end, as a date
+ // at the right of a band is, holds its end (see DocxLinePair.Pair#fromItsStart).
+ tab.setVal(pair.fromItsStart() ? STTabJc.LEFT : STTabJc.RIGHT);
tab.setPos(BigInteger.valueOf(toTwips(lineStart + pair.tabStop())));
// Each half is still the paragraph it was: its outline level, its bookmark around its
// own text, and whether it keeps with what follows.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java
index cc1bed48a..1241461bf 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java
@@ -46,6 +46,70 @@ void aTitleAtTheLeftAndDatesAtTheRightAreOneLineSplitByARightTab() throws Except
}
}
+ @Test
+ void aValueSetFromItsStartIsHeldThereByALeftTab() throws Exception {
+ // A bank detail's value starts at a column across the card whatever its length: a right
+ // tab at its end let an editor's narrower setting of a long IBAN start it further right.
+ DocumentNode row = new com.demcha.compose.document.dsl.ShapeContainerBuilder()
+ .name("BankRow")
+ .rectangle(CONTENT, 14)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(paragraph("IBAN", TextAlign.LEFT), 0, 0, LayerAlign.CENTER_LEFT)
+ .position(paragraph("GB36 SRLG 6083 7198 7654 32", TextAlign.LEFT), 120, 0, LayerAlign.CENTER_LEFT)
+ .build();
+ try (XWPFDocument document = export(row)) {
+ XWPFParagraph line = written(document).get(0);
+ CTPPr properties = line.getCTP().getPPr();
+
+ assertThat(line.getText()).isEqualTo("IBAN\tGB36 SRLG 6083 7198 7654 32");
+ assertThat(properties.getTabs().getTabArray(0).getVal().toString()).isEqualTo("left");
+ assertThat(DocxTwips.of(properties.getTabs().getTabArray(0).getPos()))
+ .as("where the value starts on the page")
+ .isCloseTo(120L * 20, org.assertj.core.data.Offset.offset(2L));
+ }
+ }
+
+ @Test
+ void aValueSetFromItsEndOrRightUpToTheEdgeKeepsItsRightTab() throws Exception {
+ // Placed from the right, the value's end is where the page holds it; placed from the
+ // left but running to the row's end, a left tab would leave an editor's wider setting of
+ // it no room, and its last word would go onto a line of its own.
+ String value = "GB36 SRLG 6083 7198 7654 32";
+ assertThat(tabOf(new com.demcha.compose.document.dsl.ShapeContainerBuilder().name("FromTheRight")
+ .rectangle(CONTENT, 14).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(paragraph("IBAN", TextAlign.LEFT), 0, 0, LayerAlign.CENTER_LEFT)
+ .position(paragraph(value, TextAlign.LEFT), -30, 0, LayerAlign.CENTER_RIGHT)
+ .build())).as("placed from the right, room to spare past it").isEqualTo("right");
+ double valueWidth = widthOf(value);
+ assertThat(tabOf(new com.demcha.compose.document.dsl.ShapeContainerBuilder().name("UpToTheEdge")
+ .rectangle(CONTENT, 14).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(paragraph("IBAN", TextAlign.LEFT), 0, 0, LayerAlign.CENTER_LEFT)
+ .position(paragraph(value, TextAlign.LEFT), CONTENT - valueWidth - 1, 0, LayerAlign.CENTER_LEFT)
+ .build())).as("from the left, a point short of the edge").isEqualTo("right");
+ }
+
+ /** The kind of the tab stop an overlay's pair is written with. */
+ private static String tabOf(DocumentNode row) throws Exception {
+ try (XWPFDocument document = export(row)) {
+ return written(document).get(0).getCTP().getPPr().getTabs().getTabArray(0).getVal().toString();
+ }
+ }
+
+ /** How wide the page sets the text as a 9pt line, from its laid-out line. */
+ private static double widthOf(String text) throws Exception {
+ try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document()
+ .pageSize(PAGE_WIDTH, 600).margin(DocumentInsets.of(MARGIN)).create()) {
+ session.pageFlow(flow -> flow.add(paragraph(text, TextAlign.LEFT)));
+ return session.layoutGraph().fragments().stream()
+ .map(fragment -> fragment.payload())
+ .filter(payload -> payload
+ instanceof com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload)
+ .map(payload -> ((com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload) payload)
+ .lines().get(0).width())
+ .findFirst().orElseThrow();
+ }
+ }
+
@Test
void aPairInALayerStackIsOneLineTooNotABandOfTwo() throws Exception {
try (XWPFDocument document = export(new com.demcha.compose.document.dsl.LayerStackBuilder()