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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A line pulled up into the line above it, with no space above to take the pull from, is no
longer dropped in Word.** A paragraph's negative top edge comes out of the space owed above
it, and where there is none — the two lines of a lockup in a table cell — it was dropped:
`CobaltRota`'s subtitle, 6.5pt up under its 21pt wordmark, stood that much low, and its
masthead row and the rota under it with it. The pull now comes off the foot of the line of
text written just before, on the same page — one exact line alone in its paragraph, with no
space below it and no run shaded or underlined — as far as its letters leave room where Word
draws them, three quarters of a point clear: the line written shorter, its text lowered by
the four fifths of that Word's baseline would rise. The rest comes off the top of the pulled
line itself, as far as its letters allow; what neither can give is not written. `CobaltRota`'s median drift falls from 8.9pt to 2.4 in Word and in LibreOffice; its
lines more than 2pt off fall from 65 to 45 in Word and from 62 to 47 in LibreOffice.

- **A chip's label taller than the chip no longer makes it taller in Word.** A shape composed in
a table cell centres its one line of text in it, and the page lets the line pass the outline
where it is taller: `CobaltRota`'s stacked shift chips, 9.2pt outlines round 8.2pt text on a
Expand Down
12 changes: 10 additions & 2 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,16 @@ a table (not a row) opening a cell still loses that edge.

An edge pulling a block up — a paragraph's, a page reference's or a rule's negative top
`margin` or `padding` — comes out of the space owed above it, as the page takes it from
there: Word has no negative space above a paragraph. What that space cannot give is not
written. Text laid over the flow in text boxes owes no space, so its edges move nothing.
there: Word has no negative space above a paragraph. What that space cannot give a paragraph
takes from the foot of the line of text written just before it on the same page — one exact
line alone in its paragraph, not one of a stack, with no space below it and no run shaded or
underlined, as shading fills the line and an underline is drawn below the letters — as far as
that line's letters leave room below them where Word draws them, three quarters of a point
clear: the line written shorter, its text lowered by the four fifths of that Word's baseline
would rise. The rest it takes from the top of its own line, where it is one line of text, as
far as its letters leave room above them, its text seated where the page sets it; what neither
can give is not written. Text laid over the flow in text boxes owes no space, so its edges
move nothing.

