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

### Public API

- **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
number the engine already worked out — a resolved column width, a settled page count.
`SemanticBackend.requiresResolvedLayout()` defaults to `false`; a backend that returns
`true` is handed the same session's layout in `SemanticExportContext.layoutGraph()`,
compiled from the graph passed to the same `export` call.
<br><br>
The default matters as much as the option: compiling a layout runs measurement and
pagination over the whole document, work that grows with the document and that an export
ignoring geometry has no use for. A backend that does not ask causes none of it. It does
not save the render-module dependency — `DocumentSession` resolves a `FontMetricsProvider`
in its constructor and only `graph-compose-render-pdf` registers one, so a session cannot
be created without it whatever a backend later asks for.
<br><br>
Binary compatibility is preserved: the four-argument `SemanticExportContext` constructor
is written out by hand rather than left to the record, since adding the component moved
the canonical constructor to five arguments and callers compiled against the published
descriptor would otherwise stop linking. **Source compatibility is not**, in two narrow
ways — a record pattern that destructures the four-component form
(`case SemanticExportContext(var canvas, var fonts, var out, var opts)`) no longer
compiles, and `getRecordComponents().length` is 5 rather than 4. `requireLayoutGraph()`
is there for a backend that asked and wants the absence to say why rather than hand back
`null`.
<br><br>
One consequence worth knowing before putting a context in a collection or a log line:
the record's `equals`, `hashCode` and `toString` now traverse the layout, so on a long
document they walk every placed node and fragment. The four original components are
unchanged.

- **An inline SVG icon can state the text it stands for.** `SvgIcon.withText(String)` returns
a copy of the icon carrying that text, read back with `SvgIcon.text()`: what a reader that
copies, searches or extracts the page should find where the icon is drawn in a line of text,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,20 @@ <R> R render(FixedLayoutBackend<R> backend, Path outputFile) throws Exception {
<R> R export(SemanticBackend<R> backend, Path outputFile) throws Exception {
context.ensureOpen();
Objects.requireNonNull(backend, "backend");
// Compiled only for a backend that asked: compiling runs measurement and
// pagination over the whole document, which an export that ignores geometry has
// no use for. It does not save the render-module dependency — the session already
// 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;
return backend.export(context.documentGraph(),
new SemanticExportContext(
context.canvas(),
context.customFontFamilies(),
outputFile,
context.outputOptions()));
context.outputOptions(),
resolvedLayout));
}

