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

### Public API

- **A DOCX export's lines grow with their text where they are as tall as their faces' own.**
- Every paragraph's lines were written at an exact height, which Word keeps whatever its text
holds: a reader making the body larger in Word — the `Normal` style two points up — got letters
taller than their lines, cut off at the top on screen.
- A paragraph's or a list item's line that is at least as tall as its runs' own line is now
written as a least height (`w:lineRule="atLeast"`). The runs' own line is their faces' line by
the faces' own tables: the tallest run's reach above the baseline and the deepest run's below,
the paragraph's mark among them. It is so where the runs are set in faces the document ships,
all the same depth below the baseline, left to right, none raised or a script, no picture in
the line, and the paragraph is seated on its baseline, not a Word list's item.
- Word and LibreOffice keep a least line's spare above its text and stand its baseline the
runs' descent above its foot (measured on the templates' faces, Word and LibreOffice 24.2, to
0.15pt). That is where the page sets it, so such a line needs no position to stand there; it
is still moved into the space above it where Word would set it a little off. A line Word
would set a quarter point or more off the page's baseline stays exact.
- The rest stays exact:
- a line the page sets tighter than its faces' own: Poppins' and Gothic A1's, whose own line
holds a line gap the page's does not; one rounded to the twip below its own, which a least
line would grow back on every line;
- a line in a face the document does not ship, the default Helvetica's among them, which the
reader's machine substitutes, or in one whose `hhea` extents, which the page measures by,
are not the metrics its line is set on;
- a table's text cells, page zones, text boxes, stacked lines and line pairs.
- The report names how many paragraphs of text are still exact, once for the document
(`line height`, no path).
- The corpus writes 1164 of its 3856 paragraphs of text at a least height. In Word, two points
more on `Normal` leaves 1138 of the 1832 paragraphs it grows on lines shorter than 1.1 times
their letters, from 1617.
- Lines move as their seats change. In Word no line stands half a point further from the page
than its baseline holds. The step from a line to the next differs from the page's by 0.120pt
on average, from 0.130, and by more than 0.2pt for 238 of 2766 steps, from 335. In
LibreOffice 24.2 it differs by 0.173pt on average, from 0.187.

- **A DOCX export's page-margin notes name every page they are about.**
- A note about several pages named the first of them only, and the later ones, which Word sets
with the margins in force too, went unnamed: `ObsidianInvoice` with its line items run onto
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion docs/recipes/docx-export.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
Expand All @@ -19,6 +20,7 @@
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;