A paragraph's or a list's `lineSpacing` — the gap the page puts between two wrapped lines —
goes into the line: Word has one line height for a paragraph and no gap between its lines,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@ public final class DocxSemanticBackend implements SemanticBackend<byte[]> {
// The last paragraph written into the body, so a container can hand it the space it
// holds below itself once its children are done.
private XWPFParagraph lastBodyParagraph;
// The paragraph writeParagraph wrote last, and the Word paragraph it wrote it in: a paragraph
// pulled up into it takes the pull off its foot (takeFromTheLineAbove), while nothing else has
// been written since — lastBodyParagraph is that same Word paragraph then.
private ParagraphNode lastWrittenNode;
private XWPFParagraph lastWrittenParagraph;
// The empty paragraph closing the last table written into the cell being filled, while
// nothing has been written after it; see newTable.
private XWPFParagraph tableCloser;
Expand Down Expand Up @@ -718,6 +723,8 @@ private void beginSection(SemanticSection section, int index) {
anItemWasWritten = false;
forgetTheHang();
lastBodyParagraph = null;
lastWrittenNode = null;
lastWrittenParagraph = null;
contentWidth = context.canvas() == null ? Double.MAX_VALUE : context.canvas().innerWidth();
canvasHeight = context.canvas() == null ? Double.NaN : context.canvas().height();
}
Expand Down Expand Up @@ -3821,15 +3828,129 @@ private static boolean drawn(CTBorder border) {
* past its content: the pull was lost, and the rule and the page under it stood 10pt low,
* the 16.4pt less the 6.3pt its metadata grid had lost above it. The pull is added
* to the edges carried down to the block, as a container's negative edge already is, so
* {@link #newBodyParagraph} nets it against everything owed above, as the page sums it;
* what that cannot give stays unwritten, as before. Text laid over the flow owes no
* {@link #newBodyParagraph} nets it against everything owed above, as the page sums it.
* Where that cannot give it all, a paragraph takes the rest off the foot of the line written
* just before it ({@link #takeFromTheLineAbove}), then off the top of its own line
* ({@link #riseInsideItsOwnLine}); what neither can give stays unwritten. Text laid over the flow owes no
* space ({@link #writeOverTheFlow}), so its edges move nothing.</p>
*/
private void standsIntoTheSpaceAbove(DocumentNode node) {
double edge = node.margin().top() + node.padding().top();
if (edge < 0) {
carriedSpacingBefore += edge;
double shortBy = -(carriedSpacingBefore + pendingSpacingAfter - borderBelow);
if (shortBy > 0.01 && node instanceof ParagraphNode paragraph) {
shortBy -= takeFromTheLineAbove(shortBy, paragraph);
if (shortBy > 0.01) {
riseInsideItsOwnLine(paragraph, shortBy);
}
}
}
}

/**
* Takes up to {@code points} off the foot of the one line written just before, where its
* letters leave that room, keeping its text where it stood; how much it took is given back to
* the space above the next block.
*
* <p>A paragraph pulled up into the one above it, with nothing above it to give the pull —
* the two lines of a lockup in a table cell, {@code CobaltRota}'s subtitle 6.5pt up under
* its 21pt wordmark — stood that much low in Word, and the row and the page under it with
* it. The line above is written shorter by what its letters do not reach, and its text,
* which Word would raise with the baseline of a shorter exact line, lowered as much.</p>
*
* <p>Only an exact line of text alone in its paragraph, not one of a stack, with no space
* written below it and no run shaded or underlined: a shaded run fills the line in Word, and
* an underline is drawn below the letters. Where both are laid out, the line must end on the
* page the pulled paragraph starts on: the last line of a page shortened would move where
* Word breaks it.</p>
*
* @return how far the line above was shortened, in points
*/
private double takeFromTheLineAbove(double points, ParagraphNode pulled) {
ParagraphNode above = lastWrittenNode;
XWPFParagraph para = lastWrittenParagraph;
if (above == null || para == null || para != lastBodyParagraph || layout.lineCount(above) != 1
|| stackedLineHeights.containsKey(above)) {
return 0;
}
com.demcha.compose.document.layout.PlacedNode abovePlaced = layout.placement(above);
com.demcha.compose.document.layout.PlacedNode pulledPlaced = layout.placement(pulled);
if (abovePlaced != null && pulledPlaced != null && abovePlaced.endPage() != pulledPlaced.startPage()) {
return 0;
}
for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) {
// A shaded run fills the line; an underline is drawn below the letters' ink.
if (run.isSetRPr() && (run.getRPr().sizeOfShdArray() > 0 || run.getRPr().sizeOfUArray() > 0
&& run.getRPr().getUArray(0).getVal() != org.openxmlformats.schemas.wordprocessingml.x2006.main.STUnderline.NONE)) {
return 0;
}
}
CTPPr properties = para.getCTP().getPPr();
CTSpacing spacing = properties != null && properties.isSetSpacing() ? properties.getSpacing() : null;
Long line = spacing != null && spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT
? writtenTwips(spacing.getLine()) : null;
java.util.Optional<com.demcha.compose.document.layout.payloads.ParagraphLine> laid = layout.firstLine(above);
// The letters' reach off their own baseline, before any seat: the position written on
// the runs below carries the seat, as far as it was written.
double[] ink = laid.isEmpty() ? null : DocxInk.of(laid.get(), measuredFonts());
if (line == null || ink == null || spacing.isSetAfter()) {
return 0;
}
// The room under the letters where Word draws them: its baseline stands a fifth of the
// line above the foot, raised by the runs' written position; less the margin, and the
// quarter point the lowering below can round away.
List<org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR> runs = runsIn(para);
long seated = 0;
if (!runs.isEmpty() && runs.get(0).isSetRPr() && runs.get(0).getRPr().sizeOfPositionArray() > 0
&& runs.get(0).getRPr().getPositionArray(0).getVal() instanceof Number number) {
seated = number.longValue();
}
double wordFoot = (1 - DocxTextBands.BASELINE_SHARE) * line / POINT_TO_TWIP + seated / HALF_POINTS_PER_POINT;
double room = wordFoot - ink[1] - DocxStackedLines.INK_MARGIN - 0.5 / HALF_POINTS_PER_POINT;
long taken = Math.round(Math.min(points, room) * POINT_TO_TWIP);
if (taken <= 0 || taken >= line) {
return 0;
}
spacing.setLine(BigInteger.valueOf(line - taken));
// Word stands an exact line's baseline four fifths of the way down it: shorter by the
// taken twips, its text would rise by four fifths of them.
long lowered = Math.round(taken / POINT_TO_TWIP * DocxTextBands.BASELINE_SHARE * HALF_POINTS_PER_POINT);
for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) {
org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr runProperties =
run.isSetRPr() ? run.getRPr() : run.addNewRPr();
org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSignedHpsMeasure position =
runProperties.sizeOfPositionArray() > 0 ? runProperties.getPositionArray(0) : runProperties.addNewPosition();
long raised = position.getVal() instanceof Number number ? number.longValue() : 0;
position.setVal(BigInteger.valueOf(raised - lowered));
}
double takenPoints = taken / POINT_TO_TWIP;
carriedSpacingBefore += takenPoints;
return takenPoints;
}

/**
* Rises a one-line paragraph inside its own line by up to {@code points}, where the room above
* its letters allows, as {@link #riseIntoItsLine} does for a container's first line.
*/
private void riseInsideItsOwnLine(ParagraphNode paragraph, double points) {
if (layout.lineCount(paragraph) != 1 || stackedLineHeights.containsKey(paragraph)) {
return;
}
java.util.OptionalDouble own = layout.lineHeight(paragraph);
java.util.Optional<com.demcha.compose.document.layout.payloads.ParagraphLine> line = layout.firstLine(paragraph);
double[] ink = inkOf(paragraph);
if (own.isEmpty() || line.isEmpty() || ink == null) {
return;
}
double aboveTheLetters = line.get().lineHeight() - line.get().baselineOffsetFromBottom() - ink[0]
- DocxStackedLines.INK_MARGIN;
double taken = Math.min(points, aboveTheLetters);
if (!(taken > 0.01) || taken >= own.getAsDouble()) {
return;
}
stackedLineHeights.put(paragraph, new DocxStackedLines.Line(own.getAsDouble() - taken, -taken, 0));
carriedSpacingBefore += taken;
}

/**
Expand Down Expand Up @@ -4570,6 +4691,8 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
DocxStackedLines.Line stacked = stackedLineHeights.get(node);
writeParagraphRuns(para, node, rightToLeft, stacked == null ? 0 : stacked.topAbove(), stacked == null);
closeAnchor(para, anchor);
lastWrittenNode = node;
lastWrittenParagraph = para;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentRowColumn;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.font.FontName;
import org.apache.poi.xwpf.usermodel.IBodyElement;
Expand Down Expand Up @@ -91,6 +92,109 @@ void aParagraphPulledUpInsideASectionPulledUpAddsToItsPull() throws Exception {
}
}

@Test
void aLinePulledUpIntoTheLineAboveInACellTakesItsFoot() throws Exception {
// CobaltRota's lockup: a subtitle pulled up under its wordmark, in a cell with nothing
// above to give the pull. The wordmark's line, its letters standing on the baseline, is
// written 3.5pt shorter, its text lowered by the four fifths of that Word's baseline would
// rise — 6 half points, where three quarters would round to 5 — and the subtitle has no
// space above it.
DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21).build();
XWPFParagraph[] plain = lockup(wordmark, 0);
XWPFParagraph[] pulled = lockup(wordmark, -3.5);

double shorter = (DocxTwips.of(lineOf(plain[0])) - DocxTwips.of(lineOf(pulled[0]))) / 20.0;
assertThat(shorter).as("the wordmark's line gives up the pull").isCloseTo(3.5, within(0.06));
assertThat(positionOf(pulled[0]) - positionOf(plain[0])).as("its text lowered as its baseline would rise")
.isEqualTo(-6);
assertThat(DocxTwips.of(lineOf(pulled[1]))).as("the pulled line whole, the pull taken above it")
.isEqualTo(DocxTwips.of(lineOf(plain[1])));
}

@Test
void aLineAboveWithItsTextShadedIsLeftWhole() throws Exception {
// A shaded run fills its exact line in Word: shortened, the chip would lose its foot.
XWPFParagraph plain = decoratedLockup(true, false, 0);
XWPFParagraph pulled = decoratedLockup(true, false, -2);

assertThat(pulled.getRuns().get(0).getCTR().getRPr().sizeOfShdArray()).as("a shaded run").isPositive();
assertThat(DocxTwips.of(lineOf(pulled))).as("its line not cut").isEqualTo(DocxTwips.of(lineOf(plain)));
assertThat(positionOf(pulled)).as("nor its text lowered").isEqualTo(positionOf(plain));
}

@Test
void aLineAboveWithItsTextUnderlinedIsLeftWhole() throws Exception {
// Word draws an underline below the letters, in the room the pull would take.
XWPFParagraph plain = decoratedLockup(false, true, 0);
XWPFParagraph pulled = decoratedLockup(false, true, -2);

assertThat(pulled.getRuns().get(0).getCTR().getRPr().sizeOfUArray()).as("an underlined run").isPositive();
assertThat(DocxTwips.of(lineOf(pulled))).as("its line not cut").isEqualTo(DocxTwips.of(lineOf(plain)));
assertThat(positionOf(pulled)).as("nor its text lowered").isEqualTo(positionOf(plain));
}

/** A 21pt wordmark, shaded or underlined, over a 10pt subtitle pulled up by {@code pull}. */
private static XWPFParagraph decoratedLockup(boolean shaded, boolean underlined, double pull) throws Exception {
DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21)
.decoration(underlined ? DocumentTextDecoration.UNDERLINE : DocumentTextDecoration.DEFAULT).build();
DocumentTextStyle subtitle = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addRow("Head", row -> row
.columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1))
.addSection("Lockup", cell -> cell
.addParagraph(p -> {
if (shaded) {
p.inlineHighlight("HALL", wordmark,
DocumentColor.rgb(255, 230, 150), 0, DocumentInsets.zero());
} else {
p.text("HALL").textStyle(wordmark);
}
})
.addParagraph(p -> p.text("QUAYSIDE BAR").textStyle(subtitle)
.margin(new DocumentInsets(pull, 0, 0, 0))))
.addParagraph(p -> p.text("MONDAY").textStyle(subtitle))))) {
return document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0);
}
}

@Test
void aPullDeeperThanTheLettersAboveLeaveRoomForRisesInsideItsOwnLine() throws Exception {
// Under 12pt text the line's foot has about 3pt below the letters; the rest of an 8pt
// pull comes out of the top of the pulled line itself.
DocumentTextStyle small = DocumentTextStyle.builder().fontName(FontName.LATO).size(12).build();
XWPFParagraph[] plain = lockup(small, 0);
XWPFParagraph[] pulled = lockup(small, -8);

assertThat(DocxTwips.of(lineOf(pulled[0]))).as("the line above gave some").isLessThan(DocxTwips.of(lineOf(plain[0])));
assertThat(DocxTwips.of(lineOf(pulled[1]))).as("and the pulled line the rest").isLessThan(DocxTwips.of(lineOf(plain[1])));
}

/** A wordmark over a 10pt subtitle pulled up by {@code pull}, in a row's cell. */
private static XWPFParagraph[] lockup(DocumentTextStyle wordmark, double pull) throws Exception {
DocumentTextStyle subtitle = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addRow("Head", row -> row
.columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1))
.addSection("Lockup", cell -> cell
.addParagraph(p -> p.text("HALL").textStyle(wordmark))
.addParagraph(p -> p.text("QUAYSIDE BAR").textStyle(subtitle)
.margin(new DocumentInsets(pull, 0, 0, 0))))
.addParagraph(p -> p.text("MONDAY").textStyle(subtitle))))) {
List<XWPFParagraph> paragraphs = document.getTables().get(0).getRow(0).getCell(0).getParagraphs();
return new XWPFParagraph[]{paragraphs.get(0), paragraphs.get(1)};
}
}

private static Object lineOf(XWPFParagraph paragraph) {
return paragraph.getCTP().getPPr().getSpacing().getLine();
}

private static int positionOf(XWPFParagraph paragraph) {
var run = paragraph.getRuns().get(0).getCTR();
return run.isSetRPr() && run.getRPr().sizeOfPositionArray() > 0
? ((Number) run.getRPr().getPositionArray(0).getVal()).intValue() : 0;
}

@Test
void aRowOpeningAPaddedColumnKeepsThePaddingAboveIt() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
Expand Down
Loading