Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A DOCX export anchors a drawing in a paragraph of the block it belongs to, so it moves with
that block when the text above is edited.**
- A shape stood in the paragraph whose text was nearest it, within 48pt, rising above that
paragraph's top by 24pt at most — in a table cell by a stroke's width —, and otherwise was
placed from the page's edges, where it stayed while the text moved away from it: an icon laid
over a card's corner, a sidebar's portrait, a skill's meter beside its label in a row, a
timeline's dots in a column of their own.
- A drawing a node paints is now anchored in the first paragraph able to carry it that the
nearest block holding it writes on its page — the node itself, a layer stack, a panel, a row,
but not the page's flow —, placed from that paragraph's top however far from its text it
stands. In a table cell, the cell of that paragraph's row holding the drawing across takes it,
its own where it stands in a column of its own; another cell keeps it placed out of its
column, unless it holds text — a text box, a badge's letters —, which LibreOffice 26.8 pulls
back into the column. A cell set to the middle or the bottom carries none: the block's next
paragraph able to place it takes the drawing, or else the next block up. A drawing no node
paints — a timeline's rail — and one whose blocks write no such paragraph on its page go to
the paragraph nearest it there. What a table's composed cell draws keeps the nearest-text
rule. The report's note on each drawing says how it is anchored.
- Measured in Word 16.0.20527 and LibreOffice 24.2: a shape anchored in a table cell's
paragraph stands where both say — rising 36pt above its row, hanging below its table, out of
its cell, taller than its row, in an exact row or a nested table, in front of the text or
behind it, and across the fidelity corpus from 155pt above its paragraph to 620pt below —;
neither clips it nor moves a line for it, and both move it with its row when the text above
is edited. LibreOffice 24.2 sets a cell carrying a drawing from its top whatever its vertical
alignment says, 16pt high in a cell set to the middle and 32pt in one set to the bottom.
- Of the 465 drawings the fidelity corpus placed on the page, 270 were drawn by a node, 5 were
timelines' rails and 190 were composed in table cells. All but 14 of the first 275 are now
anchored in a paragraph: the text boxes of `LumaStudio`'s sidebar and closing note, which no
paragraph of their column holds across, and two drawings on pages whose only text is a pair
of lines.

- **A DOCX export writes a painted box holding text in the body as a panel, its text inside its
outline.**
- A shape container shaped as a rectangle or a rounded rectangle, filled or outlined, holding
Expand Down
Binary file modified assets/readme/examples/word-export-companion.docx
Binary file not shown.
4 changes: 2 additions & 2 deletions docs/architecture/backend-capability-matrix.md

Large diffs are not rendered by default.

85 changes: 53 additions & 32 deletions docs/recipes/docx-export.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,36 @@ String pathOf(DocumentNode node) {
return node == null ? null : paths.get(node);
}

/**
* The paths of a node and of the blocks holding it, nearest first: where a drawing it paints
* looks for the paragraph that carries it ({@code DocxDrawingAnchors}).
*
* <p>A container the tree starts at is the page's flow, holding every block on its pages and
* so none in particular: it is left out. Any other node the tree starts at — a section or a
* stack added at the root rather than in a flow — is a block and stays, as does a container
* further in. A path the tree does not name — a pass of the layout's own, a timeline's rail —
* has no holders.</p>
*
* @param path a node's path ({@link #pathOf})
* @return its holders' paths, itself first; empty for a path no node of the tree has
*/
List<String> holdersOf(String path) {
if (path == null || !nodesByPath().containsKey(path)) {
return List.of();
}
List<String> holders = new ArrayList<>();
for (String holder = path; holder != null; ) {
int last = holder.lastIndexOf('/');
String above = last < 0 ? null : holder.substring(0, last);
if (above == null && nodesByPath().get(holder) instanceof com.demcha.compose.document.node.ContainerNode) {
break;
}
holders.add(holder);
holder = above;
}
return holders;
}

