Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.</p>
* 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}).</p>
*
* <p>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
Expand All @@ -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;

Expand All @@ -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
Expand All @@ -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) {
}

/**
Expand Down Expand Up @@ -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<LayerStackNode.Layer> layers = overlay instanceof LayerStackNode stack ? stack.layers()
: overlay instanceof ShapeContainerNode container ? container.layers()
: List.of();
for (LayerStackNode.Layer layer : layers) {
if (layer.node() == paragraph) {
return layer.align() == LayerAlign.TOP_LEFT || layer.align() == LayerAlign.CENTER_LEFT
|| layer.align() == LayerAlign.BOTTOM_LEFT;
}
}
return false;
}

/**
* Where across the page a one-line paragraph's text runs: its line, placed in its box by
* its alignment. A paragraph's box is often as wide as the band while its text is not — a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4026,8 +4026,9 @@ private void applyInset(XWPFParagraph para) {
}

/**
* Writes an overlay's left and right paragraph as one line, the right one after a
* right-aligned tab stop where it ends on the page (see {@link DocxLinePair}).
* Writes an overlay's left and right paragraph as one line, the right one after a tab stop:
* a right-aligned one where it ends on the page, or a left-aligned one where it starts when
* it is set from its start (see {@link DocxLinePair}).
*
* <p>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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading