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

### Public API

- **Text stands on the page's baseline in Word's exact lines, and stacked title lines take the
container's height.** Word stands the baseline of an exact line four fifths of the way down it
whatever the face — measured for Spectral, Lato and Arial, in lines 12 to 100pt tall, and
LibreOffice does the same — where the page sets it the face's ascent below the line's top. For a
face with a deep descent the two part: `NorthlineProposal`'s 46pt Spectral title stood 7pt low.
Its three lines, laid 48pt apart in a 140pt container, were also written 70, 48 and 48pt tall.
They ran 26pt past the container; the gap under it took 18pt of that, and the rest pushed the
cover 8pt below the section icons drawn where the page puts them. A paragraph's runs are now
moved to the page's baseline (`w:position`) wherever the two stand half a point or more apart,
from the line Word was given — a line pair's from the higher text's top, lines that took their
gaps from the space above from that higher top, several lines at the middle one; a list item's
and a table text cell's are not moved yet. Lines a container stacks tighter than their face are
each written to end halfway between their own letters and the next line's, read from the glyphs'
outlines: Word draws an exact line's text on screen only inside the line, and lines a pitch tall
cut the title's descenders and capitals there. Where the last layer of a shape container runs
past its foot, it ends at the foot, or below its letters where they hang past it. Where the page
sets two lines' letters into each other the edge still falls halfway, so the page does not move;
a stack whose letters cannot be read, or that holds a picture, keeps each line's own height, and
a line with space above or below it stays out of a stack. In Word `NorthlineProposal`'s median
drift falls
from 8.0pt to 0.8, its cover within 1.5pt of the page throughout, and `EditorialProposal`'s from
5.9 to 0.4; across the 62 templates, lines more than 2pt off fall from 1464 to 1341.
- **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
Expand Down Expand Up @@ -117,12 +139,11 @@ follow semantic versioning; release dates are ISO 8601.
- **Stacked title lines and icons beside their labels keep the page's height in Word.** A
container written layer by layer took more room in Word than on the page for two kinds of
layer. Lines of text laid over one another — `NorthlineProposal`'s title, three 46pt lines
48pt apart — took each line's own height, 70pt; now the first line keeps its height, each
single line of text laid over the one above it across takes the distance from that line's
foot to its own (`DocxStackedLines`), and a shape container's last line running past the
container's foot takes its overhang from the gap below. A line is never squeezed below 0.65
of its face, nor one holding a picture: Word was measured setting a 16pt word whole in a
10.7pt line. An icon picture beside a layer of text in a shape container neither painted,
48pt apart — took each line's own height, 70pt; now each single line of text laid over by
the next across ends halfway between its letters and the next line's (`DocxStackedLines`),
and a shape container's last line running past the container's foot takes its overhang from
the gap below. A stack that holds a picture keeps its lines' own heights.
An icon picture beside a layer of text in a shape container neither painted,
clipped to its outline nor transformed, or in a layer stack — `NorthlineProposal`'s glance
card facts, the contact lines of `ConsultingInvoice` — was written as a line of its own
above the label, an icon's height a fact; clear of the text across and at most twice its
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Payload records live in `core` under

| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own |
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; its description is the text it stands for or empty) |
Expand Down
20 changes: 14 additions & 6 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ it cannot work out for itself:

| What | Where it lands |
|---|---|
| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height; in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height |
| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height; in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
| Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them |
| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed |

Expand Down Expand Up @@ -555,11 +555,19 @@ tint it was flattened to. Recorded, like the other two.
puts it, in front inside a painted panel, rather than written as a line
above the text. Single lines of text a container written layer by layer
lays over one another — a title set a pitch apart, tighter than its
face's line — keep the page's pitch: the first line keeps its height,
each line laid over the one above it across takes the distance from
that line's foot to its own, never below 0.65 of its face nor when it
holds a picture, and a shape container's last line running past the
container's foot takes its overhang from the gap below. An outline no
face's line — keep the page's pitch: Word draws an exact line's text on
screen only inside the line, so each line laid over by the next across
ends halfway between its letters and the next line's, read from the
glyphs' outlines, and its text is seated in it by `w:position`. Where
the last layer of a shape container on one page runs past the
container's foot, it ends at the foot, or below its letters where they
hang past it, the overhang taken from the gap below. Where the page
sets two lines' letters into each other the edge still falls halfway,
so the page does not move; a stack whose letters cannot be read, or
that holds a picture, keeps its lines' own heights, and a line with
space above or below it stays out of a stack. Any other last line
running past a shape container's foot takes its overhang from the gap
below. An outline no
shape shows is reported as dropped.
- **`hangingIndent(true)` → the ordinary list form.** A list that opts
into marker/content geometry exports exactly as one that did not: the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.demcha.compose.document.backend.semantic.docx;