byte[] toPdfBytes() throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ public interface SemanticBackend<R> {
*/
String name();

/**
* Whether this backend needs the compiled layout alongside the semantic graph.
*
* <p>A semantic backend walks the authored tree and needs no geometry, which is why
* the default is {@code false} and why the session does not compile a layout for one.
* Compiling one runs measurement and pagination over the whole document — work that
* grows with the document and that an export ignoring geometry has no use for.</p>
*
* <p>It does <em>not</em> save the render-module dependency, and this flag should not
* be described as if it did: {@code DocumentSession} resolves a
* {@code FontMetricsProvider} in its constructor, only
* {@code graph-compose-render-pdf} registers one, and a session cannot be created
* without it whatever a backend later asks for.</p>
*
* <p>A backend that answers {@code true} is handed the same session's compiled layout
* in {@link SemanticExportContext#layoutGraph()}. It is for reading what the engine
* already worked out — a resolved width, a settled page count — not for placing
* content at coordinates; a backend that wants coordinates is a fixed-layout backend
* and should implement that contract instead.</p>
*
* @return true to be given a resolved layout; false to be given the graph alone
* @since 2.5.0
*/
@com.demcha.compose.document.api.Beta
default boolean requiresResolvedLayout() {
return false;
}

/**
* Exports the semantic document graph without running a fixed-layout renderer.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.demcha.compose.document.backend.semantic;

import com.demcha.compose.document.layout.LayoutCanvas;
import com.demcha.compose.document.layout.LayoutGraph;

import com.demcha.compose.document.output.DocumentOutputOptions;
import com.demcha.compose.font.FontFamilyDefinition;
Expand All @@ -17,12 +18,18 @@
* @param outputFile optional export output file
* @param outputOptions backend-neutral document output options (metadata,
* watermark, headers/footers, protection)
* @param layoutGraph the same document's compiled layout, or {@code null} — present only
* for a backend that asked for it through
* {@link SemanticBackend#requiresResolvedLayout()}, and always
* compiled from the graph handed to the same {@code export} call
* @since 2.5.0 carries {@code layoutGraph}
*/
public record SemanticExportContext(
LayoutCanvas canvas,
Collection<FontFamilyDefinition> customFontFamilies,
Path outputFile,
DocumentOutputOptions outputOptions
DocumentOutputOptions outputOptions,
@com.demcha.compose.document.api.Beta LayoutGraph layoutGraph
) {
/**
* Normalizes the custom font collection into an immutable snapshot and
Expand All @@ -34,6 +41,25 @@ public record SemanticExportContext(
outputOptions = outputOptions == null ? DocumentOutputOptions.EMPTY : outputOptions;
}

/**
* Constructor without a resolved layout, for a backend that reads the graph alone.
*
* <p>Written out rather than left to the record because adding the component moved
* the canonical constructor to five arguments, and this four-argument descriptor is
* published: callers compiled against it would stop linking.</p>
*
* @param canvas physical page canvas for semantic export
* @param customFontFamilies document-local font families available to the backend
* @param outputFile optional export output file
* @param outputOptions backend-neutral document output options
*/
public SemanticExportContext(LayoutCanvas canvas,
Collection<FontFamilyDefinition> customFontFamilies,
Path outputFile,
DocumentOutputOptions outputOptions) {
this(canvas, customFontFamilies, outputFile, outputOptions, null);
}

/**
* Backwards-compatible constructor without explicit output options.
*
Expand All @@ -44,7 +70,32 @@ public record SemanticExportContext(
public SemanticExportContext(LayoutCanvas canvas,
Collection<FontFamilyDefinition> customFontFamilies,
Path outputFile) {
this(canvas, customFontFamilies, outputFile, DocumentOutputOptions.EMPTY);
this(canvas, customFontFamilies, outputFile, DocumentOutputOptions.EMPTY, null);
}

/**
* The compiled layout, for a backend that asked for one.
*
* @return the resolved layout
* @throws IllegalStateException if no layout was supplied — which means the backend
* did not ask for one, or the context was built by hand
* without it
* @since 2.5.0
*/
@com.demcha.compose.document.api.Beta
public LayoutGraph requireLayoutGraph() {
if (layoutGraph == null) {
return throwMissingLayout();
}
return layoutGraph;
}

private static LayoutGraph throwMissingLayout() {
throw new IllegalStateException(
"No resolved layout in this export context. A backend is given one only "
+ "when it returns true from SemanticBackend.requiresResolvedLayout(); a "
+ "context built directly must be given the layout compiled from the same "
+ "document graph.");
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.demcha.compose.document.api;

import com.demcha.compose.document.backend.fixed.FixedLayoutRenderer;
import com.demcha.compose.document.backend.semantic.SemanticBackend;
import com.demcha.compose.document.backend.semantic.SemanticExportContext;
import com.demcha.compose.document.layout.DocumentGraph;
import com.demcha.compose.document.layout.LayoutCanvas;
import com.demcha.compose.document.layout.LayoutGraph;
import com.demcha.compose.document.output.DocumentOutputOptions;
import com.demcha.compose.engine.components.style.Margin;
import com.demcha.compose.font.FontFamilyDefinition;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

/**
* The facade asks the session for a layout only when the backend asked for one.
*
* <p>Whether the backend is <em>handed</em> a layout is a different question, and the
* qa suite answers it against a live session. This one answers the question that
* matters for what the feature costs: was the layout <em>compiled</em> at all. The two
* come apart — hoisting the call out of the ternary
* ({@code var g = context.layoutGraph(); ... ? g : null}) hands a non-asking backend
* the same {@code null} while compiling the layout anyway, which is exactly the cost the
* flag exists to avoid, and every assertion about the handed-over value stays green.
* So the session stands in as a counter here, and the assertion is on the count.</p>
*
* @author Artem Demchyshyn
*/
class SemanticExportLayoutResolutionTest {

@Test
void aBackendThatDoesNotAskShouldNotCauseALayoutToBeCompiled() throws Exception {
CountingContext context = new CountingContext();

new DocumentRenderingFacade(context).export(new ProbeBackend(false), null);

assertThat(context.layoutRequests)
.as("the session was never asked to compile a layout")
.isZero();
}

@Test
void aBackendThatAsksShouldCauseExactlyOneCompilation() throws Exception {
CountingContext context = new CountingContext();

new DocumentRenderingFacade(context).export(new ProbeBackend(true), null);

assertThat(context.layoutRequests)
.as("asked for once, not once per read")
.isEqualTo(1);
}

/** Answers whatever it is asked, and counts how often the layout is wanted. */
private static final class CountingContext implements DocumentRenderingFacade.Context {

private final LayoutCanvas canvas = LayoutCanvas.from(595, 842, Margin.of(36));
private final DocumentGraph graph = new DocumentGraph(List.of());
private int layoutRequests;

@Override
public void ensureOpen() {
}

@Override
public void ensureRenderable() {
}

@Override
public String sessionId() {
return "counting";
}

@Override
public long revision() {
return 1;
}

@Override
public int rootCount() {
return 0;
}

@Override
public LayoutCanvas canvas() {
return canvas;
}

@Override
public List<FontFamilyDefinition> customFontFamilies() {
return List.of();
}

@Override
public LayoutGraph layoutGraph() {
layoutRequests++;
return new LayoutGraph(canvas, 1, List.of(), List.of());
}

@Override
public DocumentGraph documentGraph() {
return graph;
}

@Override
public DocumentOutputOptions outputOptions() {
return DocumentOutputOptions.EMPTY;
}

@Override
public FixedLayoutRenderer convenienceBackend(String format) {
throw new UnsupportedOperationException("no fixed-layout render in this test");
}
}

/** Says whether it wants a layout and records what it was given. */
private static final class ProbeBackend implements SemanticBackend<LayoutGraph> {

private final boolean wantsLayout;

ProbeBackend(boolean wantsLayout) {
this.wantsLayout = wantsLayout;
}

@Override
public String name() {
return "probe";
}

@Override
public boolean requiresResolvedLayout() {
return wantsLayout;
}

@Override
public LayoutGraph export(DocumentGraph graph, SemanticExportContext context) {
return context.layoutGraph();
}
}
}
Loading
Loading