/**
* The height of one line of the paragraph's text, as the engine measured it.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,22 @@ public final class DocxSemanticBackend implements SemanticBackend<byte[]> {
* sets lines on its 0.12pt grid, and a twip of margin moves a line a step or nothing.
*/
private static final double LEAST_ROW_TEXT_MOVE = 0.1;
/** How a drawing the page paints is anchored, as the report says it (see DocxDrawingAnchors). */
/**
* How a drawing a table's composed cell paints is anchored, as the report says it: such a cell
* has no node of its own to be held by (see DocxDrawingAnchors).
*/
private static final String ANCHORED_BESIDE_ITS_TEXT = "anchored in the paragraph whose text it stands "
+ "beside, so it moves with that text when the text above is edited, or to the page where it "
+ "stands beside none";
/** How a drawing a node paints is anchored, as the report says it (see DocxDrawingAnchors). */
private static final String ANCHORED_IN_ITS_BLOCK = "anchored in the first paragraph able to carry it "
+ "that the nearest block holding it writes on its page, so it moves with that block when the text "
+ "above is edited; in the paragraph nearest it where no block holding it writes one there, or to "
+ "the page where no paragraph on its page can carry it";
/** How a drawing no node paints — a timeline's rail — is anchored, as the report says it. */
private static final String ANCHORED_IN_THE_NEAREST = "anchored in the paragraph nearest it on its page, "
+ "so it moves with that paragraph when the text above is edited, or to the page where no "
+ "paragraph on its page can carry it";
/**
* The report's subject for a translucent colour Word holds only opaque — a cell's shading, a
* border — flattened against the colour under it (see {@link DocxTranslucency}).
Expand Down Expand Up @@ -452,7 +464,7 @@ public final class DocxSemanticBackend implements SemanticBackend<byte[]> {
private final java.util.Map<PanelOverPages, PanelCuts> cutsDecided = new java.util.IdentityHashMap<>();
// Where the shapes this export draws are anchored — see DocxDrawingAnchors.
private final DocxDrawingAnchors anchors = new DocxDrawingAnchors(() -> nextDrawingId++,
paragraph -> grownLines.lineOf(paragraph));
paragraph -> grownLines.lineOf(paragraph), path -> this.layout.holdersOf(path));
// The page the node being written starts on, as the layout placed it, counted within the
// section being written.
private int currentPage;
Expand Down Expand Up @@ -1314,7 +1326,7 @@ private byte[] write(List<SemanticSection> sections, Path outputFile) throws Exc
drawingFor = new DocxDrawingAnchors.Owner("timeline rail", pass.path());
if (queueDrawing(pass, drawsInFront()) && railsNamed.add(pass.path())) {
report.add(DocxExportReport.Severity.APPROXIMATED, "timeline rail", pass.path(),
"drawn as a line where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT);
"drawn as a line where the layout puts it, " + ANCHORED_IN_THE_NEAREST);
}
drawingFor = null;
}
Expand Down Expand Up @@ -5073,7 +5085,7 @@ private boolean drawAndNameOwnFragments(DocumentNode node) {
StringBuilder message = new StringBuilder(drewInCell
? "drawn as a shape anchored in the table cell it fills, where the layout puts it "
+ "in the cell: it moves with the row"
: "drawn as a shape where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT);
: "drawn as a shape where the layout puts it, " + ANCHORED_IN_ITS_BLOCK);
if (badgeText != null) {
message.append("; its text is held in the shape, which stands in front of the text");
} else if (front) {
Expand Down Expand Up @@ -9838,8 +9850,8 @@ private void offerASeat(XWPFParagraph para) {
}
// The baseline the page draws the first line on, seated as the page seats it.
layout.firstTextBox(node).ifPresent(box ->
anchors.seat(box.page(), node, para, box.left(), box.right(), canvasHeight - box.top(),
canvasHeight - box.baseline() - seatShift(node)));
anchors.seat(box.page(), node, layout.pathOf(node), para, box.left(), box.right(),
canvasHeight - box.top(), canvasHeight - box.baseline() - seatShift(node)));
}

/**
Expand Down Expand Up @@ -15517,7 +15529,7 @@ private void drawWhereThePagePutsIt(XWPFDocument document, ImageNode image, byte
StringBuilder message = new StringBuilder(drewInCell
? how + ", anchored in the table cell it fills, where the layout puts it in the cell: "
+ "it moves with the row"
: how + ", where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT);
: how + ", where the layout puts it, " + ANCHORED_IN_ITS_BLOCK);
for (String lost : carriedWithout(image, false)) {
message.append("; ").append(lost);
}
Expand Down Expand Up @@ -16018,7 +16030,7 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception
} finally {
// What no cell took is drawn where the page puts it, in the order the table paints it.
if (tableDrawings != outerDrawings) {
DocxDrawingAnchors.Owner table = new DocxDrawingAnchors.Owner("cell drawing", layout.pathOf(node));
DocxDrawingAnchors.Owner table = new DocxDrawingAnchors.Owner("cell drawing", layout.pathOf(node), true);
for (CellFragment waiting : tableDrawings) {
anchors.queueOrdered(waiting.shapes(), table);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,9 @@ void aDrawingWithAMarginStaysOnThePage() throws Exception {
}

@Test
void aLineAloneInACellOfARowInTheFlowStaysOnThePage() throws Exception {
void aLineAloneInACellOfARowInTheFlowIsNotHeldToItsHeightButGoesToItsRow() throws Exception {
// A line may be a rule, so its cell's paragraph is not set to its height as a drawing
// alone in a cell is; drawn, it is its row's, and its own cell of that row carries it.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addParagraph(p -> p.text("Above"))
.addRow("Ruled", row -> row.weights(150, 150)
Expand All @@ -242,10 +244,15 @@ void aLineAloneInACellOfARowInTheFlowStaysOnThePage() throws Exception {
.stroke(DocumentStroke.of(DocumentColor.rgb(200, 200, 200), 1)).build())
.build())
.addParagraph(p -> p.text("LABEL"))))) {
String body = document.getDocument().xmlText();

assertThat(body).as("no drawing taken into a cell").doesNotContain("layoutInCell=\"1\"");
assertThat(shapesOutOfTheirCells(document)).as("the line, out of the cell as before").isEqualTo(1);
XWPFParagraph carrier = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0);

assertThat(anchors(carrier)).as("the line, in its own cell, from the row's top").singleElement()
.satisfies(line -> assertThat(line.fromV()).isEqualTo("paragraph"));
assertThat(carrier.getCTP().isSetPPr() && carrier.getCTP().getPPr().isSetSpacing()
&& carrier.getCTP().getPPr().getSpacing().isSetLine()
&& DocxTwips.of(carrier.getCTP().getPPr().getSpacing().getLine()) == 20)
.as("its paragraph not held to the line's height").isFalse();
assertThat(shapesOutOfTheirCells(document)).isZero();
}
}

Expand Down
Loading
Loading