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
96 changes: 96 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,102 @@ follow semantic versioning; release dates are ISO 8601.
short had been pulling the page back up. Line height is measured from the font, so
closing that needs resolved layout rather than arithmetic.

- **The DOCX export now takes line height and column widths from the resolved layout, and
carries the space around a block.** _(The layout opt-in is experimental — see
[API stability](docs/api-stability.md).)_ Two of the things that decide how an exported
document looks are measurements over the font — how tall a line of text is, and how wide
a column came out — and a semantic backend has no font runtime, so both were Word's to
decide. Measured against the reference render, Word set a body line at 13.9pt where the
document says 9.7 (LibreOffice: 12.1), so everything below the first paragraph sat lower
than it should and the gap grew with every line. The export now asks for the layout the
engine already compiled: the line height is written as `w:spacing w:lineRule="exact"` on
every paragraph, table cells and list items included, and a table's columns come from
the resolved cells with `w:tblLayout` fixed so Word does not re-fit them. A row's
columns come from where the layout placed its children — only their starts, since a
placed child is as wide as its own content and its width says nothing about where its
column ends.
<br><br>
The space a block holds above and below itself is not a measurement and was missing too.
A paragraph's `margin` and `padding` now become `w:spacing` before and after, and a
container — which is not a Word object, its children written where it stood — hands its
top edge to the first paragraph inside it and its bottom edge to the last. Both add to
what a paragraph asks for itself, so a card inside a section sums the way the page does.
A container that begins or ends with a table leaves that edge unwritten rather than
parking it on whatever paragraph comes next: Word has no space-before on a table, and an
empty paragraph would add a line the document never asked for. The horizontal half of
that box still has no paragraph-level equivalent and is still dropped.
<br><br>
Measured through Word 16.0 on the two-page probe: the body now starts at 65.5pt against
the reference's 65.2 and sets lines at 9.8 against 9.7; the worst grid cell falls from
78.9% to 52.1%; and the largest landmark drift down the first page falls from 44pt to
20pt. Editing is unchanged at 7 of 7 scenarios.
<br><br>
Asking for the layout costs a measurement and pagination pass over the document — the
same work a PDF render does — and reads each image a second time. A document the
fixed-layout pipeline refuses still exports: the failure is logged once and the writer
falls back to what the document itself states, which writes a table width only when the
author stated one or every column is fixed, a row's columns only when they are weights,
an even split or fixed, and no line height at all.

- **A session can export Word without naming the backend, and can be told what the export
could not carry.** _(Experimental — see [API stability](docs/api-stability.md).)_ Reaching
the Word export meant constructing `DocxSemanticBackend`, which means importing the render
artifact in the code that builds the document and carrying that dependency wherever
documents are built. A render backend has not needed that since 2.0, and now neither does
this one: `session.buildDocx(path)`, `session.writeDocx(stream)` and
`session.toDocxBytes()` find the backend through a new `SemanticBackendProvider` /
`SemanticBackendProviders` pair — the semantic half of the `ServiceLoader` path, with the
fixed-layout locator's rules, since a classpath behaves the same way whichever backend is
on it. One rule is deliberately not copied: there is no default-format lookup, because
"render this document" has an obvious answer worth defaulting to and "export it
semantically" does not. The stream stays the caller's and is not closed; the bytes are
produced in full before any are written, since a `.docx` is a ZIP whose directory comes
last; a file is written through the same atomic path as `buildPdf`, so a failed export
leaves the previous file rather than a damaged one.
<br><br>
What the export cannot carry, it has always said — to the log, one line per kind, which a
service generating documents for other people has no way to read. A backend built with
`new DocxSemanticBackend(report -> ...)` now hands over a `DocxExportReport` once the
bytes are complete: every dropped node and every approximation, each with the path of the
authored node it came from, and each marked `DROPPED` (the page draws it, the document
does not carry it) or `APPROXIMATED` (it is there as the nearest thing Word owns). Errors
do not travel this way — an export that cannot proceed throws. The log keeps saying it
once per kind; the report records every one, because a caller asking what the document
lost wants the three charts it lost and which three.