import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.layout.payloads.ParagraphSpan;
import com.demcha.compose.document.layout.payloads.ParagraphTextSpan;
import com.demcha.compose.engine.render.pdf.PdfFont;
import com.demcha.compose.font.FontLibrary;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDVectorFont;

import java.awt.geom.Rectangle2D;
import java.io.ByteArrayInputStream;
import java.io.IOException;

/**
* How far a line's letters reach above and below its baseline: the outlines of its glyphs, in
* the fonts the layout measured it with.
*
* <p>Word draws the text of an exact line on screen only inside the line: a letter reaching past
* its top or foot is cut off there, though its PDF export draws it whole. A face's ascent and
* descent are the room its tallest and deepest glyphs could need; the letters of one line need
* less, and lines a title sets closer than its face's line fit only by what they hold.</p>
*/
final class DocxInk {

private DocxInk() {
}

/**
* The reach of a line's letters, in points: {@code {above, below}} its baseline, each at
* least 0; {@code null} when a span is not text, a font is not known or a glyph has no
* outline to read.
*
* @param line the laid-out line
* @param fonts the fonts the layout measured it with
*/
static double[] of(ParagraphLine line, FontLibrary fonts) {
double above = 0;
double below = 0;
for (ParagraphSpan span : line.spans()) {
if (!(span instanceof ParagraphTextSpan text)) {
return null;
}
PdfFont font = fonts.getFont(text.textStyle().fontName(), PdfFont.class).orElse(null);
if (font == null) {
return null;
}
PDFont face = font.fontType(text.textStyle().decoration());
if (!(face instanceof PDVectorFont outlines)) {
return null;
}
double scale = text.textStyle().size() / 1000.0;
String shown = font.sanitizeForRender(text.textStyle(), text.text());
for (int i = 0; i < shown.length(); ) {
int codePoint = shown.codePointAt(i);
i += Character.charCount(codePoint);
if (Character.isWhitespace(codePoint)) {
continue;
}
try {
byte[] bytes = face.encode(new String(Character.toChars(codePoint)));
int code = face.readCode(new ByteArrayInputStream(bytes));
Rectangle2D bounds = outlines.getNormalizedPath(code).getBounds2D();
if (bounds.isEmpty()) {
continue;
}
above = Math.max(above, bounds.getMaxY() * scale);
below = Math.max(below, -bounds.getMinY() * scale);
} catch (IOException | IllegalArgumentException unreadable) {
return null;
}
}
}
return new double[]{above, below};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.demcha.compose.document.layout.PlacedNode;
import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.layout.payloads.ParagraphLineGeometry;
import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.InlineRun;
Expand Down Expand Up @@ -493,6 +494,24 @@ java.util.Optional<ParagraphLine> firstLine(DocumentNode node) {
return java.util.Optional.empty();
}

/**
* Where a node's first line of text starts, measured up from the foot of its page: the top
* of the first fragment holding its lines, inside the paragraph's padding, where the page
* starts setting them.
*
* @param node any node that lays out as paragraph lines
* @return the top in points, or empty when the node laid out nothing
*/
OptionalDouble firstLineTop(DocumentNode node) {
for (PlacedFragment fragment : textFragmentsOf(node)) {
if (fragment.payload() instanceof ParagraphFragmentPayload paragraph && !paragraph.lines().isEmpty()) {
return OptionalDouble.of(ParagraphLineGeometry.contentTop(fragment.y(), fragment.height(),
paragraph.padding().top()));
}
}
return OptionalDouble.empty();
}

/**
* Every line a node laid out, page after page, in order.
*
Expand Down
Loading
Loading