Expand Down Expand Up @@ -80,6 +82,8 @@ final class DocxDrawingAnchors {
private final List<Offer> offers = new ArrayList<>();
// Identifiers unique in the document, shared with the other drawings the export writes.
private final LongSupplier ids;
// How tall Word stands a written paragraph's line, and its baseline in it.
private final Function<CTP, DocxGrownLines.Line> lines;
// The places given each fragment's shapes in the section so far, by the fragment's place in
// the page's paint order.
private final Map<Integer, Integer> placesTaken = new HashMap<>();
Expand All @@ -97,7 +101,17 @@ final class DocxDrawingAnchors {
static final int PLACES_PER_FRAGMENT = 8;

DocxDrawingAnchors(LongSupplier ids) {
this(ids, DocxGrownLines.NONE::lineOf);
}

/**
* @param ids identifiers unique in the document
* @param lines how tall Word stands a written paragraph's line, and its baseline in it
* ({@link DocxGrownLines#lineOf})
*/
DocxDrawingAnchors(LongSupplier ids, Function<CTP, DocxGrownLines.Line> lines) {
this.ids = ids;
this.lines = lines;
}

/** Forgets everything, for a new export. */
Expand Down Expand Up @@ -410,7 +424,7 @@ private Place placeBeside(DocxDrawings.Shape shape) {
// the paragraph's first line does: Word and LibreOffice place it there, out of a cell's
// top too, and draw it whole. One rising further stands by other text: LibreOffice set an
// 11pt rule 45pt above its paragraph's top at the head of the next page.
return nearest == null ? null : nearest.place(shape);
return nearest == null ? null : nearest.place(shape, lines);
}

/**
Expand Down Expand Up @@ -474,10 +488,14 @@ record Seat(XWPFParagraph paragraph, double left, double right, double top, doub
*
* <p>Word's line is not the page's: the export stands Word's baseline where the page's
* is, and Word stands an exact line's baseline four fifths of the way down it, raised by
* the runs' position. A 15pt exact line round 8.6pt text starts 4.6pt above the page's
* line, and a shape placed from the page's line stood that much high.</p>
* the runs' position, and a least line's its runs' descent above its foot
* ({@link DocxGrownLines#lineOf}), which holds no position. A 15pt exact line round 8.6pt
* text starts 4.6pt above the page's line, and a shape placed from the page's line stood
* that much high.</p>
*
* @param lines how tall Word stands a paragraph's line, and its baseline in it
*/
double paragraphTop() {
double paragraphTop(Function<CTP, DocxGrownLines.Line> lines) {
CTPPr properties = paragraph.getCTP().getPPr();
double before = 0;
double lineTop = top;
Expand All @@ -498,11 +516,11 @@ record Seat(XWPFParagraph paragraph, double left, double right, double top, doub
}
before = twips.doubleValue() / 20;
}
if (spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT) {
if (!(spacing.getLine() instanceof Number twips)) {
return Double.NaN;
}
lineTop = baseline - (DocxTextBands.BASELINE_SHARE * twips.doubleValue() / 20 - raised());
DocxGrownLines.Line line = lines.apply(paragraph.getCTP());
if (line != null) {
lineTop = baseline - (line.baseline() - raised());
} else if (spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT) {
return Double.NaN;
}
}
}
Expand Down Expand Up @@ -552,8 +570,8 @@ private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR firstRunOfTex
* written short of the page's, or in a repeated header row, which Word repeats on every
* page with what is anchored in it.</p>
*/
Place place(DocxDrawings.Shape shape) {
double top = paragraphTop();
Place place(DocxDrawings.Shape shape, Function<CTP, DocxGrownLines.Line> lines) {
double top = paragraphTop(lines);
if (!(paragraph.getBody() instanceof XWPFTableCell cell)) {
return risesNoFurther(shape, top, false) ? new Place(paragraph, Double.POSITIVE_INFINITY, top) : null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ static Permission permissionOf(byte[] font) {
* @return the raw {@code fsType}, or -1 when the font carries no readable {@code OS/2}
*/
private static int readFsType(byte[] font) {
// version(2) avgCharWidth(2) weightClass(2) widthClass(2) then fsType.
int offset = tableOffset(font, "OS/2", 10);
return offset < 0 ? -1 : Short.toUnsignedInt(ByteBuffer.wrap(font).order(ByteOrder.BIG_ENDIAN)
.getShort(offset + 8));
}

/**
* Finds a table in the font's table directory.
*
* @param font the whole font file
* @param tag the table's four-letter tag
* @param length how many of its bytes the reader needs
* @return where the table starts, or -1 when the font has no such table, or the table or the
* file ends before that many of its bytes
*/
static int tableOffset(byte[] font, String tag, int length) {
if (font == null || font.length < 12) {
return -1;
}
Expand All @@ -86,16 +102,14 @@ private static int readFsType(byte[] font) {
if (entry + 16 > font.length) {
return -1;
}
String tag = new String(font, entry, 4, java.nio.charset.StandardCharsets.US_ASCII);
if (!"OS/2".equals(tag)) {
if (!tag.equals(new String(font, entry, 4, java.nio.charset.StandardCharsets.US_ASCII))) {
continue;
}
int offset = buffer.getInt(entry + 8);
// version(2) avgCharWidth(2) weightClass(2) widthClass(2) then fsType.
if (offset < 0 || offset + 10 > font.length) {
return -1;
}
return Short.toUnsignedInt(buffer.getShort(offset + 8));
// The directory states each table's length: a table shorter than the reader needs would
// be read into the next one's bytes.
int tableLength = buffer.getInt(entry + 12);
return offset < 0 || tableLength < length || offset + length > font.length ? -1 : offset;
}
return -1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,20 @@ private DocxFontTable() {
* @param bands the style each header and footer band of text sets its text in
* @param custom families the session registered, which win over the bundled ones
* @param report where a face that may not travel is recorded
* @return the line an editor sets each shipped face in
* @throws IOException if a part cannot be written
*/
static void write(XWPFDocument document,
DocumentGraph graph,
List<DocumentNode> zones,
List<DocumentTextStyle> bands,
Collection<FontFamilyDefinition> custom,
DocxExportReport.Builder report) throws IOException {
List<Embedded> embedded = resolve(graph, zones, bands, custom, report);
static ShippedLines write(XWPFDocument document,
DocumentGraph graph,
List<DocumentNode> zones,
List<DocumentTextStyle> bands,
Collection<FontFamilyDefinition> custom,
DocxExportReport.Builder report) throws IOException {
Map<FontName, FontFamilyDefinition> families = familiesByName(custom);
List<Embedded> embedded = resolve(graph, zones, bands, families, report);
ShippedLines lines = new ShippedLines(families, embedded);
if (embedded.isEmpty()) {
return;
return lines;
}

OPCPackage pkg = document.getPackage();
Expand All @@ -124,6 +127,55 @@ static void write(XWPFDocument document,
try (OutputStream out = table.getOutputStream()) {
out.write(toXml(embedded).getBytes(StandardCharsets.UTF_8));
}
return lines;
}

/**
* The line an editor sets each face the document ships in where nothing fixes the line's
* height ({@link DocxNaturalLine}). A face it does not ship — a standard PDF name, one that
* may not travel — is set in whatever the reader's machine substitutes, so it has none.
*/
static final class ShippedLines {
/** No face shipped: an export written without a font table. */
static final ShippedLines NONE = new ShippedLines(Map.of(), List.of());

private final Map<FontName, FontFamilyDefinition> families;
private final Map<String, DocxNaturalLine> lines = new java.util.HashMap<>();

private ShippedLines(Map<FontName, FontFamilyDefinition> families, List<Embedded> embedded) {
this.families = families;
for (Embedded family : embedded) {
for (Face face : family.faces()) {
if (face.line() != null) {
lines.put(family.wordFamily() + '\n' + face.element(), face.line());
}
}
}
}

/**
* The line a face is set in.
*
* @param fontName the face a style names
* @param slot which of its family's faces: {@code embedRegular}, {@code embedBold},
* {@code embedItalic} or {@code embedBoldItalic}
* @return its line, or null where the document does not ship that face
*/
DocxNaturalLine of(FontName fontName, String slot) {
FontFamilyDefinition family = fontName == null ? null : families.get(FontLibrary.resolveFamily(fontName));
return family == null ? null : of(family.wordFamily(), slot);
}

/**
* The line a face is set in, by the family a run names in Word.
*
* @param wordFamily the family's name as the font table writes it
* @param slot which of its faces, as {@link #of(FontName, String)}
* @return its line, or null where the document does not ship that face
*/
DocxNaturalLine of(String wordFamily, String slot) {
return lines.get(wordFamily + '\n' + slot);
}
}

/**
Expand Down Expand Up @@ -152,17 +204,22 @@ static Map<FontName, FontFamilyDefinition> familiesByName(Collection<FontFamilyD
private record Embedded(String wordFamily, List<Face> faces) {
}

/** One face of a family: which slot it fills, its bytes, and how the part refers to it. */
/**
* One face of a family: which slot it fills, its bytes, how the part refers to it, and the
* line an editor sets it in.
*/
private static final class Face {
private final String element;
private final byte[] bytes;
private final String fontKey;
private final DocxNaturalLine line;
private String relationshipId;

Face(String element, byte[] bytes, String fontKey) {
Face(String element, byte[] bytes, String fontKey, DocxNaturalLine line) {
this.element = element;
this.bytes = bytes;
this.fontKey = fontKey;
this.line = line;
}

String element() {
Expand All @@ -173,6 +230,10 @@ byte[] bytes() {
return bytes;
}

DocxNaturalLine line() {
return line;
}

void relationshipId(String id) {
this.relationshipId = id;
}
Expand All @@ -188,7 +249,7 @@ void relationshipId(String id) {
private static List<Embedded> resolve(DocumentGraph graph,
List<DocumentNode> zones,
List<DocumentTextStyle> bands,
Collection<FontFamilyDefinition> custom,
Map<FontName, FontFamilyDefinition> families,
DocxExportReport.Builder report) {
Map<FontName, Set<Slot>> used = new LinkedHashMap<>();
for (DocumentNode root : graph.roots()) {
Expand All @@ -202,8 +263,6 @@ private static List<Embedded> resolve(DocumentGraph graph,
return List.of();
}

Map<FontName, FontFamilyDefinition> families = familiesByName(custom);

List<Embedded> embedded = new ArrayList<>();
for (Map.Entry<FontName, Set<Slot>> entry : used.entrySet()) {
FontFamilyDefinition family = families.get(FontLibrary.resolveFamily(entry.getKey()));
Expand Down Expand Up @@ -325,7 +384,7 @@ private static void addFace(List<Face> faces,
}
}
DocxFontEmbedding.Obfuscated obfuscated = DocxFontEmbedding.obfuscate(bytes);
faces.add(new Face(slot.element(), obfuscated.bytes(), obfuscated.fontKey()));
faces.add(new Face(slot.element(), obfuscated.bytes(), obfuscated.fontKey(), DocxNaturalLine.of(bytes)));
}

/**
Expand Down
Loading
Loading