- **A DOCX export now ships the faces the document is set in.** A face was named and never
shipped: the export declared `Lato` on its runs and embedded nothing, so on a machine
without Lato installed Word substituted another face — and a substituted face has
different glyph widths, so every line breaks somewhere else and the geometry above it
stops meaning anything. The package now carries a font table and one obfuscated font
part per face, for every family the document names that has a file behind it: the
bundled families and whatever the session registered. The standard PDF faces are never
written, and not because of their terms — they are names rather than files, and a reader
gets the editor's substitution for them, the same one a PDF viewer applies.
<br><br>
Only the faces the document uses travel: a family's four faces are about 2.5 MB, so the
face is chosen from each style's decoration, and a reader who later bolds a word gets
whatever their machine does for a missing bold face. And only what the face permits: an
OpenType face states its terms in `OS/2`, and the format distinguishes embedding for
reading and printing from embedding in a document someone will edit — a face that allows
only the first is named but not shipped, with one warning naming the family.
<br><br>
Measured through Word 16.0 on a machine where Lato is not installed: the exported
document reports `Lato` in the render rather than a substitution, and its Lato paragraph
breaks at the same word as the reference, ending within 2.3pt over a 489pt line. The
two-page probe's file grows from 366 KB with one face to 2.9 MB if all four are written,
which is why the slot is read from the style.
<br><br>
A run also names the family rather than the face. `FontName.HELVETICA_BOLD` is a face,
and it was written where Word expects a family: Word resolves a family and takes the
weight from `w:b`, so asked for a family by that name it found none and substituted —
which is how a document naming its headings by face came out set in something else. The
face is now resolved to its family exactly as the layout resolves it, through
`FontLibrary.resolveFamily`, and the name written is that family's `wordFamily()`. The
weight is deliberately not read from the face name, because the engine does not read it
either: a style naming `HELVETICA_BOLD` with no `decoration` lays out regular, and
writing `w:b` would make Word bolder than the page it matches. Two names for one family
now also weigh as one style when `Normal` is chosen, since they are written identically.
- **A semantic export backend can ask for the compiled layout.** _(Experimental — see
[API stability](docs/api-stability.md).)_ A semantic backend walks the authored tree and
gets no geometry, which is right for most of them and wrong for the ones that need a
Expand Down
Binary file modified assets/readme/examples/word-export-companion.docx
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.demcha.compose.document.backend.fixed.FixedLayoutRenderContext;
import com.demcha.compose.document.backend.fixed.FixedLayoutRenderer;
import com.demcha.compose.document.backend.semantic.SemanticBackend;
import com.demcha.compose.document.backend.semantic.SemanticBackendProviders;
import com.demcha.compose.document.backend.semantic.SemanticExportContext;
import com.demcha.compose.document.layout.DocumentGraph;
import com.demcha.compose.document.layout.LayoutCanvas;
Expand Down Expand Up @@ -36,6 +37,7 @@
*/
final class DocumentRenderingFacade {
private static final Logger LIFECYCLE_LOG = LoggerFactory.getLogger("com.demcha.compose.document.lifecycle");
private static final Logger LOG = LoggerFactory.getLogger(DocumentRenderingFacade.class);

/** Provider format keys for the fixed-layout convenience paths. */
private static final String PDF = "pdf";
Expand Down Expand Up @@ -96,7 +98,7 @@ <R> R export(SemanticBackend<R> backend, Path outputFile) throws Exception {
// needed a FontMetricsProvider to be constructed at all.
// The graph, the canvas and the layout all come off the same session state, so a
// backend given both is given a layout compiled from the graph beside it.
LayoutGraph resolvedLayout = backend.requiresResolvedLayout() ? context.layoutGraph() : null;
LayoutGraph resolvedLayout = backend.requiresResolvedLayout() ? compiledLayout(backend) : null;
return backend.export(context.documentGraph(),
new SemanticExportContext(
context.canvas(),
Expand All @@ -106,6 +108,35 @@ <R> R export(SemanticBackend<R> backend, Path outputFile) throws Exception {
resolvedLayout));
}

/**
* Compiles the layout for a backend that asked for it, or hands it nothing.
*
* <p>A semantic export is defined over the authored tree, and some documents export
* from it that the fixed-layout pipeline refuses — a list item made of inline runs
* without the marker geometry that resolving one needs, for instance, exports as an
* ordinary Word list item and cannot be laid out at all. Before a backend could ask
* for the layout that difference never came up; letting the request turn such a
* document from "exports" into "throws" would take something away that worked, in
* exchange for a number the backend only wanted as an improvement.</p>
*
* <p>So the layout is offered rather than imposed: what compiles is handed over, what
* does not is reported once and the export continues without it.
* {@code SemanticExportContext.layoutGraph()} is nullable for exactly this, and a
* backend that cannot proceed without it says so through
* {@code requireLayoutGraph()}.</p>
*/
private LayoutGraph compiledLayout(SemanticBackend<?> backend) {
try {
return context.layoutGraph();
} catch (RuntimeException failure) {
LOG.warn("Backend '{}' asked for the resolved layout and this document cannot be "
+ "laid out ({}); exporting without it — measured geometry is unavailable, "
+ "so the export falls back to what the document itself states",
backend.name(), failure.toString());
return null;
}
}

byte[] toPdfBytes() throws Exception {
return renderBytes(PDF);
}
Expand All @@ -118,6 +149,65 @@ void buildPdf(Path outputFile) throws Exception {
buildFixedLayout(PDF, outputFile);
}

/**
* Exports through the semantic backend registered for {@code format}.
*
* <p>{@code ensureRenderable()} is deliberately not called: a semantic export is
* defined over the authored tree, and a document the fixed-layout pipeline refuses can
* still export from it. Refusing here would take that away for no gain.</p>
*/
byte[] toSemanticBytes(String format) throws Exception {
context.ensureOpen();
long startNanos = System.nanoTime();
LIFECYCLE_LOG.debug("document.{}.bytes.start sessionId={} revision={} roots={}",
format, context.sessionId(), context.revision(), context.rootCount());
try {
byte[] bytes = export(SemanticBackendProviders.forFormat(format).create(), null);
LIFECYCLE_LOG.debug(
"document.{}.bytes.end sessionId={} revision={} byteCount={} durationMs={}",
format, context.sessionId(), context.revision(), bytes.length,
elapsedMillis(startNanos));
return bytes;
} catch (Exception ex) {
LIFECYCLE_LOG.error("document.{}.bytes.failed sessionId={} revision={} errorType={}",
format, context.sessionId(), context.revision(),
ex.getClass().getSimpleName(), ex);
throw ex;
}
}

/**
* Writes the export to a stream the caller owns and keeps open.
*
* <p>The bytes are produced in full before any of them are written. The format is a ZIP
* package whose directory is written last, so an export that fails part-way would
* otherwise leave a truncated archive on a stream nobody can rewind.</p>
*/
void writeSemantic(String format, OutputStream output) throws Exception {
Objects.requireNonNull(output, "output");
output.write(toSemanticBytes(format));
output.flush();
}

/** Writes the export to a file, replacing it only once the whole export succeeded. */
void buildSemantic(String format, Path outputFile) throws Exception {
Path target = Objects.requireNonNull(outputFile, "outputFile");
long startNanos = System.nanoTime();
LIFECYCLE_LOG.debug("document.{}.build.start sessionId={} revision={} roots={}",
format, context.sessionId(), context.revision(), context.rootCount());
try {
byte[] bytes = toSemanticBytes(format);
AtomicFileOutput.write(target, output -> output.write(bytes));
LIFECYCLE_LOG.debug("document.{}.build.end sessionId={} revision={} durationMs={}",
format, context.sessionId(), context.revision(), elapsedMillis(startNanos));
} catch (Exception ex) {
LIFECYCLE_LOG.error("document.{}.build.failed sessionId={} revision={} errorType={}",
format, context.sessionId(), context.revision(),
ex.getClass().getSimpleName(), ex);
throw ex;
}
}

byte[] toPptxBytes() throws Exception {
return renderBytes(PPTX);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@
* <li>inspect {@link #layoutGraph()} / {@link #layoutSnapshot()} as needed</li>
* <li>render with {@link #writePdf(OutputStream)}, {@link #toPdfBytes()}, {@link #buildPdf()},
* their PPTX counterparts ({@link #writePptx(OutputStream)}, {@link #toPptxBytes()},
* {@link #buildPptx(Path)}), or a custom backend</li>
* {@link #buildPptx(Path)}), or a custom backend — or export an editable Word
* document with {@link #buildDocx(Path)}, {@link #writeDocx(OutputStream)} and
* {@link #toDocxBytes()}, which write the document's structure rather than its
* pixels and let Word lay it out</li>
* </ol>
*
* <p><b>Thread-safety:</b> this type is mutable and not thread-safe.</p>
Expand All @@ -65,6 +68,9 @@
* @since 1.0.0
*/
public final class DocumentSession implements AutoCloseable {

/** Provider format key for the Word export path. */
private static final String DOCX = "docx";
private static final Logger LIFECYCLE_LOG = LoggerFactory.getLogger("com.demcha.compose.document.lifecycle");

private final String sessionId = Integer.toHexString(System.identityHashCode(this));
Expand Down Expand Up @@ -1054,6 +1060,76 @@ public void buildPdf(Path outputFile) throws DocumentRenderingException {
});
}

/**
* Exports the current session as an editable Word document and returns the bytes.
*
* <p>Unlike a PDF render, this is an export: the document's structure is written as
* Word's own paragraphs, tables and lists, and Word lays the result out itself. A
* reader can edit it the way they edit any document — lengthen a sentence and the
* paragraph reflows, insert a table row and the table grows. What the export cannot
* carry, it says so about rather than approximating; the
* <a href="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/DemchaAV/GraphCompose/blob/develop/docs/recipes/docx-export.md">DOCX
* recipe</a> lists what maps, what falls back and what is deliberately left out.</p>
*
* <p>Requires {@code io.github.demchaav:graph-compose-render-docx} on the classpath;
* without it the export fails with a {@link com.demcha.compose.document.exceptions.MissingBackendException} naming the
* artifact. The returned array is not cached by the session, so code that can stream
* should prefer {@link #writeDocx(OutputStream)}.</p>
*
* <p><b>Experimental</b> ({@code @Beta}) — see {@code docs/api-stability.md}.</p>
*
* @return the exported .docx bytes
* @throws DocumentRenderingException if the export fails
* @since 2.5.0
*/
@Beta
public byte[] toDocxBytes() throws DocumentRenderingException {
return wrapRendering("export DOCX bytes", () -> renderingFacade.toSemanticBytes(DOCX));
}

/**
* Streams the current session's Word export to a stream the caller owns.
*
* <p>GraphCompose writes the bytes and does not close the stream, which makes this
* suitable for an HTTP response or an upload. The export is produced in full before
* anything is written: a {@code .docx} is a ZIP whose directory comes last, so a
* half-written one is not a shorter document but an unreadable file.</p>
*
* <p><b>Experimental</b> ({@code @Beta}) — see {@code docs/api-stability.md}.</p>
*
* @param output destination stream that receives the exported bytes
* @throws DocumentRenderingException if the export fails
* @since 2.5.0
*/
@Beta
public void writeDocx(OutputStream output) throws DocumentRenderingException {
wrapRendering("write DOCX to stream", () -> {
renderingFacade.writeSemantic(DOCX, output);
return null;
});
}

/**
* Exports the current session into the supplied file.
*
* <p>Written through the same atomic path as {@link #buildPdf(Path)}: an export that
* fails leaves whatever was there before, rather than a partial file that opens as a
* damaged document.</p>
*
* <p><b>Experimental</b> ({@code @Beta}) — see {@code docs/api-stability.md}.</p>
*
* @param outputFile destination .docx path
* @throws DocumentRenderingException if the export fails
* @since 2.5.0
*/
@Beta
public void buildDocx(Path outputFile) throws DocumentRenderingException {
wrapRendering("build DOCX at '" + outputFile + "'", () -> {
renderingFacade.buildSemantic(DOCX, outputFile);
return null;
});
}

/**
* Renders the current session through the fixed-layout PPTX backend and
* returns the .pptx bytes. One resolved page becomes one identically-sized
Expand Down
Loading
Loading