diff --git a/CHANGELOG.md b/CHANGELOG.md index 07857d266..d94b0a809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,101 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A timeline's rail is one line, drawn from where its markers landed.** + It was a left border repeated on every entry section, which is why it sat at the entry's + edge whatever the markers did, could not stop short of them, and had no way to be + anything but the full height of the entries. It is now computed after layout from the + markers' and entries' resolved positions, and contributed as one fragment per page — + one logical rail, however many pages it crosses, bounded on each by that page alone. + + Two independent choices, and they stay independent. `TimelineRailExtent` says how far the + rail runs: `ENTRY_BOUNDS`, the default and what every existing timeline already draws, or + `MARKER_TO_MARKER`, which starts at the first marker and stops at the last. + `markerOnRail()` says where it runs: it aligns every marker's declared anchor with the + timeline axis. With the current centre anchor, markers of different sizes are centred + within the axis column and share one continuous rail — a 6pt dot, a 14pt numbered disc + and a 24pt square all sit on the same line rather than on the same left edge. An entry's + body moves with them into the content column beside the marker, so the line is left with + only markers to cross — see *Fixed* below. A timeline that does not call it keeps the + left-edge anchor and the placement it has always had. + A timeline with one entry and `MARKER_TO_MARKER` emits no rail at all rather than a line + of no length. `TIMELINE_BOUNDS` is named and rejected — on one page it is the same line + as `ENTRY_BOUNDS`, and across pages there is nothing to measure it against. + + **A leading column sits to the left of the timeline axis; it does not move the rail to + the entry boundary.** The layout is `LEADING | AXIS | CONTENT`, and the rail belongs to + the axis. + + Existing timelines lay out where they always did — the rail's geometry matches the border + it replaces to 0.000000 in x and at both ends, on every page, and page counts are + unchanged. It is not pixel-identical, and the difference is worth knowing: two pixels in a + three-entry timeline and one in a two-entry one, at the rows where two entry borders used + to abut. Each drew its own antialiased end there, so the seam came out *lighter* than the + rail's own colour; one continuous line has no seams and paints the colour asked for. + +- **A timeline's rail is one configuration.** + `TimelineBuilder.rail(Consumer)` takes a `DocumentStroke`, and + `connector(colour, width)` is now the shorthand that normalizes into exactly the same + rail — one rendering system rather than an old spelling and a new one, which is what + lets the rail grow later without a second path growing beside it. Saying it both ways + throws, naming both calls; saying it twice the same way still works, because + `connector(colour, 0)` followed by `connector(null, width)` has always been a way to set + the two halves separately and code doing that must not start failing. + +- **A timeline marker can be anything you can draw.** + `TimelineMarker.custom(width, height, recipe)` takes a declared box and a recipe that + fills it, so a marker made of three stacked shapes, a bordered pill or an icon needs no + change to `TimelineBuilder` — the four built-in factories now go through the same door. + The box is declared rather than measured, and it does not have to be square; a marker + drawn as several fragments has one box exactly as a marker drawn as one does, which is + what keeps the geometry around it independent of how the marker was built. + + Fixed along the way: `TimelineMarker`'s documentation said the marker's size laid out the + rail column. It never did — the column's width comes from `markerColumnWeight(...)` or + `axisWidth(...)`, and the field the sentence pointed at was read by nothing. + +- **A timeline's marker column can be given a width in points.** + `TimelineBuilder.axisWidth(double)` is the peer of `markerColumnWeight(double)`: points + rather than a share of the row. Reach for it when the markers should sit the same + distance from the edge on every page width — a weight is a share of what the row has + left, so it moves when the page or the columns beside it do. + + Declare one or the other, not both; a timeline that asks for both throws, naming both + calls. There is no conversion between them that does not need a row width neither the + builder nor the caller has, and a conversion done anyway is right on exactly one page: + the default `markerColumnWeight(0.10)` resolves to 24pt on a 320pt page and to 38.5pt on + a 480pt one. Nothing converts; the row resolves whichever it was handed. Timelines that + set neither are unchanged. + +- **A timeline can put a column before its markers — the `DATE` of `DATE | ● | CONTENT`.** + `TimelineBuilder.leadingColumn(DocumentRowColumn)` declares the width once for the whole + timeline and `TimelineEntryBuilder.leading(Consumer)` fills it per entry. + Every entry gets the column, including entries that put nothing in it, because the point + of the column is that the markers after it start at the same x whatever the dates say. + Nothing is styled for you inside it, as with `content(...)`. + + `auto()` is rejected at the call, with the reason. An auto column is measured from its + own row's content, so a timeline whose dates read `2023` and `September 2024 - present` + would put those two markers 131pt apart — measured, which is why this is an exception + rather than a documented caveat. `fixed(points)` and `weight(share)` are decided by the + row and both align exactly. Leading content without a declared column throws too, naming + the call to add, rather than inventing a width per entry. + +- **A timeline entry can fill its own content column.** + `TimelineBuilder.entry(Consumer)` is a longer form of the existing + `entry(marker, ...)` that takes its marker from `TimelineEntryBuilder.marker(...)` inside + the lambda, and `TimelineEntryBuilder.content(Consumer)` hands that + entry's content column over whole. An entry that needs a table, a chart or a nested row + beside its marker no longer has to express it as a title plus an `add(...)` block below + the body. + + The two ways of describing an entry do not mix. `title`/`meta`/`body`/`add` and their + style overrides describe slots the timeline styles and arranges for you; `content(...)` + says it should arrange nothing. Calling both on one entry throws at authoring time, in + either order, rather than quietly letting one win — a mistake that would otherwise + surface only in the rendered document. `entry(marker, ...)` and `entry(e -> e.marker(...))` + build the same entry, and declaring a marker both ways throws for the same reason. + - **A vertical flow can pin its width and still grow with its content.** `AbstractFlowBuilder.fixedWidth(double)` — so `addSection(s -> s.fixedWidth(240))`, `module(m -> m.fixedWidth(240))` and `pageFlow(page -> page.fixedWidth(200))` — @@ -98,6 +193,16 @@ follow semantic versioning; release dates are ISO 8601. middle — while horizontal margins come off every slice as before. **An anchor whose content fits on one page is unchanged**, which is every anchor that exists today. +- **A timeline's markers now report where they landed, and the marker column gains a + level in the node tree.** Each marker is wrapped so the finished layout carries one + resolved anchor per marker — one box however many shapes the marker drew, on the page + the marker is actually on — which is what the rail will be computed from instead of a + per-entry section border. Nothing is drawn for it and nothing moves: every existing + timeline renders pixel for pixel as it did, and every box that was in the layout graph is + still there at the same coordinates. What changes is the *paths*: the marker's column now + holds a wrapper holding the marker, so a **committed layout snapshot that includes a + timeline needs re-recording** — check that the diff is only added wrapper entries and + renamed paths before approving it, as the three snapshots in this repository were. - **A built-in feature can now draw from geometry the layout has already resolved.** Some things cannot be drawn while laying out because they depend on where other things @@ -227,8 +332,122 @@ follow semantic versioning; release dates are ISO 8601. zero — and no layout snapshot, pixel baseline or committed preview moves: every composed cell in the templates and the examples uses zero margins. +### Fixed + +- **`markerOnRail()` no longer draws the rail through the entry's text.** + Putting the markers on the rail moves the line into the middle of the axis column, and an + entry's body spanned the whole entry — so the line was drawn straight through ordinary + body text. It now starts where the title starts: + + ``` + LEADING | AXIS | CONTENT + | ● | title + | │ | body line 1 + | │ | body line 2 + | ● | next entry + ``` + + The body uses the content column the entry's own header row resolved, so a column given in + points and a column given as a share of the row behave identically — neither is recomputed + — and it stays a vertical block, so an entry longer than a page still splits across pages + with its text at the same x on every one. + + A timeline that does not call `markerOnRail()` is untouched: the rail stays beside the + axis, the body still spans the entry and clears the line by the gutter, and every baseline + and layout snapshot of one is byte-identical. What changed is a narrower body under + `markerOnRail()`, which wraps into more lines — so those timelines can take more pages than + they did while the text was running under the line. + +- **A timeline marker is the box it declared.** + `TimelineMarker.custom(width, height, recipe)` took a box and then ignored it: the + timeline measured whatever the recipe happened to draw, so the two numbers a caller wrote + reserved nothing and the class documentation — "the box is declared, not measured" — was + describing an intention rather than the code. A recipe drawing a 10pt dot inside a + declared 30pt box resolved to 10, and everything derived from the marker followed the ink + instead of the declaration. + + The recipe is now handed a canvas of exactly the declared size and draws from its origin. + Smaller content leaves the rest of the box empty; larger content overflows visibly rather + than growing it, and the declaration outranks even the axis column, because the rail is + derived from this box and a clamped one would put the line where nothing asked for it. The + four built-in factories draw exactly what they declare, so none of them moved — every + pixel baseline is unchanged, and the layout snapshots gained one node per marker and not + one changed coordinate. + +- **A timeline inside a card keeps its rail.** + A rail is drawn under the body, and "the body" was taken to be the document's: the + fragment went to the front of the list, before everything. Everything includes the fill of + whatever the timeline sits inside, so a timeline in a panel had its rail painted first and + covered a moment later — present in the geometry, absent from the page, and invisible to + any assertion that reads coordinates. The feature catalogue is exactly that shape, and its + timeline lost its line. + + Under-body now means under the contributing feature's own content: the fragment is spliced + immediately before the first fragment that feature drew on that page. A pass that anchored + nothing on a page still goes to the front, which is what a page-wide backdrop wants. + +- **A DOCX export no longer loses the content of a wrapper it cannot draw.** + The semantic backend writes the nodes it recognises and skips the rest, and skipping a + wrapper took its whole subtree with it. Two were unknown to it: `AlignNode`, which says + where in the available width to place its child, and the internal anchor a feature uses + to learn where its child landed. Word lays text out itself, so neither survives as + geometry — but each has exactly one child, and that child is the document. An aligned + section exported as a well-formed file with its text missing: no exception, no warning, + nothing to read. + + Both are transparent to the export now, in the document walk and the row-cell walk + alike — a wrapper handled in one and missed in the other loses a subtree just as + completely. PDF and PPTX were never affected; they draw what the layout produced. + +### Tests + +- **The timeline's finished visual model is pinned scene by scene.** Ten scenarios, each + given the instrument that can decide it: a coordinate where the claim is a coordinate, + a picture where the claim is a shape or a paint order, and neither where the other + already says it. Five new baselines — three marker sizes on one axis, the two extents + drawn on one identical scene so the pair reads as a diff, a rail crossing three pages, + and a ring the line disappears behind — with nine assertions for the invariants that + only exist once the parts are assembled: one x for a whole timeline however many pages + it crosses, a date of any length leaving the axis alone, no fragment reaching outside + its own page's band, and a marker of three shapes railing exactly as a plain one of the + same declared box. + + No layout snapshot was added, and that is measured rather than preferred: a snapshot + records nodes, the rail is a fragment, and neither committed timeline snapshot contains + the word. The four baselines recorded before the rework are byte-identical. + +- **A timeline written before the rail moved is guarded against moving.** Eleven documents + using nothing but the builder as it shipped — every marker factory, every knob, a body + across a page break, an entry taller than four pages, a timeline started near the bottom + of one, one inside a padded section, two on a page — hold every placed box and every page + count they had, and the per-entry borders they used to draw are covered by the rail that + replaced them to within 1.4e-14 across sixteen page-instances. No member left the public + surface: 2540 before, 2556 after, all sixteen of the difference new. + + What can be re-checked on one branch is a test: every method the old builder had, called + in one expression; the default anchor still packing markers left; the page counts; one + rail on each page the entries occupy and on no other; the two extents moving nothing but + the rail; a padded section, a margin and a card each carrying the timeline with them; five + constructions of one 16pt marker; an outline of any thickness; and the rail painted before + the text and not only before the markers. + ### Documentation +- **The timeline recipe describes the finished model.** `LEADING | AXIS | CONTENT`, what the + rail belongs to and what cannot move it, both ways to fill an entry, the leading column and + why `auto()` is refused, the two axis sizings, what a declared marker box means, what + `markerOnRail()` does to markers *and* to the body, the two supported extents and the one + that is not, pagination, and what each backend does with a rail. The old sentence calling + the rail "a left accent border on each entry" is gone; it stopped being true when the rail + became one line resolved after layout. + +- **Two engine seams are written down, and so is the difference between them.** + `docs/architecture/resolved-layout-seams.md`: a resolved-layout pass reads geometry that is + already settled and can only draw, while a resolved horizontal band is read during the same + compile and therefore changes what is measured after it. The note ends with the paint order + in one block, including what "under the body" means — under the contributing feature's own + content, which is not the same as the front of the page's fragment list. + - **A row's width rule, and what `fill()` does when there is no slot.** Two things a signature cannot say now have a page and a proof. A row with no `columns(...)`, no `weights(...)`, no grow spacer and the default `START` arrangement splits its inner diff --git a/assets/readme/examples/feature-catalog.pdf b/assets/readme/examples/feature-catalog.pdf index 1e9825605..48de857dc 100644 Binary files a/assets/readme/examples/feature-catalog.pdf and b/assets/readme/examples/feature-catalog.pdf differ diff --git a/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java b/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java index e5274e59d..9f7332fb6 100644 --- a/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java +++ b/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java @@ -6,10 +6,15 @@ import com.demcha.compose.document.layout.ResolvedLayoutAddition; import com.demcha.compose.document.layout.ResolvedLayoutMetadata; import com.demcha.compose.document.layout.ResolvedLayoutPass; +import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload; import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.TreeMap; /** * Runs the resolved-layout passes over a compiled graph and splices what they contribute. @@ -26,7 +31,12 @@ * * *

A pass running after backgrounds would have its under-body fragment prepended to - * index 0 — beneath an opaque page background, and invisible.

+ * index 0 — beneath an opaque page background, and invisible. Within the body the same + * hazard is local rather than page-wide: an under-body fragment placed at the front of the + * list also sits beneath the fill of every container drawn before the feature, including + * the panel the feature is inside. So under-body means under the contributing feature's own + * content — spliced immediately before the first fragment its anchors produced on that + * page — and a pass that anchored nothing on a page keeps the front of the list.

* * @author Artem Demchyshyn * @since 2.4.0 @@ -48,18 +58,30 @@ private ResolvedLayoutPasses() { */ static LayoutGraph apply(LayoutGraph base, List passes) { Objects.requireNonNull(base, "base"); - if (passes == null || passes.isEmpty()) { - return base; - } // Collected once, from the compiled graph, and handed to every pass. Nothing a // pass contributes can seed a new anchor for a later pass, so the result does not // depend on how the passes happen to interleave. ResolvedLayoutMetadata metadata = ResolvedLayoutMetadata.from(base); - List under = new ArrayList<>(); + List running = discover(metadata); + if (passes != null) { + running.addAll(passes); + } + if (running.isEmpty()) { + return base; + } + + // Where each pass's own content starts, page by page, so that "under the body" can + // mean under *its* body. Prepending to index 0 instead puts the fragment beneath + // everything drawn before the feature — including the fill of a panel the feature + // sits inside, which paints over it and leaves no trace in the geometry. + Map> ownContent = firstOwnFragmentPerPage(base); + + Map> under = new TreeMap<>(); List over = new ArrayList<>(); - for (ResolvedLayoutPass pass : passes) { + int underCount = 0; + for (ResolvedLayoutPass pass : running) { List additions = pass.contribute(base, metadata); if (additions == null) { throw new IllegalStateException( @@ -83,23 +105,94 @@ static LayoutGraph apply(LayoutGraph base, List passes) { requireFinite(pass, fragment.y(), "y"); requireFinite(pass, fragment.width(), "width"); requireFinite(pass, fragment.height(), "height"); - (addition.depth() == LayoutDepth.UNDER_BODY ? under : over).add(fragment); + if (addition.depth() == LayoutDepth.UNDER_BODY) { + under.computeIfAbsent( + ownContent.getOrDefault(pass, Map.of()) + .getOrDefault(fragment.pageIndex(), 0), + index -> new ArrayList<>()) + .add(fragment); + underCount++; + } else { + over.add(fragment); + } } } - if (under.isEmpty() && over.isEmpty()) { + if (underCount == 0 && over.isEmpty()) { return base; } - List combined = - new ArrayList<>(under.size() + base.fragments().size() + over.size()); - combined.addAll(under); - combined.addAll(base.fragments()); + List body = base.fragments(); + List combined = new ArrayList<>(underCount + body.size() + over.size()); + for (int i = 0; i < body.size(); i++) { + combined.addAll(under.getOrDefault(i, List.of())); + combined.add(body.get(i)); + } + combined.addAll(under.getOrDefault(body.size(), List.of())); combined.addAll(over); // Nodes pass through untouched: a pass contributes drawing, never structure. return new LayoutGraph(base.canvas(), base.totalPages(), base.nodes(), combined); } + /** + * The first fragment each anchor group put on each page. + * + *

An anchor's own fragment precedes its child's, so this is where a feature's + * content begins in draw order — and therefore where something drawn beneath that + * feature has to be spliced. One scan, and the answer for every pass at once.

+ * + * @param base the compiled graph + * @return group key to page to first index; groups with no anchors are absent + */ + private static Map> firstOwnFragmentPerPage(LayoutGraph base) { + Map> first = new IdentityHashMap<>(); + List fragments = base.fragments(); + for (int i = 0; i < fragments.size(); i++) { + PlacedFragment fragment = fragments.get(i); + if (fragment.payload() instanceof LayoutAnchorPayload anchor) { + first.computeIfAbsent(anchor.id().groupKey(), key -> new HashMap<>()) + .putIfAbsent(fragment.pageIndex(), i); + } + } + return first; + } + + /** + * The passes the document itself asks for, found in what it anchored. + * + *

A built-in feature declares an owner on the semantic tree and keys its anchors on + * it; an owner that is also a pass is a feature saying it has something to + * draw once the layout is settled. Nothing is registered, no session is handed around, + * and this class stays ignorant of every feature that uses it — it asks whether the + * owner is a pass, not what kind of thing it is.

+ * + *

Order is first appearance in the anchor list, which is the compiler's placement + * order, which is reading order down the document. Two instances of one feature on a + * page therefore draw in the order they were written.

+ * + * @param metadata the anchors the document resolved + * @return the passes to run, deduplicated by identity, in document order + */ + private static List discover(ResolvedLayoutMetadata metadata) { + List found = new ArrayList<>(); + for (var anchor : metadata.anchors()) { + if (anchor.id().groupKey() instanceof ResolvedLayoutPass pass && !containsSame(found, pass)) { + found.add(pass); + } + } + return found; + } + + /** Identity, not equality: two owners configured alike are still two features. */ + private static boolean containsSame(List passes, ResolvedLayoutPass pass) { + for (ResolvedLayoutPass known : passes) { + if (known == pass) { + return true; + } + } + return false; + } + private static void requireFinite(ResolvedLayoutPass pass, double value, String name) { if (!Double.isFinite(value)) { throw new IllegalStateException("Resolved-layout pass '" + pass.id() diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java new file mode 100644 index 000000000..f67724690 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java @@ -0,0 +1,25 @@ +package com.demcha.compose.document.dsl; + +/** + * What a timeline anchor marks. + * + *

An enum constant because the resolved-layout seam compares an anchor's kind by + * reference — a string would work or fail on interning, which is why it refuses one.

+ * + * @author Artem Demchyshyn + * @since 2.4.0 + */ +enum TimelineAnchorKind { + + /** The marker in an entry's axis column — where the rail passes, horizontally. */ + MARKER, + + /** + * A whole entry — where the rail starts and stops, vertically. + * + *

Separate from the marker because they answer different questions, and because an + * entry is the only one of the two that can span pages: its slices carry each page's + * content band, which is what a rail crossing a page break needs.

+ */ + ENTRY +} diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineAxisSize.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAxisSize.java new file mode 100644 index 000000000..4a541d6ef --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAxisSize.java @@ -0,0 +1,39 @@ +package com.demcha.compose.document.dsl; + +/** + * How wide the axis column — the one the markers sit in — is. + * + *

Two strategies, and deliberately not one number. A weight is a share of what the row + * has left; a fixed width is points. Converting one into the other would need the row's + * width, which the builder does not have, and doing it anyway is how a timeline that has + * rendered the same way for versions quietly moves: {@code markerColumnWeight(0.10)} means + * "a tenth of the remainder" on a 260pt page and on a 500pt one, and no single point value + * is both.

+ * + *

So both survive to the layout, which asks the row to resolve whichever it was given. + * A closed set of two rather than a reused {@code DocumentRowColumn} because the third + * strategy that type offers — auto — cannot align a column across rows, which is the one + * thing this column has to do.

+ * + * @author Artem Demchyshyn + * @since 2.4.0 + */ +sealed interface TimelineAxisSize { + + /** + * An axis of exactly this many points, the same on any page width. + * + * @param points width in points + */ + record Fixed(double points) implements TimelineAxisSize { + } + + /** + * An axis taking this share of the row's remaining space, against a content weight + * of 1.0. + * + * @param weight relative share + */ + record Weight(double weight) implements TimelineAxisSize { + } +} diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java index 0c27a7a36..1d2914df5 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java @@ -1,7 +1,17 @@ package com.demcha.compose.document.dsl; +import com.demcha.compose.document.layout.HorizontalBandContentNode; +import com.demcha.compose.document.layout.HorizontalBandsNode; +import com.demcha.compose.document.layout.LayoutAnchorId; +import com.demcha.compose.document.layout.LayoutAnchorNode; +import com.demcha.compose.document.node.AlignNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.HorizontalAlign; +import com.demcha.compose.document.node.SectionNode; 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.DocumentStroke; import com.demcha.compose.document.style.DocumentTextDecoration; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.font.FontName; @@ -27,14 +37,18 @@ * .title("Engineer").meta("2019 - 2021").body("Built ..."))); * } * - *

The rail is a left border on each entry that auto-stretches to the entry's - * height, so it spans variable-length content without any fixed sizing; entries - * stack flush so the rail reads as one continuous line. The timeline paginates - * between entries, and a tall entry splits within itself — between its marker row - * and its body, and within the body — with the rail continuing across the page - * break. Only the single marker-plus-title row of an entry is atomic, so it would - * throw {@code AtomicNodeTooLargeException} only in the degenerate case of one - * marker row taller than a whole page.

+ *

The rail is one logical line, computed after layout from where the markers and + * entries actually landed and drawn as one fragment per page it crosses. How far it + * runs is a {@link TimelineRailExtent}; where it runs comes from the marker anchor, + * a gutter to the left of the markers by default or through them after + * {@link #markerOnRail()}. It is drawn beneath the markers, so a filled marker + * covers the line passing under it.

+ * + *

The timeline paginates between entries, and a tall entry splits within itself — + * between its marker row and its body, and within the body — with the rail + * continuing across the page break. Only the single marker-plus-title row of an + * entry is atomic, so it would throw {@code AtomicNodeTooLargeException} only in the + * degenerate case of one marker row taller than a whole page.

* * @author Artem Demchyshyn * @since 1.7.0 @@ -44,12 +58,16 @@ public final class TimelineBuilder { private static final DocumentColor DEFAULT_RAIL = DocumentColor.rgb(150, 158, 172); private static final DocumentColor DEFAULT_INK = DocumentColor.rgb(34, 38, 50); private static final DocumentColor DEFAULT_MUTED = DocumentColor.rgb(120, 124, 136); - private final List entries = new ArrayList<>(); - private DocumentColor connectorColor = DEFAULT_RAIL; - private double connectorWidth = 1.5; + private final List entries = new ArrayList<>(); + private DocumentStroke railStroke = DocumentStroke.of(DEFAULT_RAIL, 1.5); + private String railDeclaredBy; + private TimelineRailExtent railExtent = TimelineRailExtent.ENTRY_BOUNDS; + private TimelineMarkerAnchor markerAnchor; private double gutter = 8.0; private double markerGap = 8.0; - private double markerColumnWeight = 0.10; + private TimelineAxisSize axis = new TimelineAxisSize.Weight(0.10); + private String axisDeclaredBy; + private DocumentRowColumn leadingColumn; private double entrySpacing = 14.0; private DocumentTextStyle titleStyle; private DocumentTextStyle metaStyle; @@ -60,10 +78,6 @@ public final class TimelineBuilder { TimelineBuilder() { } - private static boolean notBlank(String value) { - return value != null && !value.isBlank(); - } - private static DocumentTextStyle defaultTitleStyle() { return DocumentTextStyle.builder() .fontName(FontName.HELVETICA) @@ -92,20 +106,108 @@ private static DocumentTextStyle defaultBodyStyle() { /** * Sets the connector rail colour and width. * + *

The shorthand for {@link #rail(Consumer)}: both describe the same rail, and a + * timeline that uses both throws rather than letting one of them win. A call that + * changes nothing — a null colour and a non-positive width — is not a use.

+ * * @param color rail colour; ignored when {@code null} * @param width rail width in points; ignored when not positive * @return this builder + * @throws IllegalStateException if the rail is already configured */ public TimelineBuilder connector(DocumentColor color, double width) { - if (color != null) { - this.connectorColor = color; + if (color == null && !(width > 0)) { + return this; + } + declareRailOnce("connector"); + this.railStroke = DocumentStroke.of( + color == null ? railStroke.color() : color, + width > 0 ? width : railStroke.width()); + return this; + } + + /** + * Configures the connector rail. + * + *

{@link #connector(DocumentColor, double)} is the shorthand for this and produces + * the same rail — one configuration, not an old one and a new one. Setting the rail + * both ways throws rather than letting one of them win.

+ * + * @param spec rail builder callback + * @return this builder + * @throws NullPointerException if {@code spec} is null + * @throws IllegalStateException if the rail is already configured + * @since 2.4.0 + */ + public TimelineBuilder rail(Consumer spec) { + Objects.requireNonNull(spec, "spec"); + TimelineRailBuilder builder = new TimelineRailBuilder(); + spec.accept(builder); + if (builder.extent() != null) { + this.railExtent = builder.extent(); } - if (width > 0) { - this.connectorWidth = width; + if (builder.stroke() == null) { + return this; } + declareRailOnce("rail"); + this.railStroke = builder.stroke(); + return this; + } + + /** + * Puts the markers on the rail, rather than beside it. + * + *

A timeline draws its rail at the marker's left edge, pulled back by the gutter — + * where it has been since before there was a choice, and a distance that grows with the + * marker, so leaving it alone is what "renders unchanged" means. This opts a timeline + * into the other anchor: the rail passes through the marker's centre, at every marker + * size.

+ * + *

It moves the markers too, because that is what putting them on the rail means: + * each is placed inside the axis column so that its anchor point lands on the axis, + * which for the centre anchor is the middle of that column. Markers of different sizes + * therefore share one line instead of one left edge.

+ * + *

An entry's body moves as well, into the content column beside the marker. With the + * rail beside the axis a body spanning the entry clears the line by the gutter; with the + * rail inside the axis that same body would be drawn through, so the body starts where + * the title starts. It stays a vertical block — an entry longer than a page still splits, + * with its text at the same x on every page — and the column it uses is the one the + * entry's own header row resolved, so a fixed axis and a weighted one behave alike.

+ * + *

A timeline that does not call this keeps the left-edge anchor and the placement it + * has always had: markers packed to the left of the axis column, rail one gutter + * further left.

+ * + * @return this builder + * @since 2.4.0 + */ + public TimelineBuilder markerOnRail() { + this.markerAnchor = TimelineMarkerAnchor.onTheRail(); return this; } + /** + * Rejects the rail being configured through both spellings. + * + *

Calling the same one twice is ordinary setter accumulation and stays + * legal — {@code connector(colour, 0)} then {@code connector(null, width)} has always + * been a way to set the two halves separately, and code doing that must not start + * throwing. What is rejected is a timeline that says it both ways.

+ * + * @param call the spelling being used + * @throws IllegalStateException if the other spelling already configured the rail + */ + private void declareRailOnce(String call) { + if (railDeclaredBy != null && !railDeclaredBy.equals(call)) { + throw new IllegalStateException( + "A timeline has one rail, configured once: this one calls " + railDeclaredBy + + "(...) and " + call + "(...). connector(colour, width) is the shorthand for " + + "rail(r -> r.stroke(...)), so either says the whole thing."); + } + railDeclaredBy = call; + } + /** * Sets the gutter between the rail and the marker / content. * @@ -136,13 +238,88 @@ public TimelineBuilder markerGap(double gap) { * Sets the relative width of the marker column (its weight against a content * weight of 1.0). Increase it for large numbered discs on narrow timelines. * + *

See {@link #axisWidth(double)} for the same column in points. Declare one or the + * other, not both.

+ * * @param weight marker column weight; ignored when not positive * @return this builder + * @throws IllegalStateException if the axis width is already declared */ public TimelineBuilder markerColumnWeight(double weight) { if (weight > 0) { - this.markerColumnWeight = weight; + declareAxisOnce("markerColumnWeight"); + this.axis = new TimelineAxisSize.Weight(weight); + } + return this; + } + + /** + * Sets the axis column — the one the markers sit in — to a fixed width in points. + * + *

The peer of {@link #markerColumnWeight(double)}, and the one to reach for when the + * markers should sit the same distance from the edge whatever the page width: a weight + * is a share of what the row has left, so it moves when the page or the columns beside + * it do.

+ * + *

Declare one or the other, not both. They are two answers to the same question, and + * there is no conversion between them that does not need a row width neither the + * builder nor the caller has.

+ * + * @param points axis width in points + * @return this builder + * @throws IllegalArgumentException if {@code points} is not positive and finite + * @throws IllegalStateException if the axis width is already declared + * @since 2.4.0 + */ + public TimelineBuilder axisWidth(double points) { + if (!(points > 0) || Double.isInfinite(points)) { + throw new IllegalArgumentException( + "A timeline's axis width must be a positive finite number of points, got: " + points); } + declareAxisOnce("axisWidth"); + this.axis = new TimelineAxisSize.Fixed(points); + return this; + } + + private void declareAxisOnce(String call) { + if (axisDeclaredBy != null) { + throw new IllegalStateException( + "A timeline's axis column has one width, declared once: this one calls " + + axisDeclaredBy + "(...) and " + call + "(...). A weight is a share of the " + + "row and a fixed width is points, so neither can stand in for the other."); + } + axisDeclaredBy = call; + } + + /** + * Gives every entry a column before its marker, for the {@code DATE} of a + * {@code DATE | ● | CONTENT} timeline. + * + *

The width is declared once, for the whole timeline, and every entry gets it — + * including entries that put nothing in it, so they stay aligned with the ones that + * do. Fill it per entry with {@link TimelineEntryBuilder#leading(Consumer)}.

+ * + *

{@link DocumentRowColumn#auto()} is rejected: an auto column is measured from its + * own row's content, so entries with leading text of different lengths would place + * their markers at different x and the rail would not be straight. A fixed width or a + * weight is decided by the row, which is what makes it the same in every entry.

+ * + * @param column the leading column's width + * @return this builder + * @throws NullPointerException if {@code column} is null + * @throws IllegalArgumentException if {@code column} is {@link DocumentRowColumn#auto()} + * @since 2.4.0 + */ + public TimelineBuilder leadingColumn(DocumentRowColumn column) { + Objects.requireNonNull(column, "column"); + if (column.type() == DocumentRowColumn.Type.AUTO) { + throw new IllegalArgumentException( + "A timeline's leading column cannot be auto(): an auto column is measured " + + "from its own row's content, so entries with leading text of different " + + "lengths would put their markers at different x and the rail would not be " + + "straight. Use fixed(points) or weight(share), which the row decides."); + } + this.leadingColumn = column; return this; } @@ -203,10 +380,35 @@ public TimelineBuilder bodyStyle(DocumentTextStyle style) { public TimelineBuilder entry(TimelineMarker marker, Consumer content) { Objects.requireNonNull(marker, "marker"); TimelineEntryBuilder entry = new TimelineEntryBuilder(); + entry.markerFromShorthand(marker); if (content != null) { content.accept(entry); } - entries.add(new Entry(marker, entry)); + entries.add(entry); + return this; + } + + /** + * Adds one timeline entry, marker included. + * + *

The longer form of {@link #entry(TimelineMarker, Consumer)}, for entries that + * describe their own content rather than filling in a title, a meta line and a body:

+ *
{@code
+     * timeline.entry(e -> e
+     *     .marker(TimelineMarker.dot(8, accent))
+     *     .content(column -> column.addParagraph("Anything at all")));
+     * }
+ * + * @param entry callback configuring the entry, which must set a marker + * @return this builder + * @throws NullPointerException if {@code entry} is {@code null} + * @since 2.4.0 + */ + public TimelineBuilder entry(Consumer entry) { + Objects.requireNonNull(entry, "entry"); + TimelineEntryBuilder built = new TimelineEntryBuilder(); + entry.accept(built); + entries.add(built); return this; } @@ -238,62 +440,205 @@ public TimelineBuilder keepEntriesTogether() { } void buildInto(SectionBuilder timeline) { - timeline.spacing(0); - timeline.keepTogether(keepTogether); + layout(normalize(), timeline); + } + + /** + * Resolves this builder's defaults and per-entry overrides into the internal model. + * + *

Everything the authoring API knows and the layout does not — which style a slot + * fell back to, whether a body was given at all — is settled here.

+ * + * @return the normalized timeline + */ + private TimelineSpec normalize() { DocumentTextStyle resolvedTitle = titleStyle != null ? titleStyle : defaultTitleStyle(); DocumentTextStyle resolvedMeta = metaStyle != null ? metaStyle : defaultMetaStyle(); DocumentTextStyle resolvedBody = bodyStyle != null ? bodyStyle : defaultBodyStyle(); + List specs = new ArrayList<>(entries.size()); + for (TimelineEntryBuilder entry : entries) { + if (leadingColumn == null && entry.hasLeading()) { + // Caught here rather than dropped: without a declared width there is no + // column to put it in, and inventing one per entry is exactly what would + // stop the markers lining up. + throw new IllegalStateException( + "An entry has leading(...) content but the timeline has no leading column. " + + "Call leadingColumn(...) on the timeline, so every entry's leading is the " + + "same width and the markers line up."); + } + specs.add(entry.normalize(resolvedTitle, resolvedMeta, resolvedBody)); + } + // One owner per timeline, allocated here. Every marker below anchors on this + // instance, so the pass that draws the rail asks for it and gets these markers and + // nobody else's — two timelines on a page never merge. + if (railExtent == TimelineRailExtent.TIMELINE_BOUNDS) { + throw new IllegalArgumentException( + "TimelineRailExtent.TIMELINE_BOUNDS is not implemented. On one page it is the " + + "same line as ENTRY_BOUNDS, and across pages there is nothing to measure it " + + "against — a timeline's own box draws nothing. Use ENTRY_BOUNDS or " + + "MARKER_TO_MARKER."); + } + TimelineRailSpec railSpec = new TimelineRailSpec(railStroke); + // The gutter is only knowable here, so the default anchor is resolved here too — + // and it is the same model the opted-in one uses, not a branch beside it. + TimelineMarkerAnchor anchor = + markerAnchor == null ? TimelineMarkerAnchor.atLeftEdge(gutter) : markerAnchor; + return new TimelineSpec(new TimelineRailOwner(railSpec, railExtent, anchor), + railSpec, leadingColumn, gutter, markerGap, axis, anchor, entrySpacing, + keepTogether, keepEntriesTogether, List.copyOf(specs)); + } + + /** + * Lays a normalized timeline out. It reads nothing but the spec, which is what will + * let a second authoring API reach this same code without it learning of that API. + * + * @param spec the normalized timeline + * @param timeline the section the timeline is built into + */ + private static void layout(TimelineSpec spec, SectionBuilder timeline) { + timeline.spacing(0); + timeline.keepTogether(spec.keepTogether()); + // Which column the body belongs in, and whether the question arises at all. It does + // only when the rail moved into the axis; with the rail beside it the body spans the + // entry as it always has, and the header row publishes nothing. + boolean bodyClearsTheAxis = spec.markerAnchor().railRunsThroughTheAxis(); + int contentColumn = spec.leadingColumn() == null ? 1 : 2; + List entries = spec.entries(); for (int i = 0; i < entries.size(); i++) { - Entry entry = entries.get(i); + TimelineEntrySpec entry = entries.get(i); + int index = i; boolean last = i == entries.size() - 1; - double bottom = last ? 0.0 : entrySpacing; - timeline.addSection(section -> { - section.keepTogether(keepEntriesTogether) - .accentLeft(connectorColor, connectorWidth) - .padding(new DocumentInsets(0, 0, bottom, gutter)) + double bottom = last ? 0.0 : spec.entrySpacing(); + // One identity per entry, because one row resolves one set of columns. Nothing + // reads it but the body immediately below, and nothing else can: it is compared + // by reference and never leaves this loop. + Object bandKey = bodyClearsTheAxis ? new Object() : null; + SectionBuilder entrySection = new SectionBuilder(); + { + SectionBuilder section = entrySection; + // No accentLeft. The rail is one logical line drawn from the resolved + // anchors below, not a border repeated per entry — which is why it can + // start and stop at the markers, and why it holds its x under markers of + // different sizes. + section.keepTogether(spec.keepEntriesTogether()) + .padding(new DocumentInsets(0, 0, bottom, spec.gutter())) .spacing(4); - section.addRow(header -> { - header.spacing(markerGap).weights(markerColumnWeight, 1.0); - header.addSection(markerColumn -> { - markerColumn.spacing(0); - entry.marker().renderInto(markerColumn); - }); - header.addSection(titleColumn -> { - titleColumn.spacing(2); - if (notBlank(entry.entry().title())) { - DocumentTextStyle style = entry.entry().titleStyle() != null - ? entry.entry().titleStyle() : resolvedTitle; - titleColumn.addParagraph(p -> p - .text(entry.entry().title()) - .textStyle(style) - .margin(DocumentInsets.zero())); - } - if (notBlank(entry.entry().meta())) { - DocumentTextStyle style = entry.entry().metaStyle() != null - ? entry.entry().metaStyle() : resolvedMeta; - titleColumn.addParagraph(p -> p - .text(entry.entry().meta()) - .textStyle(style) - .margin(DocumentInsets.zero())); - } - }); - }); - if (notBlank(entry.entry().body())) { - DocumentTextStyle style = entry.entry().bodyStyle() != null - ? entry.entry().bodyStyle() : resolvedBody; - section.addParagraph(p -> p - .text(entry.entry().body()) - .textStyle(style) - .lineSpacing(1.3) - .margin(DocumentInsets.zero())); - } - if (entry.entry().extra() != null) { - entry.entry().extra().accept(section); + Consumer headerSpec = header -> { + header.spacing(spec.markerGap()); + DocumentRowColumn axis = column(spec.axis()); + if (spec.leadingColumn() == null && spec.axis() instanceof TimelineAxisSize.Weight weight) { + // The same two columns either way — columns(weight, weight) resolves + // exactly as weights(...) does, confirmed by the snapshots. But + // weights(...) is what a timeline has always put on its RowNode, and + // RowNode.weights() is public; spelling it the other way empties that + // list for every timeline that exists. Sugar where the sugar applies. + header.weights(weight.weight(), 1.0); + } else if (spec.leadingColumn() == null) { + header.columns(axis, DocumentRowColumn.weight(1.0)); + } else { + header.columns(spec.leadingColumn(), axis, DocumentRowColumn.weight(1.0)); + // Present even when this entry put nothing in it: the column is the + // timeline's, not the entry's, and an entry that skipped it must + // still start its marker where every other entry starts one. + header.addSection(entry.leading() == null ? column -> { } : entry.leading()); + } + header.addSection(anchoredMarker(spec, entry, index)); + header.addSection(entry.beside()); + }; + + if (bandKey == null) { + // The rail is beside the axis, so a body spanning the entry clears it by + // the gutter. This is the layout every timeline written before the choice + // already has, and it is left exactly as it was. + section.addRow(headerSpec); + entry.below().accept(section); + } else { + // The rail runs through the axis, so a body spanning the entry would be + // crossed by it. The header publishes its columns; the body lays itself + // out in the content one and stays a vertical sibling, which is what lets + // it be longer than a page — a row cannot cross one. + RowBuilder header = new RowBuilder(); + headerSpec.accept(header); + section.add(new HorizontalBandsNode("", bandKey, header.build())); + + SectionBuilder body = new SectionBuilder(); + body.spacing(4); + entry.below().accept(body); + SectionNode built = body.build(); + if (!built.children().isEmpty()) { + section.add(new HorizontalBandContentNode("", bandKey, contentColumn, built)); + } } - }); + } + // The entry, anchored. Its slices are where the rail starts and stops on each + // page — and only its slices carry that, because an entry is the one thing + // here that can cross a page boundary. + timeline.add(new LayoutAnchorNode("", + new LayoutAnchorId(spec.owner(), TimelineAnchorKind.ENTRY, index), + entrySection.build())); } } - private record Entry(TimelineMarker marker, TimelineEntryBuilder entry) { + + /** + * The marker's column, wrapped so the finished layout reports where the marker landed. + * + *

The wrapper is what makes a marker one box however many fragments it drew: the + * anchor reports the wrapped node's own box, so a ring-disc-pip marker and a plain dot + * of the same size resolve identically. It adds a level to the layout paths — anything + * keying on those, a snapshot for one, sees it — and no geometry: the wrapper measures + * to its child and adds no spacing.

+ * + *

The anchor sits inside the row's column rather than being the column. A + * row hosts a fixed set of child types and an anchor is not one of them, and widening + * that list — a public builder's contract — for an internal wrapper would be the wrong + * trade. Wrapping the marker rather than its column is also the truer statement of what + * is being anchored, and it is what a recipe that draws several nodes needs: they + * become one box here.

+ * + * @param spec the timeline, for its owner + * @param entry the entry whose marker this is + * @param index the entry's position, which becomes the anchor's index + * @return the marker column, with the marker anchored inside it + */ + private static Consumer anchoredMarker(TimelineSpec spec, + TimelineEntrySpec entry, + int index) { + DocumentNode anchored = new LayoutAnchorNode("", + new LayoutAnchorId(spec.owner(), TimelineAnchorKind.MARKER, index), + entry.marker().node()); + // Where in the axis column the marker sits comes from the anchor, not from a mode: + // an anchor on the marker's left edge wants the marker at the column's left edge, + // one on its centre wants it at the column's centre. That is what puts markers of + // 6, 14 and 24pt on one line — each is centred in the same column, so each centre + // is the column's centre — and it works with a weight axis, whose width nobody + // knows until layout. + // + // The align wraps the anchor and not the other way round. The anchor has to stay + // around the marker itself or it would report the column's box, which is the whole + // thing the marker anchor exists not to be. + DocumentNode placed = new AlignNode(anchored, spec.markerAnchor().horizontalAlign()); + return markerColumn -> { + markerColumn.spacing(0); + markerColumn.add(placed); + }; + } + + + /** + * Hands the axis size to the row in the row's own vocabulary. + * + *

The only place the two strategies meet, and neither is converted into the other: + * the row resolves a weight against its own width, which is the number nobody upstream + * of it has.

+ * + * @param axis the axis size + * @return the column to give the row + */ + private static DocumentRowColumn column(TimelineAxisSize axis) { + if (axis instanceof TimelineAxisSize.Fixed fixed) { + return DocumentRowColumn.fixed(fixed.points()); + } + return DocumentRowColumn.weight(((TimelineAxisSize.Weight) axis).weight()); } } diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineEntryBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineEntryBuilder.java index aed8216b9..fd5b32ec1 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineEntryBuilder.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineEntryBuilder.java @@ -1,7 +1,10 @@ package com.demcha.compose.document.dsl; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentRowColumn; import com.demcha.compose.document.style.DocumentTextStyle; +import java.util.Objects; import java.util.function.Consumer; /** @@ -12,11 +15,24 @@ *

Each text slot has a no-style setter (the timeline's default style is * applied) and a per-entry style override.

* + *

An entry describes its content one of two ways, never both. The + * semantic way is {@link #title(String)}, {@link #meta(String)}, + * {@link #body(String)} and {@link #add(Consumer)}, which the timeline styles and + * arranges for you. The custom way is {@link #content(Consumer)}, which hands you + * the entry's content column to fill however you like. Mixing them throws, because the + * two disagree about what the entry's shape is rather than composing.

+ * * @author Artem Demchyshyn * @since 1.7.0 */ public final class TimelineEntryBuilder { + /** Which vocabulary an entry has committed to; null until it commits. */ + private enum Mode { SEMANTIC, CUSTOM } + + private Mode mode; + private TimelineMarker marker; + private boolean markerGivenByShorthand; private String title; private DocumentTextStyle titleStyle; private String meta; @@ -24,10 +40,86 @@ public final class TimelineEntryBuilder { private String body; private DocumentTextStyle bodyStyle; private Consumer extra; + private Consumer content; + private Consumer leading; TimelineEntryBuilder() { } + /** + * Sets the marker drawn in the rail for this entry. + * + *

An entry has exactly one marker. Declaring a second throws, whether the first came + * from the {@code entry(marker, e -> ...)} shorthand or from an earlier call to this + * method — quietly letting one of them win is a difference that would show up only in + * the rendered document.

+ * + * @param marker the marker + * @return this builder + * @throws NullPointerException if {@code marker} is null + * @throws IllegalStateException if the entry already has a marker + * @since 2.4.0 + */ + public TimelineEntryBuilder marker(TimelineMarker marker) { + Objects.requireNonNull(marker, "marker"); + if (this.marker != null) { + // Keyed on the marker itself, not on where the first one came from: two + // marker(...) calls in the advanced form are the same mistake as one beside the + // shorthand, and letting the second win would only show in the rendered page. + throw new IllegalStateException( + "A timeline entry has exactly one marker, and this entry already has " + + (markerGivenByShorthand + ? "the one from entry(marker, ...). Call marker(...) only inside " + + "entry(entry -> ...)." + : "one. Call marker(...) once.")); + } + this.marker = marker; + return this; + } + + /** + * Fills the entry's content column yourself, instead of describing it as a title, a + * meta line and a body. + * + *

Nothing is styled for you here — the timeline's title, meta and body styles + * describe slots this entry no longer has.

+ * + * @param content callback receiving the entry's content column + * @return this builder + * @throws NullPointerException if {@code content} is null + * @throws IllegalStateException if the entry already uses the semantic content API + * @since 2.4.0 + */ + public TimelineEntryBuilder content(Consumer content) { + Objects.requireNonNull(content, "content"); + enter(Mode.CUSTOM); + declareOnce(this.content != null, "content"); + this.content = content; + return this; + } + + /** + * Fills the column before the marker — the {@code DATE} of a + * {@code DATE | ● | CONTENT} timeline. + * + *

Legal with either way of describing the entry's content, because it describes a + * different column. The timeline must declare how wide that column is, with + * {@link TimelineBuilder#leadingColumn(DocumentRowColumn)}: the width has to be the + * same for every entry, or the markers do not line up and the rail is not straight.

+ * + * @param leading callback receiving the entry's leading column + * @return this builder + * @throws NullPointerException if {@code leading} is null + * @throws IllegalStateException if the entry already has leading content + * @since 2.4.0 + */ + public TimelineEntryBuilder leading(Consumer leading) { + Objects.requireNonNull(leading, "leading"); + declareOnce(this.leading != null, "leading"); + this.leading = leading; + return this; + } + /** * Sets the entry title (drawn beside the marker). * @@ -35,6 +127,7 @@ public final class TimelineEntryBuilder { * @return this builder */ public TimelineEntryBuilder title(String title) { + enter(Mode.SEMANTIC); this.title = title; return this; } @@ -47,6 +140,7 @@ public TimelineEntryBuilder title(String title) { * @return this builder */ public TimelineEntryBuilder title(String title, DocumentTextStyle style) { + enter(Mode.SEMANTIC); this.title = title; this.titleStyle = style; return this; @@ -59,6 +153,7 @@ public TimelineEntryBuilder title(String title, DocumentTextStyle style) { * @return this builder */ public TimelineEntryBuilder titleStyle(DocumentTextStyle style) { + enter(Mode.SEMANTIC); this.titleStyle = style; return this; } @@ -71,6 +166,7 @@ public TimelineEntryBuilder titleStyle(DocumentTextStyle style) { * @return this builder */ public TimelineEntryBuilder meta(String meta) { + enter(Mode.SEMANTIC); this.meta = meta; return this; } @@ -83,6 +179,7 @@ public TimelineEntryBuilder meta(String meta) { * @return this builder */ public TimelineEntryBuilder meta(String meta, DocumentTextStyle style) { + enter(Mode.SEMANTIC); this.meta = meta; this.metaStyle = style; return this; @@ -95,6 +192,7 @@ public TimelineEntryBuilder meta(String meta, DocumentTextStyle style) { * @return this builder */ public TimelineEntryBuilder metaStyle(DocumentTextStyle style) { + enter(Mode.SEMANTIC); this.metaStyle = style; return this; } @@ -106,6 +204,7 @@ public TimelineEntryBuilder metaStyle(DocumentTextStyle style) { * @return this builder */ public TimelineEntryBuilder body(String body) { + enter(Mode.SEMANTIC); this.body = body; return this; } @@ -118,6 +217,7 @@ public TimelineEntryBuilder body(String body) { * @return this builder */ public TimelineEntryBuilder body(String body, DocumentTextStyle style) { + enter(Mode.SEMANTIC); this.body = body; this.bodyStyle = style; return this; @@ -130,6 +230,7 @@ public TimelineEntryBuilder body(String body, DocumentTextStyle style) { * @return this builder */ public TimelineEntryBuilder bodyStyle(DocumentTextStyle style) { + enter(Mode.SEMANTIC); this.bodyStyle = style; return this; } @@ -142,35 +243,123 @@ public TimelineEntryBuilder bodyStyle(DocumentTextStyle style) { * @return this builder */ public TimelineEntryBuilder add(Consumer extra) { + enter(Mode.SEMANTIC); this.extra = extra; return this; } - String title() { - return title; + /** Records the marker the {@code entry(marker, ...)} shorthand supplied. */ + void markerFromShorthand(TimelineMarker marker) { + this.marker = marker; + this.markerGivenByShorthand = true; } - DocumentTextStyle titleStyle() { - return titleStyle; + /** Whether this entry was given leading content, for the timeline to check. */ + boolean hasLeading() { + return leading != null; } - String meta() { - return meta; + /** + * Rejects a second declaration of one of the entry's structural slots. + * + *

A slot that takes a whole column — {@code marker}, {@code leading}, + * {@code content} — is declared, not assigned. Two of them is a mistake rather than an + * override, and the shape of an entry should not depend on which call came last. + * Ordinary values like {@code title(...)} do replace, as builder setters normally + * do.

+ * + * @param alreadyDeclared whether the slot is already filled + * @param slot the slot's name, for the message + * @throws IllegalStateException if it is + */ + private static void declareOnce(boolean alreadyDeclared, String slot) { + if (alreadyDeclared) { + throw new IllegalStateException( + "A timeline entry declares " + slot + "(...) once; this entry declares it twice."); + } } - DocumentTextStyle metaStyle() { - return metaStyle; + /** + * Commits this entry to one content vocabulary, or rejects the second one. + * + * @param wanted the vocabulary the calling setter belongs to + * @throws IllegalStateException if the entry already committed to the other one + */ + private void enter(Mode wanted) { + if (mode != null && mode != wanted) { + throw new IllegalStateException( + "Cannot combine title/meta/body entry content with custom content(). " + + "Use either the semantic entry API or content()."); + } + mode = wanted; } - String body() { - return body; - } + /** + * Resolves this entry into the form the layout consumes. + * + *

Style resolution happens here and only here: a per-entry override wins, otherwise + * the timeline's default for that slot. Downstream there is no title, meta or body + * left — only content that goes beside the marker and content that goes below it, and + * no trace of which of the two authoring APIs described it.

+ * + * @param defaultTitleStyle the timeline's title style + * @param defaultMetaStyle the timeline's meta style + * @param defaultBodyStyle the timeline's body style + * @return the normalized entry + * @throws IllegalStateException if the entry has no marker + */ + TimelineEntrySpec normalize(DocumentTextStyle defaultTitleStyle, + DocumentTextStyle defaultMetaStyle, + DocumentTextStyle defaultBodyStyle) { + if (marker == null) { + throw new IllegalStateException( + "A timeline entry needs a marker: call marker(...) inside entry(entry -> ...), " + + "or use the entry(marker, ...) shorthand."); + } + if (mode == Mode.CUSTOM) { + // The content column, handed over whole. Nothing is styled or spaced for the + // caller here — the slots those defaults describe are the ones they declined. + return new TimelineEntrySpec(leading, marker, content, section -> { }); + } + String entryTitle = title; + String entryMeta = meta; + String entryBody = body; + DocumentTextStyle resolvedTitle = titleStyle != null ? titleStyle : defaultTitleStyle; + DocumentTextStyle resolvedMeta = metaStyle != null ? metaStyle : defaultMetaStyle; + DocumentTextStyle resolvedBody = bodyStyle != null ? bodyStyle : defaultBodyStyle; + Consumer entryExtra = extra; - DocumentTextStyle bodyStyle() { - return bodyStyle; + Consumer beside = column -> { + column.spacing(2); + if (notBlank(entryTitle)) { + column.addParagraph(p -> p + .text(entryTitle) + .textStyle(resolvedTitle) + .margin(DocumentInsets.zero())); + } + if (notBlank(entryMeta)) { + column.addParagraph(p -> p + .text(entryMeta) + .textStyle(resolvedMeta) + .margin(DocumentInsets.zero())); + } + }; + Consumer below = section -> { + if (notBlank(entryBody)) { + section.addParagraph(p -> p + .text(entryBody) + .textStyle(resolvedBody) + .lineSpacing(1.3) + .margin(DocumentInsets.zero())); + } + if (entryExtra != null) { + entryExtra.accept(section); + } + }; + return new TimelineEntrySpec(leading, marker, beside, below); } - Consumer extra() { - return extra; + private static boolean notBlank(String value) { + return value != null && !value.isBlank(); } } diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineEntrySpec.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineEntrySpec.java new file mode 100644 index 000000000..534af9e75 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineEntrySpec.java @@ -0,0 +1,29 @@ +package com.demcha.compose.document.dsl; + +import java.util.function.Consumer; + +/** + * One entry, with no memory of which API described it. + * + *

Content arrives already resolved: whichever builder produced this entry has already + * chosen the text styles, so nothing downstream reads a title, a meta line or a body. What + * remains is the geometric distinction the layout actually depends on — whether a block + * sits beside the marker, indented past the marker column, or below it at + * the entry's own left edge.

+ * + * @param leading content rendered in the column before the marker, or null when this entry + * has none — the column is still laid out for it, so an entry without + * leading content stays aligned with the entries that have it + * @param marker the marker drawn in the rail for this entry + * @param beside content rendered into the header row's column next to the marker; applied + * even when it draws nothing, because the column itself is part of the + * entry's geometry + * @param below content rendered under the header row, starting at the entry's left edge + * @author Artem Demchyshyn + * @since 2.4.0 + */ +record TimelineEntrySpec(Consumer leading, + TimelineMarker marker, + Consumer beside, + Consumer below) { +} diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarker.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarker.java index 17bcb9be3..b4ab75504 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarker.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarker.java @@ -1,9 +1,14 @@ package com.demcha.compose.document.dsl; +import com.demcha.compose.document.node.CanvasChild; +import com.demcha.compose.document.node.CanvasLayerNode; +import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.style.*; import com.demcha.compose.font.FontName; +import java.util.List; +import java.util.Objects; import java.util.function.Consumer; /** @@ -12,20 +17,29 @@ * factories and passed to * {@link TimelineBuilder#entry(TimelineMarker, java.util.function.Consumer)}. * - *

A marker carries its own {@link #size()} (used to lay out the rail column) - * and a recipe that draws it into that column, so new marker shapes are one - * factory method without touching the timeline layout.

+ *

A marker is a declared box and a recipe that draws into it, so a new marker + * shape is one factory method and the timeline layout never learns what shape it + * is. {@link #custom(double, double, Consumer)} holds that door open for callers: + * anything that can be drawn into a column can be a marker, and nothing in + * {@link TimelineBuilder} needs to know about it.

+ * + *

The box is declared, not measured. A marker drawn as three stacked + * shapes has one box, exactly as one drawn as a single ellipse does — whatever + * anchors on a marker must not be able to tell how the marker was built.

* * @author Artem Demchyshyn * @since 1.7.0 */ public final class TimelineMarker { - private final double size; + private final TimelineMarkerBounds bounds; + // Internal, and a Consumer: the timeline calls it and nothing else does, so its + // shape can grow later — a marker that wants to know it is the first or the last, + // say — without any of that reaching the public factories. private final Consumer recipe; - private TimelineMarker(double size, Consumer recipe) { - this.size = size; + private TimelineMarker(TimelineMarkerBounds bounds, Consumer recipe) { + this.bounds = bounds; this.recipe = recipe; } @@ -37,7 +51,7 @@ private TimelineMarker(double size, Consumer recipe) { * @return the marker */ public static TimelineMarker dot(double size, DocumentColor color) { - return new TimelineMarker(size, column -> column.addCircle(size, color)); + return new TimelineMarker(TimelineMarkerBounds.square(size), column -> column.addCircle(size, color)); } /** @@ -49,7 +63,7 @@ public static TimelineMarker dot(double size, DocumentColor color) { * @return the marker */ public static TimelineMarker circle(double size, DocumentColor fill, DocumentStroke stroke) { - return new TimelineMarker(size, column -> column.addCircle(size, ellipse -> { + return new TimelineMarker(TimelineMarkerBounds.square(size), column -> column.addCircle(size, ellipse -> { if (fill != null) { ellipse.fillColor(fill); } @@ -77,7 +91,7 @@ public static TimelineMarker numbered(int number, double size, .color(textColor == null ? DocumentColor.WHITE : textColor) .build(); String text = Integer.toString(number); - return new TimelineMarker(size, column -> column.addCircle(size, fill, disc -> disc + return new TimelineMarker(TimelineMarkerBounds.square(size), column -> column.addCircle(size, fill, disc -> disc .center(new ParagraphBuilder() .text(text) .textStyle(label) @@ -94,18 +108,61 @@ public static TimelineMarker numbered(int number, double size, * @return the marker */ public static TimelineMarker square(double size, DocumentColor fill) { - return new TimelineMarker(size, column -> column.addShape(shape -> shape + return new TimelineMarker(TimelineMarkerBounds.square(size), column -> column.addShape(shape -> shape .name("TimelineMarkerSquare") .size(size, size) .fillColor(fill) .margin(DocumentInsets.zero()))); } - double size() { - return size; + /** + * A marker of your own: any content, drawn into the marker column. + * + *

The box is yours to declare and the timeline takes it at its word — it never + * measures what you drew. That is what makes a marker of several shapes behave like a + * marker of one:

+ *
{@code
+     * TimelineMarker.custom(16, 16, column -> column.addLayerStack(stack -> stack
+     *         .back(ring).center(disc).center(pip)));
+     * }
+ * + * @param width the marker's declared width in points + * @param height the marker's declared height in points + * @param recipe draws the marker into its column + * @return the marker + * @throws NullPointerException if {@code recipe} is null + * @throws IllegalArgumentException if a dimension is not positive and finite + * @since 2.4.0 + */ + public static TimelineMarker custom(double width, double height, Consumer recipe) { + Objects.requireNonNull(recipe, "recipe"); + return new TimelineMarker(new TimelineMarkerBounds(width, height), recipe); } - void renderInto(SectionBuilder column) { - recipe.accept(column); + /** + * This marker as one node: the box it declared, with its recipe drawn inside. + * + *

The declared box is what the marker is, whatever the recipe measures to. + * The recipe is handed a canvas of exactly {@code width × height} and draws from its + * origin: a recipe smaller than the box leaves the rest of it empty, and one larger + * overflows visibly rather than growing the box. Either way the timeline reserves the + * declared box, the anchor reports the declared box, and the rail is derived from the + * declared box — so how a marker is drawn stays invisible to everything around it, + * which is the reason a marker declares a box instead of being measured.

+ * + *

It outranks the axis column too: a box wider than the column it is placed in keeps + * its width and overflows, rather than being squeezed into the column. The rail is + * derived from this box, so a clamped box would put the line somewhere neither the + * marker nor the axis asked for.

+ * + * @return the marker's node, sized to its declared box + */ + DocumentNode node() { + SectionBuilder drawn = new SectionBuilder(); + drawn.spacing(0); + recipe.accept(drawn); + return new CanvasLayerNode("marker", bounds.width(), bounds.height(), + List.of(new CanvasChild(drawn.build(), 0, 0)), + ClipPolicy.OVERFLOW_VISIBLE, DocumentInsets.zero(), DocumentInsets.zero()); } } diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerAnchor.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerAnchor.java new file mode 100644 index 000000000..39c0750d8 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerAnchor.java @@ -0,0 +1,115 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.document.layout.ResolvedLayoutAnchor; +import com.demcha.compose.document.node.HorizontalAlign; + +/** + * Which point of a marker the rail passes through. + * + *

A fraction of the marker's own box plus an offset in points, and that shape is what + * lets one equation serve both the timeline written years ago and the one written to sit + * on its rail. Measured before it was chosen: today's rail sits at the entry's left edge + * while a marker's centre is {@code margin + gutter + size/2}, so the distance between + * them grows with the marker — 12pt at size 8, 18pt at size 20. Expressed as + * {@code relativeX = 0.0, offsetX = -gutter} it is one constant at every size; expressed + * as a centre it would need a different number for each.

+ * + *

So there is no legacy branch anywhere in the layout. There are two anchors:

+ *
    + *
  • {@link #atLeftEdge(double)} — {@code (0.0, 0.5)} offset {@code (-gutter, 0)}, what + * a timeline that predates the choice already renders;
  • + *
  • {@link #onTheRail()} — {@code (0.5, 0.5)} offset {@code (0, 0)}, the marker + * centred on its own rail.
  • + *
+ * + * @param relativeX fraction of the marker's width, left to right + * @param relativeY fraction of the marker's height, bottom to top + * @param offsetX points added after the fraction, positive to the right + * @param offsetY points added after the fraction, positive upwards + * @author Artem Demchyshyn + * @since 2.4.0 + */ +record TimelineMarkerAnchor(double relativeX, double relativeY, double offsetX, double offsetY) { + + /** The marker's left edge, pulled back by the gutter — where the rail has always been. */ + static TimelineMarkerAnchor atLeftEdge(double gutter) { + return new TimelineMarkerAnchor(0.0, 0.5, -gutter, 0.0); + } + + /** The marker's centre, which is what "the marker sits on the rail" means. */ + static TimelineMarkerAnchor onTheRail() { + return new TimelineMarkerAnchor(0.5, 0.5, 0.0, 0.0); + } + + /** + * Where the marker has to sit in its axis column for its anchor to land on the axis. + * + *

Placement follows from the anchor rather than from a mode: an anchor on the + * marker's left edge wants the marker at the column's left edge, one on its centre + * wants it at the column's centre. That is what puts markers of 6, 14 and 24pt on one + * line — each centred in the same column, so each centre is the column's centre — and + * it holds for a weighted axis, whose width nobody knows until layout.

+ * + *

The mapping is exhaustive over the fractions that exist, and refuses the ones that + * do not. Placing a {@code relativeX} of, say, 0.25 would need the marker inset by a + * quarter of the leftover width, which is a fractional alignment the engine does not + * have; centring it instead would put its anchor somewhere other than the axis and + * report a resolved anchor that quietly disagrees with the rail. There is no public API + * that can produce such a fraction today, so this throws rather than invent a + * behaviour — the day fractional placement exists as a general capability, this is the + * one place that learns about it.

+ * + * @return the alignment for the marker inside its axis column + * @throws IllegalStateException if the anchor's {@code relativeX} has no placement + */ + HorizontalAlign horizontalAlign() { + if (relativeX == 0.0) { + return HorizontalAlign.LEFT; + } + if (relativeX == 0.5) { + return HorizontalAlign.CENTER; + } + throw new IllegalStateException( + "A timeline marker anchored at relativeX " + relativeX + " cannot be placed: the " + + "engine aligns a child left, centre or right, and nothing between. Placing it " + + "anywhere else would put its resolved anchor off the axis the rail is drawn on."); + } + + /** + * Whether the rail runs through the axis column rather than beside it. + * + *

The question decides where an entry's body goes. A rail beside the axis leaves the + * entry's whole width free, and a body spanning it clears the line by the gutter — the + * layout every timeline written before the choice already has. A rail through the axis + * would be crossed by that same body, so the body is laid out in the content column + * instead and the line is left with only markers to pass through.

+ * + *

The anchor is what knows, because the anchor is what moved the rail. A flag on the + * builder would be a second way to say the same thing, and two ways can disagree.

+ * + * @return true when the rail is inside the axis column + */ + boolean railRunsThroughTheAxis() { + return relativeX != 0.0; + } + + /** + * Where this anchor puts the rail, horizontally, for a resolved marker. + * + * @param marker the marker's resolved box + * @return the x the rail passes through + */ + double x(ResolvedLayoutAnchor marker) { + return marker.pointX(relativeX) + offsetX; + } + + /** + * Where this anchor puts the rail's end, vertically, for a resolved marker. + * + * @param marker the marker's resolved box + * @return the y the rail starts or stops at + */ + double y(ResolvedLayoutAnchor marker) { + return marker.pointY(relativeY) + offsetY; + } +} diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerBounds.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerBounds.java new file mode 100644 index 000000000..0d551f383 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerBounds.java @@ -0,0 +1,43 @@ +package com.demcha.compose.document.dsl; + +/** + * The box a marker declares for itself, whatever it draws inside it. + * + *

One box per marker, and it is a declaration rather than a measurement. A marker drawn + * as three stacked shapes has one box; a marker drawn as one ellipse has one box; the rail + * that later anchors on it cannot tell the two apart, which is the point. Reading the box + * back off whatever the marker happened to draw would make the rail's position depend on + * the marker's construction.

+ * + *

Width and height separately, not one {@code size}: the four built-in markers are all + * square, but a custom one — a chevron, a date pill, an icon — need not be.

+ * + * @param width declared width in points + * @param height declared height in points + * @author Artem Demchyshyn + * @since 2.4.0 + */ +record TimelineMarkerBounds(double width, double height) { + + /** + * Validates the box. + * + * @throws IllegalArgumentException if either dimension is not positive and finite + */ + TimelineMarkerBounds { + require(width, "width"); + require(height, "height"); + } + + /** A square box, which is what every built-in marker declares. */ + static TimelineMarkerBounds square(double size) { + return new TimelineMarkerBounds(size, size); + } + + private static void require(double value, String name) { + if (!(value > 0) || Double.isInfinite(value)) { + throw new IllegalArgumentException( + "A timeline marker's " + name + " must be a positive finite number of points, got: " + value); + } + } +} diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java new file mode 100644 index 000000000..2dfbeaf9a --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java @@ -0,0 +1,69 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.document.style.DocumentStroke; + +import java.util.Objects; + +/** + * Configures a timeline's connector rail. + * + *

Reached through {@link TimelineBuilder#rail(java.util.function.Consumer)}:

+ *
{@code
+ * timeline.rail(rail -> rail.stroke(DocumentStroke.of(accent, 1.5)));
+ * }
+ * + *

{@link TimelineBuilder#connector(com.demcha.compose.document.style.DocumentColor, double)} + * is the shorthand for exactly this and produces the same rail — there is one rail + * configuration, not an old one and a new one. Setting the rail both ways throws rather + * than letting one of them win.

+ * + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public final class TimelineRailBuilder { + + private DocumentStroke stroke; + private TimelineRailExtent extent; + + TimelineRailBuilder() { + } + + /** + * Sets the rail's colour and width. + * + * @param stroke the rail stroke + * @return this builder + * @throws NullPointerException if {@code stroke} is null + */ + public TimelineRailBuilder stroke(DocumentStroke stroke) { + this.stroke = Objects.requireNonNull(stroke, "stroke"); + return this; + } + + /** + * Sets how far the rail runs. + * + *

Independent of where it runs: an extent is the line's two ends, and its x comes + * from the marker anchor. A timeline that sets neither keeps + * {@link TimelineRailExtent#ENTRY_BOUNDS}, which is what it already draws.

+ * + * @param extent the rail's extent + * @return this builder + * @throws NullPointerException if {@code extent} is null + * @since 2.4.0 + */ + public TimelineRailBuilder extent(TimelineRailExtent extent) { + this.extent = Objects.requireNonNull(extent, "extent"); + return this; + } + + /** The stroke this rail was given, or null when the caller set none. */ + DocumentStroke stroke() { + return stroke; + } + + /** The extent this rail was given, or null when the caller set none. */ + TimelineRailExtent extent() { + return extent; + } +} diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java new file mode 100644 index 000000000..da2f6a5c5 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java @@ -0,0 +1,49 @@ +package com.demcha.compose.document.dsl; + +/** + * How far a timeline's rail runs. + * + *

The two ends of the line, and nothing about where it sits horizontally — that comes + * from the marker anchor. The two are independent on purpose: a rail can start and stop at + * the markers while passing through their left edges, or span the entries while passing + * through their centres.

+ * + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public enum TimelineRailExtent { + + /** + * From the first marker's anchor point to the last's. + * + *

No rail above the first marker or below the last. With a single entry the extent + * is zero and no rail is drawn at all — a zero-length line is not a shorter line.

+ */ + MARKER_TO_MARKER, + + /** + * The union of the entries' resolved boxes, page by page. + * + *

What a timeline written before there was a choice already draws, to the point: + * measured against the per-entry border it replaces, the two agree to 0.000000 in both + * ends on every page. The gaps between entries are inside it, because an entry's + * spacing is padding within its own box; there is no tail after the last entry, + * because the last entry has no such padding.

+ * + *

Page by page is load-bearing. An entry that spans pages has a different + * extent on each of them, and its box as a whole is a coordinate belonging to no + * page.

+ */ + ENTRY_BOUNDS, + + /** + * The timeline's own box, rather than the union of the entries in it. + * + *

Defined but not implemented: it is indistinguishable from + * {@link #ENTRY_BOUNDS} on a single page — a timeline has no padding of its own — and + * across pages there is nothing to measure it against, because the container draws + * nothing. Asking for it throws rather than quietly resolving to the neighbour it + * happens to equal in the easy case.

+ */ + TIMELINE_BOUNDS +} diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java new file mode 100644 index 000000000..5a2af7fc4 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java @@ -0,0 +1,146 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.document.layout.LayoutDepth; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.NodeDefinitionSupport; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.ResolvedLayoutAddition; +import com.demcha.compose.document.layout.ResolvedLayoutAnchor; +import com.demcha.compose.document.layout.ResolvedLayoutMetadata; +import com.demcha.compose.document.layout.ResolvedLayoutPass; +import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload; +import com.demcha.compose.document.layout.payloads.SideBorders; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * One timeline's identity, its rail's configuration, and the pass that draws that rail. + * + *

Every marker and every entry in a timeline anchors on the same instance of this, so + * the pass asks for its anchors and gets that timeline's and nobody else's. Two + * timelines on one page are two owners and never merge.

+ * + *

Being the pass as well as the identity is what removes the registration step. A + * feature declares an owner on the semantic tree; if that owner is also a + * {@link ResolvedLayoutPass}, the document has said it has something to draw once the + * layout is settled. Nothing calls a register method, no session is passed into the DSL, + * and the driver that runs it knows nothing about timelines — it asks whether an owner is + * a pass, not what kind of thing it is.

+ * + *

Deliberately a plain final class and not a record. The seam compares these with + * {@code ==}, and a record invites the reader to think in value equality: two timelines + * configured identically are still two timelines.

+ * + * @author Artem Demchyshyn + * @since 2.4.0 + */ +final class TimelineRailOwner implements ResolvedLayoutPass { + + private static final double EPS = 1e-9; + + private final TimelineRailSpec rail; + private final TimelineRailExtent extent; + private final TimelineMarkerAnchor markerAnchor; + + TimelineRailOwner(TimelineRailSpec rail, TimelineRailExtent extent, TimelineMarkerAnchor markerAnchor) { + this.rail = rail; + this.extent = extent; + this.markerAnchor = markerAnchor; + } + + /** + * The rail this timeline asked for. + * + * @return the rail spec + */ + TimelineRailSpec rail() { + return rail; + } + + @Override + public String id() { + return "timeline-rail"; + } + + @Override + public List contribute(LayoutGraph graph, ResolvedLayoutMetadata metadata) { + List markers = metadata.anchors(this, TimelineAnchorKind.MARKER); + if (markers.isEmpty()) { + // Not this timeline's document. No inspection, no feature flag: the anchors + // are simply not there. + return List.of(); + } + + // X from the marker, y from the extent — two independent questions. Every marker + // in a timeline resolves to the same x, so the first one answers for all of them; + // a marker of a different size moves its own centre but not its left edge, which + // is why the legacy anchor is expressed as an edge plus a constant. + double railX = markerAnchor.x(markers.get(0)); + List segments = segments(metadata, markers); + + List additions = new ArrayList<>(segments.size()); + SideBorders leftOnly = new SideBorders(null, null, null, + NodeDefinitionSupport.toStroke(rail.stroke())); + for (Segment segment : segments) { + double height = segment.top - segment.bottom; + if (height <= EPS) { + // A rail of no length is not a shorter rail. One entry with + // MARKER_TO_MARKER lands here, and nothing reaches a backend. + continue; + } + additions.add(new ResolvedLayoutAddition(LayoutDepth.UNDER_BODY, + PlacedFragment.withZeroInsets("@timeline-rail", additions.size(), + segment.page, railX, segment.bottom, + rail.stroke().width(), height, + new ShapeFragmentPayload(null, null, null, null, null, leftOnly, null)))); + } + return additions; + } + + /** + * The rail's vertical extent, one segment per page it appears on. + * + *

Both extents are built from the entries' resolved slices, because those already + * carry the one thing neither the markers nor the node boxes do: what a page's content + * band is, on that page, after per-page margins. {@code MARKER_TO_MARKER} then trims + * the first and last of them back to the markers rather than deriving a band of its + * own.

+ */ + private List segments(ResolvedLayoutMetadata metadata, List markers) { + Map byPage = new LinkedHashMap<>(); + for (ResolvedLayoutAnchor entry : metadata.anchors(this, TimelineAnchorKind.ENTRY)) { + byPage.merge(entry.pageIndex(), + new Segment(entry.pageIndex(), entry.pointY(1.0), entry.y()), + Segment::union); + } + if (extent == TimelineRailExtent.ENTRY_BOUNDS) { + return List.copyOf(byPage.values()); + } + + ResolvedLayoutAnchor first = markers.get(0); + ResolvedLayoutAnchor last = markers.get(markers.size() - 1); + double startY = markerAnchor.y(first); + double endY = markerAnchor.y(last); + List trimmed = new ArrayList<>(); + for (Segment segment : byPage.values()) { + if (segment.page < first.pageIndex() || segment.page > last.pageIndex()) { + continue; + } + double top = segment.page == first.pageIndex() ? Math.min(segment.top, startY) : segment.top; + double bottom = segment.page == last.pageIndex() ? Math.max(segment.bottom, endY) : segment.bottom; + trimmed.add(new Segment(segment.page, top, bottom)); + } + return trimmed; + } + + /** One page's worth of rail, in page coordinates. */ + private record Segment(int page, double top, double bottom) { + + Segment union(Segment other) { + return new Segment(page, Math.max(top, other.top), Math.min(bottom, other.bottom)); + } + } +} diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java new file mode 100644 index 000000000..6677091ad --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java @@ -0,0 +1,21 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.document.style.DocumentStroke; + +/** + * The rail, described rather than drawn. + * + *

One stroke, whichever way the caller asked for it: {@code connector(colour, width)} + * and {@code rail(r -> r.stroke(...))} both normalize here, so there is one rail + * configuration for the layout to read rather than an old shape and a new one.

+ * + *

A stroke is all it takes to describe, because the rail's geometry is not here: how far + * it runs is a {@link TimelineRailExtent} and where it runs comes from the marker anchor, + * both resolved after layout. This is only what it is drawn with.

+ * + * @param stroke the rail's colour and width + * @author Artem Demchyshyn + * @since 2.4.0 + */ +record TimelineRailSpec(DocumentStroke stroke) { +} diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java new file mode 100644 index 000000000..eefe9b6c9 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java @@ -0,0 +1,43 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.document.style.DocumentRowColumn; + +import java.util.List; + +/** + * A whole timeline, normalized: everything the layout needs and nothing about how it was + * authored. + * + *

{@link TimelineBuilder} resolves its defaults, its per-entry style overrides and its + * content callbacks into one of these, and the layout reads only this. That separation is + * the point — a second authoring API can produce the same spec, and the layout will not be + * able to tell which one it came from.

+ * + * @param owner this timeline's identity, which every one of its markers + * anchors on and no other timeline's markers do + * @param rail the connector rail + * @param leadingColumn how wide the column before the marker is, or null when the + * timeline has no leading column at all + * @param gutter space between the rail and the entry's content + * @param markerGap horizontal gap between the marker column and the content + * beside it + * @param axis how wide the column the markers sit in is + * @param entrySpacing vertical space between entries; the rail spans it + * @param keepTogether whether the timeline relocates whole rather than splitting + * @param keepEntriesTogether whether each entry relocates whole rather than splitting + * @param entries the entries, in document order + * @author Artem Demchyshyn + * @since 2.4.0 + */ +record TimelineSpec(TimelineRailOwner owner, + TimelineRailSpec rail, + DocumentRowColumn leadingColumn, + double gutter, + double markerGap, + TimelineAxisSize axis, + TimelineMarkerAnchor markerAnchor, + double entrySpacing, + boolean keepTogether, + boolean keepEntriesTogether, + List entries) { +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java b/core/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java index 23939cecc..9440682af 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java +++ b/core/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java @@ -48,6 +48,8 @@ public static NodeRegistry registerDefaults(NodeRegistry registry) { .register(new PathDefinition()) .register(new AlignDefinition()) .register(new LayoutAnchorDefinition()) + .register(new HorizontalBandsDefinition()) + .register(new HorizontalBandContentDefinition()) .register(new ChartDefinition()); } } diff --git a/core/src/main/java/com/demcha/compose/document/layout/CompilerState.java b/core/src/main/java/com/demcha/compose/document/layout/CompilerState.java index 59bd2bb40..7539e1b82 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/CompilerState.java +++ b/core/src/main/java/com/demcha/compose/document/layout/CompilerState.java @@ -2,6 +2,9 @@ import com.demcha.compose.engine.components.style.Margin; +import java.util.IdentityHashMap; +import java.util.List; + import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS; /** @@ -26,6 +29,18 @@ final class CompilerState { double usedHeight; int maxTouchedPage = -1; + /** + * Column bands published by rows during this compilation, by owner identity. + * + *

Local to one compile — a fresh state is built for every pass — so nothing survives + * into another document, and identity is the key because two features that describe + * their columns alike are still two features.

+ */ + private final IdentityHashMap> bands = new IdentityHashMap<>(); + + /** The key the next row to be compiled publishes under, set by the wrapper around it. */ + private Object expectedBandKey; + CompilerState(LayoutCanvas canvas) { this(canvas, null); } @@ -35,6 +50,73 @@ final class CompilerState { this.geometry = geometry; } + /** + * Announces that the row about to be compiled publishes its columns under this key. + * + * @param key the owner identity, from the wrapper + */ + void expectBands(Object key) { + this.expectedBandKey = key; + } + + /** + * The key set by a wrapper, cleared as it is read. + * + * @return the key, or null when this row is not published + */ + Object takeExpectedBandKey() { + Object key = expectedBandKey; + expectedBandKey = null; + return key; + } + + /** + * Records the columns a row resolved. + * + * @param key the owner identity + * @param slots the resolved columns, in order + * @throws IllegalStateException if this key already published + */ + void publishBands(Object key, List slots) { + if (bands.containsKey(key)) { + throw new IllegalStateException( + "Two rows publish their columns under one identity. A band identity names one row; " + + "give the second row an identity of its own."); + } + bands.put(key, List.copyOf(slots)); + } + + /** + * The band a consumer asks for, or a refusal saying which part of the arrangement is wrong. + * + * @param key the owner identity the consumer names + * @param slot the column index + * @param path the consumer's layout path, for the message + * @return the resolved band + * @throws IllegalStateException if nothing published, the slot does not exist, or it is empty + */ + ResolvedHorizontalBand band(Object key, int slot, String path) { + List published = bands.get(key); + if (published == null) { + throw new IllegalStateException("Node '" + path + "' lays out inside a column that was never " + + "published. The row it names has to be laid out before it — an " + + "earlier sibling, not a later one and not a parent."); + } + if (slot >= published.size()) { + throw new IllegalStateException("Node '" + path + "' asks for column " + slot + + " of a row that resolved " + published.size() + + ". Columns are counted from zero."); + } + ResolvedHorizontalBand band = published.get(slot); + if (band.width() <= EPS) { + throw new IllegalStateException("Node '" + path + "' lays out inside column " + slot + + ", which resolved to " + band.width() + + "pt. There is nothing to lay out in — widen the column or " + + "reduce what the row has to fit."); + } + return band; + } + /** Whether per-page geometry is active (a document with per-page margins). */ boolean hasPageGeometry() { return geometry != null; diff --git a/core/src/main/java/com/demcha/compose/document/layout/HorizontalBandContentNode.java b/core/src/main/java/com/demcha/compose/document/layout/HorizontalBandContentNode.java new file mode 100644 index 000000000..bdddfb204 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/HorizontalBandContentNode.java @@ -0,0 +1,59 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.document.node.DocumentNode; + +import java.util.List; +import java.util.Objects; + +/** + * Lays its child out inside a column another node already resolved. + * + *

The child is measured and paginated at the band's width, at the band's x, exactly as it + * would be in the column itself — but it stays where it is in the vertical flow, so it + * splits across pages the way any other block does. That is the whole point: a row cannot + * cross a page, so content that has to line up with a column and also has to be long cannot + * live in the row.

+ * + *

The band has to have been published before this node is reached, by a + * {@link HorizontalBandsNode} carrying the same key by identity — in practice an earlier + * sibling. Everything else fails closed: an unknown key, a slot the row does not have, or a + * slot resolved to nothing throws rather than falling back to the parent's width, because a + * silent fallback is a layout that looks deliberate and is not.

+ * + *

Transparent otherwise: it measures to its child and adds no spacing of its own.

+ * + * @param name semantic name, may be empty + * @param key the identity the band was published under + * @param slot which of that row's columns, counting from zero + * @param child the content to lay out inside the band + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public record HorizontalBandContentNode(String name, Object key, int slot, DocumentNode child) + implements DocumentNode { + + /** + * Normalizes the name and validates the rest. + * + * @throws NullPointerException if {@code key} or {@code child} is null + * @throws IllegalArgumentException if {@code slot} is negative + */ + public HorizontalBandContentNode { + name = name == null ? "" : name; + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(child, "child"); + if (slot < 0) { + throw new IllegalArgumentException("A band slot is counted from zero: " + slot); + } + } + + /** + * The single child laid out in the band. + * + * @return one child + */ + @Override + public List children() { + return List.of(child); + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/HorizontalBandsNode.java b/core/src/main/java/com/demcha/compose/document/layout/HorizontalBandsNode.java new file mode 100644 index 000000000..c23d70421 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/HorizontalBandsNode.java @@ -0,0 +1,65 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.RowNode; + +import java.util.List; +import java.util.Objects; + +/** + * Wraps a row so the columns it resolves can be read by content laid out after it. + * + *

The wrapper is transparent: it measures to its child and adds no spacing, so putting + * one around a row changes no geometry. What it adds is a publication — when the row is + * compiled, each of its slots is recorded under {@link #key} as a + * {@link ResolvedHorizontalBand}, and a {@link HorizontalBandContentNode} naming the same + * key can lay itself out inside one of them.

+ * + *

The key is compared by identity, never by equality. Two features that happen to + * describe their columns alike are still two features, and a band belongs to whichever + * object published it; there is no name, path or index that a second feature could collide + * with by accident.

+ * + *

The child has to be a row, because a row is what has columns. Wrapping anything else + * would publish nothing and leave the consumer to fail later with a puzzle instead of a + * mistake, so it is rejected here.

+ * + *

Lives in this {@code @Internal} package on purpose: this is engine plumbing that a + * built-in feature uses to reach geometry the engine already resolved, and one built-in use + * case is not enough to stabilise an authoring API.

+ * + * @param name semantic name, may be empty + * @param key the identity the bands are published under + * @param child the row whose columns are published + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public record HorizontalBandsNode(String name, Object key, DocumentNode child) implements DocumentNode { + + /** + * Normalizes the name and validates the rest. + * + * @throws NullPointerException if {@code key} or {@code child} is null + * @throws IllegalArgumentException if {@code child} is not a row + */ + public HorizontalBandsNode { + name = name == null ? "" : name; + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(child, "child"); + if (!(child instanceof RowNode)) { + throw new IllegalArgumentException( + "A horizontal-bands wrapper publishes a row's columns, and " + child.nodeKind() + + " has none. Wrap the row itself."); + } + } + + /** + * The single wrapped row. + * + * @return one child + */ + @Override + public List children() { + return List.of(child); + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index da4528ef4..a518eea7e 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -150,6 +150,13 @@ private void compileNode(PreparedNode prepared, return; } + if (node instanceof HorizontalBandsNode bands) { + // The row inside is what gets compiled next, and nothing else is compiled in + // between, so this is where it learns whose columns it is resolving. The key is + // taken back as the row reads it, so an unwrapped row publishes nothing. + state.expectBands(bands.key()); + } + if (availableWidth <= EPS) { // Name the node's own fixed width when it has one: a sub-point request // survives DocumentFlowWidth.of and dies here, and blaming the parent's @@ -322,6 +329,17 @@ private void compileComposite(PreparedNode prepared, thisChildRegionWidth = Math.max(0.0, pageAvailableWidth - padding.horizontal()); thisChildRegionX = state.marginLeftForPage(childStartPage) + margin.left() + padding.left(); } + if (child instanceof HorizontalBandContentNode consumer) { + // A column an earlier row already resolved, standing in for the region this + // child would otherwise get. It has to happen here, before the measurement + // on the next line: the width decides the wrapping, the wrapping decides the + // height, and the height decides the pagination — which is why this cannot + // be a post-layout pass. + ResolvedHorizontalBand band = + state.band(consumer.key(), consumer.slot(), pathFor(child, path, index)); + thisChildRegionX = band.x(); + thisChildRegionWidth = band.width(); + } PreparedNode childPrepared = prepareForRegionWidth(prepareContext, child, thisChildRegionWidth); @@ -480,11 +498,23 @@ private void compileHorizontalRow(PreparedNode prepared, double bandContentHeight = naturalMeasure.height() - padding.vertical(); double cursorX = placementX + padding.left() + flexLeading; + // Whose columns these are, if anyone asked. Read once, before the loop that + // resolves them, because the loop is also where the slots stop existing. + Object bandKey = state.takeExpectedBandKey(); + List publishedBands = + bandKey == null ? null : new ArrayList<>(children.size()); + for (int index = 0; index < children.size(); index++) { DocumentNode child = children.get(index); Margin childMargin = toMargin(child.margin()); double slotWidth = slotWidths[index]; double childRegionX = cursorX + childMargin.left(); + if (publishedBands != null) { + // The slot, not the child's box: a band means the same thing whatever + // was put in the column, so content laid out in it later is seated + // exactly as a child of that column would be. + publishedBands.add(new ResolvedHorizontalBand(cursorX, slotWidth)); + } // The whole slot goes in — prepareForRegionWidth removes the child's // margin itself. Pre-subtracting it here took it off twice, so the @@ -582,6 +612,10 @@ private void compileHorizontalRow(PreparedNode prepared, cursorX += slotWidth + layoutSpec.spacing() + (index < children.size() - 1 ? flexExtraGap : 0.0); } + + if (publishedBands != null) { + state.publishBands(bandKey, publishedBands); + } } int endPage = state.pageIndex; @@ -904,6 +938,17 @@ private double compileNodeInFixedSlot(PreparedNode prepared, Margin margin = toMargin(node.margin()); Padding padding = toPadding(node.padding()); double availableWidth = childAvailableWidth(slotWidth, node); + + if (node instanceof HorizontalBandContentNode) { + // Only the vertical flow narrows a child to a published band, and a fixed slot + // is not the vertical flow. Refusing here rather than laying the content out in + // the slot it happens to be in: content silently seated somewhere other than the + // column it named is the failure this whole mechanism exists to prevent. + throw new IllegalStateException("Node '" + path + "' lays out inside a published column, " + + "which only works in a vertical flow. It is in a fixed slot " + + "here — a row column or a stack layer — where the surrounding " + + "rectangle is already decided."); + } MeasureResult measure = prepared.measureResult(); double placementX = slotX + margin.left(); double placementTopY = slotTopY - margin.top(); diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutDepth.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutDepth.java index e34affa57..b175beef2 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/LayoutDepth.java +++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutDepth.java @@ -4,9 +4,9 @@ * Where a pass's fragment sits relative to the document body. * *

This engine has no z-index: fragments draw in list order, so depth is expressed by - * splice position rather than by a number. The driver builds one list — - * under-body additions, then the compiled body, then over-body additions — and the - * backends walk it front to back.

+ * splice position rather than by a number. The driver builds one list and the backends walk + * it front to back — an under-body addition goes in immediately before the first fragment + * the contributing feature drew on that page, an over-body addition after the whole body.

* * @author Artem Demchyshyn * @since 2.4.0 @@ -14,8 +14,13 @@ public enum LayoutDepth { /** - * Behind the body. A rail belongs here: a filled marker should cover the line running - * under it rather than be crossed by it. + * Behind the feature's own body. A rail belongs here: a filled marker should cover the + * line running under it rather than be crossed by it. + * + *

Behind its body, not behind the document's. A fragment put at the front of + * the list would also sit beneath the fill of whatever the feature is inside — a card, a + * panel, a tinted section — and disappear under it while remaining present in the + * geometry, which is a defect no coordinate can show.

*/ UNDER_BODY, diff --git a/core/src/main/java/com/demcha/compose/document/layout/ResolvedHorizontalBand.java b/core/src/main/java/com/demcha/compose/document/layout/ResolvedHorizontalBand.java new file mode 100644 index 000000000..feb5eef8a --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/ResolvedHorizontalBand.java @@ -0,0 +1,23 @@ +package com.demcha.compose.document.layout; + +/** + * One column of a horizontal composite, as the layout resolved it. + * + *

A row works out where each of its columns starts and how wide it is — from fixed + * points, from shares of what is left, or from a mixture — and then lays its children into + * those slots and forgets the arithmetic. A band is that arithmetic, kept: the slot itself, + * before the child in it applies its own margin, so it means the same thing whatever was put + * there.

+ * + *

It exists so that content after a row can line up with one of its columns + * without recomputing the column. Recomputing is the failure this avoids: a second copy of + * the width formula agrees with the first until a share, a gap or a fixed column changes, + * and then disagrees silently.

+ * + * @param x the slot's left edge, in the same coordinates the row was placed in + * @param width the slot's width + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public record ResolvedHorizontalBand(double x, double width) { +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/definitions/HorizontalBandContentDefinition.java b/core/src/main/java/com/demcha/compose/document/layout/definitions/HorizontalBandContentDefinition.java new file mode 100644 index 000000000..a3388abc5 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/definitions/HorizontalBandContentDefinition.java @@ -0,0 +1,77 @@ +package com.demcha.compose.document.layout.definitions; + +import com.demcha.compose.document.layout.BoxConstraints; +import com.demcha.compose.document.layout.CompositeLayoutSpec; +import com.demcha.compose.document.layout.FragmentContext; +import com.demcha.compose.document.layout.FragmentPlacement; +import com.demcha.compose.document.layout.HorizontalBandContentNode; +import com.demcha.compose.document.layout.LayoutFragment; +import com.demcha.compose.document.layout.MeasureResult; +import com.demcha.compose.document.layout.NodeDefinition; +import com.demcha.compose.document.layout.PaginationPolicy; +import com.demcha.compose.document.layout.PrepareContext; +import com.demcha.compose.document.layout.PreparedNode; +import com.demcha.compose.document.node.DocumentNode; + +import java.util.List; + +/** + * Layout definition for {@link HorizontalBandContentNode}: lays the child out unchanged, in + * whatever width it is handed. + * + *

The band is not read here. By the time this runs the compiler has already narrowed the + * region to the published column, so measuring against the width it was given is measuring + * against the band — which is what makes wrapping, height and pagination all follow from one + * number rather than from a second copy of it.

+ * + *

Vertical, and therefore splittable: that is the entire reason this node exists rather + * than putting the content in the row.

+ * + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public final class HorizontalBandContentDefinition implements NodeDefinition { + + /** + * Creates the band-content layout definition. + */ + public HorizontalBandContentDefinition() { + } + + @Override + public Class nodeType() { + return HorizontalBandContentNode.class; + } + + @Override + public PreparedNode prepare(HorizontalBandContentNode node, PrepareContext ctx, + BoxConstraints constraints) { + DocumentNode child = node.child(); + double childInner = Math.max(0.0, constraints.availableWidth() - child.margin().horizontal()); + PreparedNode childPrepared = ctx.prepare(child, BoxConstraints.natural(childInner)); + double height = childPrepared.measureResult().height() + child.margin().vertical(); + // Fills the band rather than shrinking to the child. The band is the claim — content + // laid out in a column occupies that column — and a wrapper that reported its child's + // width instead would make the geometry unreadable from the outside: two different + // bands holding the same short line would look identical. + return PreparedNode.composite(node, new MeasureResult(constraints.availableWidth(), height), + new CompositeLayoutSpec(0.0, CompositeLayoutSpec.Axis.VERTICAL)); + } + + @Override + public PaginationPolicy paginationPolicy(HorizontalBandContentNode node) { + return PaginationPolicy.ATOMIC; + } + + @Override + public List children(HorizontalBandContentNode node) { + return node.children(); + } + + @Override + public List emitFragments(PreparedNode prepared, + FragmentContext ctx, + FragmentPlacement placement) { + return List.of(); + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/definitions/HorizontalBandsDefinition.java b/core/src/main/java/com/demcha/compose/document/layout/definitions/HorizontalBandsDefinition.java new file mode 100644 index 000000000..f401244ee --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/definitions/HorizontalBandsDefinition.java @@ -0,0 +1,71 @@ +package com.demcha.compose.document.layout.definitions; + +import com.demcha.compose.document.layout.BoxConstraints; +import com.demcha.compose.document.layout.CompositeLayoutSpec; +import com.demcha.compose.document.layout.FragmentContext; +import com.demcha.compose.document.layout.FragmentPlacement; +import com.demcha.compose.document.layout.HorizontalBandsNode; +import com.demcha.compose.document.layout.LayoutFragment; +import com.demcha.compose.document.layout.MeasureResult; +import com.demcha.compose.document.layout.NodeDefinition; +import com.demcha.compose.document.layout.PaginationPolicy; +import com.demcha.compose.document.layout.PrepareContext; +import com.demcha.compose.document.layout.PreparedNode; +import com.demcha.compose.document.node.DocumentNode; + +import java.util.List; + +/** + * Layout definition for {@link HorizontalBandsNode}: lays the row out unchanged. + * + *

The wrapper contributes nothing of its own — no size, no spacing, no fragment. It + * measures to the row and passes the available width straight through, so a row inside one + * is laid out exactly as the same row without one. The publication happens in the compiler, + * where the row's slots are resolved; there is nowhere else it could happen, because the + * arithmetic that produces them lives there and nowhere else.

+ * + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public final class HorizontalBandsDefinition implements NodeDefinition { + + /** + * Creates the horizontal-bands layout definition. + */ + public HorizontalBandsDefinition() { + } + + @Override + public Class nodeType() { + return HorizontalBandsNode.class; + } + + @Override + public PreparedNode prepare(HorizontalBandsNode node, PrepareContext ctx, + BoxConstraints constraints) { + DocumentNode child = node.child(); + double childInner = Math.max(0.0, constraints.availableWidth() - child.margin().horizontal()); + PreparedNode childPrepared = ctx.prepare(child, BoxConstraints.natural(childInner)); + double width = childPrepared.measureResult().width() + child.margin().horizontal(); + double height = childPrepared.measureResult().height() + child.margin().vertical(); + return PreparedNode.composite(node, new MeasureResult(width, height), + new CompositeLayoutSpec(0.0, CompositeLayoutSpec.Axis.VERTICAL)); + } + + @Override + public PaginationPolicy paginationPolicy(HorizontalBandsNode node) { + return PaginationPolicy.ATOMIC; + } + + @Override + public List children(HorizontalBandsNode node) { + return node.children(); + } + + @Override + public List emitFragments(PreparedNode prepared, + FragmentContext ctx, + FragmentPlacement placement) { + return List.of(); + } +} diff --git a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java index ddfe50da2..3a2967a7f 100644 --- a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java +++ b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java @@ -1,52 +1,79 @@ package com.demcha.compose.document.dsl; +import com.demcha.compose.document.layout.LayoutAnchorId; +import com.demcha.compose.document.layout.LayoutAnchorNode; +import com.demcha.compose.document.node.AlignNode; +import com.demcha.compose.document.node.CanvasLayerNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.EllipseNode; +import com.demcha.compose.document.node.LayerStackNode; import com.demcha.compose.document.node.ParagraphNode; import com.demcha.compose.document.node.RowNode; import com.demcha.compose.document.node.SectionNode; +import com.demcha.compose.document.node.ShapeContainerNode; +import com.demcha.compose.document.node.ShapeNode; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentRowColumn; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.function.Consumer; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.assertj.core.api.Assertions.within; /** * Covers {@link AbstractFlowBuilder#addTimeline} / {@link TimelineBuilder}: each * entry becomes a section carrying the connector rail (a left border), a marker * row, and the entry's content. + * + *

Most of this file is a freeze. The rail is about to be reworked from a + * per-entry left border into one logical axis anchored to markers, and nothing + * pinned the current tree — three tests and a blank-page pixel smoke. These cases + * exist so the rework has something to move against: every public method + * is asserted at the node level, so a change that quietly drops one goes red here + * rather than in a reader's document.

*/ class TimelineBuilderTest { private static final DocumentColor NAVY = DocumentColor.rgb(20, 40, 70); + // The defaults TimelineBuilder ships with, restated so a silent change to one + // fails here by name instead of shifting every timeline already in the wild. + private static final DocumentColor DEFAULT_RAIL = DocumentColor.rgb(150, 158, 172); + private static final double DEFAULT_RAIL_WIDTH = 1.5; + private static final double DEFAULT_GUTTER = 8.0; + private static final double DEFAULT_MARKER_GAP = 8.0; + private static final double DEFAULT_MARKER_COLUMN_WEIGHT = 0.10; + private static final double DEFAULT_ENTRY_SPACING = 14.0; + @Test void addTimelineProducesOneSectionPerEntryWithRail() { - SectionNode root = new SectionBuilder().addTimeline(t -> t - .entry(TimelineMarker.dot(8, NAVY), e -> e.title("First").body("body one")) - .entry(TimelineMarker.numbered(2, 14, NAVY, DocumentColor.WHITE), - e -> e.title("Second").meta("2020 - 2021").body("body two"))) - .build(); + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("First").body("body one")) + .entry(TimelineMarker.numbered(2, 14, NAVY, DocumentColor.WHITE), + e -> e.title("Second").meta("2020 - 2021").body("body two"))); - assertThat(root.children()).hasSize(1); - SectionNode timeline = (SectionNode) root.children().get(0); assertThat(timeline.children()).hasSize(2); - - SectionNode entry = (SectionNode) timeline.children().get(0); + SectionNode entry = entry(timeline, 0); assertThat(entry.borders().hasAny()) - .as("each entry carries the connector rail as a left border") - .isTrue(); + .as("the rail is no longer a border on the entry; it is one line drawn after layout") + .isFalse(); assertThat(entry.children()).anySatisfy(child -> assertThat(child).isInstanceOf(RowNode.class)); assertThat(lastParagraph(entry).text()).isEqualTo("body one"); } @Test void entryWithoutContentStillRendersAMarkerRow() { - SectionNode root = new SectionBuilder() - .addTimeline(t -> t.entry(TimelineMarker.square(8, NAVY), null)) - .build(); - SectionNode timeline = (SectionNode) root.children().get(0); + SectionNode timeline = timelineOf(t -> t.entry(TimelineMarker.square(8, NAVY), null)); assertThat(timeline.children()).hasSize(1); - SectionNode entry = (SectionNode) timeline.children().get(0); - assertThat(entry.children()).anySatisfy(child -> assertThat(child).isInstanceOf(RowNode.class)); + assertThat(entry(timeline, 0).children()) + .anySatisfy(child -> assertThat(child).isInstanceOf(RowNode.class)); } @Test @@ -55,11 +82,804 @@ void entryRejectsNullMarker() { new SectionBuilder().addTimeline(t -> t.entry(null, e -> e.title("x")))); } - private static ParagraphNode lastParagraph(SectionNode entry) { + // --- the rail ------------------------------------------------------------ + + @Test + void noEntryCarriesTheRailAsABorderAnyMore() { + // The rail left the node tree. It is one line computed from resolved anchors after + // layout, not a border repeated per entry, so there is nothing here to assert about + // it — its colour, width, extent and position are pinned in + // TimelineRailGeometryTest, where they can be measured. + SectionNode timeline = timelineOf(t -> t + .connector(NAVY, 3.25) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(entry(timeline, 0).borders().hasAny()) + .as("an entry decorates nothing now") + .isFalse(); + } + + @Test + void everyEntryIsAnchoredSoTheRailCanBeComputedFromWhereItLands() { + // The wrapper every other test in this file reads through, and the reason it + // exists: an entry is the only part of a timeline that can cross a page boundary, + // so only its anchor carries what the rail needs on each page. + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("First")) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("Second"))); + + LayoutAnchorNode first = (LayoutAnchorNode) timeline.children().get(0); + LayoutAnchorNode second = (LayoutAnchorNode) timeline.children().get(1); + assertThat(first.id().index()).isZero(); + assertThat(second.id().index()).isEqualTo(1); + assertThat(first.id().kind()).isSameAs(second.id().kind()); + assertThat(first.id().kind().toString()).isEqualTo("ENTRY"); + assertThat(first.id().groupKey()) + .as("the same owner the markers anchor on, so one pass sees both") + .isSameAs(second.id().groupKey()); + } + + @Test + void railAndConnectorAreTheSameFeature() { + // Not two rails with two code paths: connector(...) normalizes into the same + // stroke rail(...) sets, so the trees are identical rather than merely similar. + SectionNode shorthand = timelineOf(t -> t + .connector(NAVY, 3.25) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + SectionNode longForm = timelineOf(t -> t + .rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 3.25))) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(railStroke(longForm).color().color()) + .isEqualTo(railStroke(shorthand).color().color()); + assertThat(railStroke(longForm).width()) + .isEqualTo(railStroke(shorthand).width(), within(1e-9)); + assertThat(outline(longForm)).isEqualTo(outline(shorthand)); + } + + @Test + void theRailIsConfiguredOnceInEitherOrder() { + assertThatIllegalStateException() + .as("shorthand then long form") + .isThrownBy(() -> timelineOf(t -> t + .connector(NAVY, 2) + .rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 3))))) + .withMessageContaining("one rail, configured once"); + assertThatIllegalStateException() + .as("long form then shorthand") + .isThrownBy(() -> timelineOf(t -> t + .rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 3))) + .connector(NAVY, 2))) + .withMessageContaining("one rail, configured once"); + } + + @Test + void aCallThatChangesNothingDoesNotCountAsConfiguringTheRail() { + // connector has always ignored a null colour and a non-positive width, and rail(...) + // with an empty lambda sets no stroke. Neither may block the other, or code that + // used to work would start throwing. + SectionNode fromEmptyRail = timelineOf(t -> t + .rail(rail -> { }) + .connector(NAVY, 2) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + SectionNode fromEmptyConnector = timelineOf(t -> t + .connector(null, 0) + .rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 2))) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(railStroke(fromEmptyRail).width()).isEqualTo(2.0, within(1e-9)); + assertThat(railStroke(fromEmptyConnector).width()).isEqualTo(2.0, within(1e-9)); + } + + @Test + void connectorStillTakesOneHalfOfTheStrokeAtATime() { + // Long-standing behaviour, and the reason two connector(...) calls stay legal: a + // null colour keeps the current one and a non-positive width keeps the current + // width, so setting the halves separately works. Normalizing into a single stroke + // is exactly where that could quietly become "both or nothing", and a guard against + // saying it two ways could just as quietly forbid saying it twice. + SectionNode widthOnly = timelineOf(t -> t + .connector(null, 4) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + assertThat(railStroke(widthOnly).width()).isEqualTo(4.0, within(1e-9)); + assertThat(railStroke(widthOnly).color().color()) + .as("the default colour survives a width-only call") + .isEqualTo(DEFAULT_RAIL.color()); + + SectionNode inTwoCalls = timelineOf(t -> t + .connector(NAVY, 0) + .connector(null, 4) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + assertThat(railStroke(inTwoCalls).color().color()) + .as("the colour from the first call") + .isEqualTo(NAVY.color()); + assertThat(railStroke(inTwoCalls).width()) + .as("and the width from the second") + .isEqualTo(4.0, within(1e-9)); + } + + // --- spacing and the columns --------------------------------------------- + + @Test + void gutterBecomesTheEntrysLeftPadding() { + SectionNode timeline = timelineOf(t -> t + .gutter(21) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(entry(timeline, 0).padding().left()).isEqualTo(21.0, within(1e-9)); + } + + @Test + void spacingIsBottomPaddingOnEveryEntryButTheLast() { + SectionNode timeline = timelineOf(t -> t + .spacing(20) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("First")) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("Second"))); + + assertThat(entry(timeline, 0).padding().bottom()) + .as("the gap is padding inside the bordered entry, so the rail crosses it") + .isEqualTo(20.0, within(1e-9)); + assertThat(entry(timeline, 1).padding().bottom()) + .as("no spacing after the last entry") + .isEqualTo(0.0, within(1e-9)); + } + + @Test + void markerGapBecomesTheHeaderRowGap() { + SectionNode timeline = timelineOf(t -> t + .markerGap(17) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(header(entry(timeline, 0)).gap()).isEqualTo(17.0, within(1e-9)); + } + + @Test + void markerColumnWeightBecomesTheHeaderRowWeights() { + SectionNode timeline = timelineOf(t -> t + .markerColumnWeight(0.4) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(header(entry(timeline, 0)).weights()) + .as("marker column then content column") + .containsExactly(0.4, 1.0); + } + + @Test + void theDefaultsAreTheOnesTimelinesInTheWildAlreadyRenderWith() { + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("First")) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("Second"))); + + SectionNode first = entry(timeline, 0); + assertThat(first.padding().left()).isEqualTo(DEFAULT_GUTTER, within(1e-9)); + assertThat(first.padding().bottom()).isEqualTo(DEFAULT_ENTRY_SPACING, within(1e-9)); + assertThat(header(first).gap()).isEqualTo(DEFAULT_MARKER_GAP, within(1e-9)); + assertThat(header(first).weights()).containsExactly(DEFAULT_MARKER_COLUMN_WEIGHT, 1.0); + } + + // --- pagination flags ---------------------------------------------------- + + @Test + void keepTogetherHoldsTheWholeTimelineNotTheEntries() { + SectionNode timeline = timelineOf(t -> t + .keepTogether() + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(timeline.keepTogether()).isTrue(); + assertThat(entry(timeline, 0).keepTogether()) + .as("keepTogether is about the timeline; the entries stay free") + .isFalse(); + } + + @Test + void keepEntriesTogetherHoldsEachEntryNotTheTimeline() { + SectionNode timeline = timelineOf(t -> t + .keepEntriesTogether() + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("First")) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("Second"))); + + assertThat(timeline.keepTogether()).isFalse(); + assertThat(entry(timeline, 0).keepTogether()).isTrue(); + assertThat(entry(timeline, 1).keepTogether()).isTrue(); + } + + // --- content and styles -------------------------------------------------- + + @Test + void titleAndMetaStackInTheHeaderWhileBodyHangsOffTheEntry() { + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e + .title("Senior Engineer").meta("2023 - now").body("What I did."))); + + SectionNode entry = entry(timeline, 0); + assertThat(paragraphTexts(header(entry).children().get(1))) + .as("title and meta stack in the content column of the header row") + .containsExactly("Senior Engineer", "2023 - now"); + assertThat(lastParagraph(entry).text()).isEqualTo("What I did."); + } + + @Test + void perTimelineStylesReachTheParagraphsThatUseThem() { + DocumentTextStyle title = DocumentTextStyle.builder().size(19).build(); + DocumentTextStyle meta = DocumentTextStyle.builder().size(7).build(); + DocumentTextStyle body = DocumentTextStyle.builder().size(11).build(); + + SectionNode timeline = timelineOf(t -> t + .titleStyle(title).metaStyle(meta).bodyStyle(body) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("T").meta("M").body("B"))); + + SectionNode entry = entry(timeline, 0); + List head = paragraphsOf(header(entry).children().get(1)); + assertThat(head.get(0).textStyle().size()).isEqualTo(19.0, within(1e-9)); + assertThat(head.get(1).textStyle().size()).isEqualTo(7.0, within(1e-9)); + assertThat(lastParagraph(entry).textStyle().size()).isEqualTo(11.0, within(1e-9)); + } + + @Test + void aPerEntryStyleBeatsTheTimelineDefaultForThatSlotAlone() { + // Precedence, and its scope: the entry that overrides gets its own style, the slots + // it did not override keep the timeline's, and the entry beside it is untouched. + // Three separate things one `override != null` decides, so they are asserted + // together — and the resolution moved when the model was normalized. + DocumentTextStyle timelineTitle = DocumentTextStyle.builder().size(19).build(); + DocumentTextStyle timelineMeta = DocumentTextStyle.builder().size(7).build(); + DocumentTextStyle timelineBody = DocumentTextStyle.builder().size(11).build(); + DocumentTextStyle ownTitle = DocumentTextStyle.builder().size(23).build(); + DocumentTextStyle ownBody = DocumentTextStyle.builder().size(5).build(); + + SectionNode timeline = timelineOf(t -> t + .titleStyle(timelineTitle).metaStyle(timelineMeta).bodyStyle(timelineBody) + .entry(TimelineMarker.dot(8, NAVY), e -> e + .title("Overridden", ownTitle).meta("M").body("B", ownBody)) + .entry(TimelineMarker.dot(8, NAVY), e -> e + .title("Plain").meta("M").body("B"))); + + List overridden = paragraphsOf(header(entry(timeline, 0)).children().get(1)); + assertThat(overridden.get(0).textStyle().size()).as("its own title style") + .isEqualTo(23.0, within(1e-9)); + assertThat(overridden.get(1).textStyle().size()).as("the meta it did not override") + .isEqualTo(7.0, within(1e-9)); + assertThat(lastParagraph(entry(timeline, 0)).textStyle().size()).isEqualTo(5.0, within(1e-9)); + + List plain = paragraphsOf(header(entry(timeline, 1)).children().get(1)); + assertThat(plain.get(0).textStyle().size()).as("the next entry is unaffected") + .isEqualTo(19.0, within(1e-9)); + assertThat(lastParagraph(entry(timeline, 1)).textStyle().size()).isEqualTo(11.0, within(1e-9)); + } + + @Test + void anEntryOmitsTheParagraphsItWasNotGiven() { + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("Only a title"))); + + assertThat(paragraphTexts(header(entry(timeline, 0)).children().get(1))) + .containsExactly("Only a title"); + assertThat(paragraphsOf(entry(timeline, 0))) + .as("no body paragraph when no body was given") + .isEmpty(); + } + + @Test + void addHangsExtraContentOffTheEntryItself() { + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e + .title("T") + .add(extra -> extra.addParagraph("appended")))); + + assertThat(paragraphTexts(entry(timeline, 0))) + .as("add() content is a sibling of the header row, not of the title") + .contains("appended"); + } + + // --- the two ways to describe an entry ----------------------------------- + + @Test + void theShorthandAndTheLongFormBuildTheSameEntry() { + // entry(marker, ...) is meant to be sugar, not a second path. If it ever grows one, + // every guarantee proven through one form stops covering the other. + SectionNode shorthand = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e + .title("Senior Engineer").meta("2023 - now").body("What I did."))); + SectionNode longForm = timelineOf(t -> t + .entry(e -> e + .marker(TimelineMarker.dot(8, NAVY)) + .title("Senior Engineer").meta("2023 - now").body("What I did."))); + + assertThat(outline(longForm)) + .as("same structure, same text, same order") + .isEqualTo(outline(shorthand)); + } + + @Test + void contentFillsTheEntrysColumnInsteadOfATitleAndABody() { + SectionNode timeline = timelineOf(t -> t + .entry(e -> e + .marker(TimelineMarker.dot(8, NAVY)) + .content(column -> column + .addParagraph("Mine, first") + .addParagraph("Mine, second")))); + + SectionNode entry = entry(timeline, 0); + assertThat(paragraphTexts(header(entry).children().get(1))) + .as("the caller's blocks land in the content column of the header row") + .containsExactly("Mine, first", "Mine, second"); + assertThat(paragraphsOf(entry)) + .as("and nothing hangs below the header row, because no body was described") + .isEmpty(); + } + + @Test + void theSemanticApiAndContentCannotBeMixed() { + String message = "Cannot combine title/meta/body entry content with custom content(). " + + "Use either the semantic entry API or content()."; + + assertThatIllegalStateException() + .as("semantic first") + .isThrownBy(() -> timelineOf(t -> t.entry(e -> e + .marker(TimelineMarker.dot(8, NAVY)) + .title("T") + .content(column -> column.addParagraph("also this"))))) + .withMessage(message); + + assertThatIllegalStateException() + .as("and custom first — the rule is not about which came last") + .isThrownBy(() -> timelineOf(t -> t.entry(e -> e + .marker(TimelineMarker.dot(8, NAVY)) + .content(column -> column.addParagraph("mine")) + .title("T")))) + .withMessage(message); + } + + @Test + void aStyleOverrideOnItsOwnAlreadyCommitsTheEntryToTheSemanticApi() { + // Easy to miss when the rule is read as "title, meta or body": a style override + // names a slot that content() does not have, so the two are just as incompatible. + assertThatIllegalStateException() + .isThrownBy(() -> timelineOf(t -> t.entry(e -> e + .marker(TimelineMarker.dot(8, NAVY)) + .titleStyle(DocumentTextStyle.builder().size(12).build()) + .content(column -> column.addParagraph("mine"))))) + .withMessageContaining("either the semantic entry API or content()"); + } + + @Test + void anEntryWithoutAMarkerSaysWhichCallIsMissing() { + assertThatIllegalStateException() + .isThrownBy(() -> timelineOf(t -> t.entry(e -> e.title("no marker")))) + .withMessageContaining("marker(...)"); + } + + @Test + void theShorthandsMarkerCannotBeReplacedFromInsideTheEntry() { + // Two markers declared for one entry. Letting either win silently is the kind of + // order-dependence that only shows up in the rendered document. + assertThatIllegalStateException() + .isThrownBy(() -> timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e + .marker(TimelineMarker.dot(12, NAVY)) + .title("T")))) + .withMessageContaining("exactly one marker") + .withMessageContaining("entry(marker, ...)"); + } + + @Test + void aMarkerCannotBeDeclaredTwiceInTheAdvancedFormEither() { + // The same invariant, reached without the shorthand. A guard keyed on "the marker + // came from entry(marker, ...)" would let this one through and silently keep the + // second marker — one entry, two markers declared, no error. + assertThatIllegalStateException() + .isThrownBy(() -> timelineOf(t -> t + .entry(e -> e + .marker(TimelineMarker.dot(8, NAVY)) + .marker(TimelineMarker.square(12, NAVY)) + .title("T")))) + .withMessageContaining("exactly one marker"); + } + + // --- the axis column ----------------------------------------------------- + + @Test + void axisWidthSizesTheMarkerColumnInPointsInsteadOfShares() { + SectionNode timeline = timelineOf(t -> t + .axisWidth(18) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(header(entry(timeline, 0)).columns()) + .as("a fixed axis, then the content column taking the rest") + .containsExactly(DocumentRowColumn.fixed(18), DocumentRowColumn.weight(1.0)); + } + + @Test + void aWeightAxisStillReachesTheRowAsWeightsAsItAlwaysHas() { + // columns(weight, weight) resolves identically — the snapshots say so. But + // RowNode.weights() is public, and spelling it the other way would empty that list + // for every timeline already written. + SectionNode timeline = timelineOf(t -> t + .markerColumnWeight(0.4) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(header(entry(timeline, 0)).weights()).containsExactly(0.4, 1.0); + assertThat(header(entry(timeline, 0)).columns()).isEmpty(); + } + + @Test + void theAxisWidthIsDeclaredOnceInEitherOrder() { + // A weight is a share of the row and a fixed width is points. There is no + // conversion between them without a row width, so a timeline that asks for both + // has not said what it wants. + assertThatIllegalStateException() + .as("weight then points") + .isThrownBy(() -> timelineOf(t -> t.markerColumnWeight(0.4).axisWidth(18))) + .withMessageContaining("one width, declared once"); + assertThatIllegalStateException() + .as("points then weight") + .isThrownBy(() -> timelineOf(t -> t.axisWidth(18).markerColumnWeight(0.4))) + .withMessageContaining("one width, declared once"); + } + + @Test + void anIgnoredMarkerColumnWeightDoesNotCountAsDeclaringTheAxis() { + // markerColumnWeight has always ignored a non-positive value. A call that changed + // nothing must not then block axisWidth — that would be a new failure in code that + // used to work. + SectionNode timeline = timelineOf(t -> t + .markerColumnWeight(0) + .axisWidth(18) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))); + + assertThat(header(entry(timeline, 0)).columns()) + .containsExactly(DocumentRowColumn.fixed(18), DocumentRowColumn.weight(1.0)); + } + + @Test + void axisWidthTakesOnlyAPositiveFiniteNumberOfPoints() { + assertThatIllegalArgumentException().isThrownBy(() -> timelineOf(t -> t.axisWidth(0))) + .withMessageContaining("positive finite"); + assertThatIllegalArgumentException().isThrownBy(() -> timelineOf(t -> t.axisWidth(-4))) + .withMessageContaining("positive finite"); + assertThatIllegalArgumentException().isThrownBy(() -> timelineOf(t -> t.axisWidth(Double.NaN))) + .withMessageContaining("positive finite"); + assertThatIllegalArgumentException() + .isThrownBy(() -> timelineOf(t -> t.axisWidth(Double.POSITIVE_INFINITY))) + .withMessageContaining("positive finite"); + } + + // --- the leading column -------------------------------------------------- + + @Test + void aLeadingColumnPutsAThirdColumnBeforeTheMarker() { + SectionNode timeline = timelineOf(t -> t + .leadingColumn(DocumentRowColumn.fixed(48)) + .entry(e -> e + .marker(TimelineMarker.dot(8, NAVY)) + .leading(date -> date.addParagraph("2023")) + .title("Senior Engineer"))); + + RowNode header = header(entry(timeline, 0)); + assertThat(header.children()).hasSize(3); + assertThat(paragraphTexts(header.children().get(0))) + .as("leading first, before the marker") + .containsExactly("2023"); + assertThat(markerContent(header.children().get(1))) + .as("then the marker") + .isInstanceOf(EllipseNode.class); + assertThat(paragraphTexts(header.children().get(2))).containsExactly("Senior Engineer"); + } + + @Test + void anEntryWithNoLeadingContentStillGetsTheColumn() { + // The column belongs to the timeline, not to the entry. An entry that skips it has + // to keep the empty space, or its marker starts where another entry's date starts. + SectionNode timeline = timelineOf(t -> t + .leadingColumn(DocumentRowColumn.fixed(48)) + .entry(e -> e.marker(TimelineMarker.dot(8, NAVY)) + .leading(date -> date.addParagraph("2023")).title("With")) + .entry(e -> e.marker(TimelineMarker.dot(8, NAVY)).title("Without"))); + + RowNode withoutLeading = header(entry(timeline, 1)); + assertThat(withoutLeading.children()) + .as("three columns either way") + .hasSize(3); + assertThat(paragraphTexts(withoutLeading.children().get(0))) + .as("the first is simply empty") + .isEmpty(); + assertThat(markerContent(withoutLeading.children().get(1))) + .as("so the marker is still the second column, as in the entry above") + .isInstanceOf(EllipseNode.class); + } + + @Test + void withNoLeadingColumnTheHeaderRowIsTheTwoColumnOneItAlwaysWas() { + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("T"))); + + assertThat(header(entry(timeline, 0)).children()) + .as("declaring no leading column adds no column") + .hasSize(2); + } + + @Test + void anAutoLeadingColumnIsRejectedForTheReasonItWouldFail() { + // Measured, not assumed: with auto(), a row whose leading text is "2023" and one + // whose leading text is "September 2024 - present" put their markers 131pt apart. + assertThatIllegalArgumentException() + .isThrownBy(() -> timelineOf(t -> t.leadingColumn(DocumentRowColumn.auto()))) + .withMessageContaining("measured from its own row's content") + .withMessageContaining("fixed(points) or weight(share)"); + } + + @Test + void leadingContentWithoutALeadingColumnNamesTheCallToAdd() { + assertThatIllegalStateException() + .isThrownBy(() -> timelineOf(t -> t + .entry(e -> e.marker(TimelineMarker.dot(8, NAVY)) + .leading(date -> date.addParagraph("2023"))))) + .withMessageContaining("leadingColumn(...)"); + } + + @Test + void leadingWorksWithEitherWayOfDescribingTheContent() { + // It describes a different column, so it is not part of the choice between them. + SectionNode semantic = timelineOf(t -> t + .leadingColumn(DocumentRowColumn.weight(0.3)) + .entry(e -> e.marker(TimelineMarker.dot(8, NAVY)) + .leading(d -> d.addParagraph("2023")).title("T"))); + SectionNode custom = timelineOf(t -> t + .leadingColumn(DocumentRowColumn.weight(0.3)) + .entry(e -> e.marker(TimelineMarker.dot(8, NAVY)) + .leading(d -> d.addParagraph("2023")).content(c -> c.addParagraph("T")))); + + assertThat(paragraphTexts(header(entry(semantic, 0)).children().get(0))).containsExactly("2023"); + assertThat(paragraphTexts(header(entry(custom, 0)).children().get(0))).containsExactly("2023"); + } + + @Test + void aColumnSlotIsDeclaredOnceNotAssigned() { + // marker, leading and content each take a whole column. Two of them is a mistake, + // and which one survived should not depend on the order the calls were written in. + assertThatIllegalStateException() + .as("leading twice") + .isThrownBy(() -> timelineOf(t -> t + .leadingColumn(DocumentRowColumn.fixed(48)) + .entry(e -> e.marker(TimelineMarker.dot(8, NAVY)) + .leading(d -> d.addParagraph("a")) + .leading(d -> d.addParagraph("b"))))) + .withMessageContaining("declares leading(...) once"); + + assertThatIllegalStateException() + .as("content twice") + .isThrownBy(() -> timelineOf(t -> t + .entry(e -> e.marker(TimelineMarker.dot(8, NAVY)) + .content(c -> c.addParagraph("a")) + .content(c -> c.addParagraph("b"))))) + .withMessageContaining("declares content(...) once"); + } + + // --- markers ------------------------------------------------------------- + + @Test + void aCustomMarkerRendersWithoutTheBuilderKnowingWhatItIs() { + // The point of the factory: three shapes and a caller's own arrangement reach the + // marker column, and nothing in TimelineBuilder was told about any of it. + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.custom(16, 16, column -> column.addLayerStack(stack -> stack + .back(new EllipseNode("ring", 16, 16, NAVY, null, null, null, null, null)) + .center(new EllipseNode("disc", 10, 10, DocumentColor.WHITE, + null, null, null, null, null)) + .center(new EllipseNode("pip", 4, 4, NAVY, null, null, null, null, null)))), + e -> e.title("Custom"))); + + DocumentNode marker = markerContent(header(entry(timeline, 0)).children().get(0)); + assertThat(marker).isInstanceOf(LayerStackNode.class); + assertThat(((LayerStackNode) marker).layers()).hasSize(3); + } + + @Test + void aMarkerIsWrappedInAnAnchorSoTheLayoutCanReportWhereItLanded() { + // The wrapper every other test in this file reads through. It is what turns "the + // marker drew three shapes" into one box the finished layout can report, and it + // sits inside the row's column rather than being it — a row hosts a fixed set of + // child types and this is not one of them. + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")) + .entry(TimelineMarker.dot(8, NAVY), e -> e.title("y"))); + + DocumentNode first = markerAnchorIn(header(entry(timeline, 0)).children().get(0)); + DocumentNode second = markerAnchorIn(header(entry(timeline, 1)).children().get(0)); + + LayoutAnchorId firstId = ((LayoutAnchorNode) first).id(); + LayoutAnchorId secondId = ((LayoutAnchorNode) second).id(); + assertThat(firstId.index()).as("the entry's position").isZero(); + assertThat(secondId.index()).isEqualTo(1); + assertThat(firstId.kind()).isSameAs(secondId.kind()); + assertThat(firstId.groupKey()) + .as("one owner for the whole timeline, so its markers find each other") + .isSameAs(secondId.groupKey()); + } + + @Test + void twoTimelinesOnAPageAnchorOnDifferentOwners() { + // The property that lets a pass ask for its own markers and get nobody else's. + SectionNode page = new SectionBuilder() + .addTimeline(t -> t.entry(TimelineMarker.dot(8, NAVY), e -> e.title("first timeline"))) + .addTimeline(t -> t.entry(TimelineMarker.dot(8, NAVY), e -> e.title("second timeline"))) + .build(); + + Object first = ownerOf((SectionNode) page.children().get(0)); + Object second = ownerOf((SectionNode) page.children().get(1)); + assertThat(first).isNotSameAs(second); + } + + @Test + void aCustomMarkerDeclaresABoxTheTimelineTakesAtItsWord() { + // Declared rather than measured, and not required to be square: the box is what a + // rail anchors on, and it must not depend on what the recipe happened to draw. + assertThatIllegalArgumentException() + .isThrownBy(() -> TimelineMarker.custom(0, 16, column -> { })) + .withMessageContaining("width must be a positive finite"); + assertThatIllegalArgumentException() + .isThrownBy(() -> TimelineMarker.custom(16, Double.NaN, column -> { })) + .withMessageContaining("height must be a positive finite"); + assertThatNullPointerException() + .isThrownBy(() -> TimelineMarker.custom(16, 16, null)); + } + + @Test + void everyMarkerFactoryPutsItsOwnShapeInTheMarkerColumn() { + assertThat(markerNode(TimelineMarker.dot(8, NAVY))).isInstanceOf(EllipseNode.class); + assertThat(markerNode(TimelineMarker.circle(8, NAVY, null))).isInstanceOf(EllipseNode.class); + assertThat(markerNode(TimelineMarker.numbered(3, 14, NAVY, DocumentColor.WHITE))) + .isInstanceOf(ShapeContainerNode.class); + assertThat(markerNode(TimelineMarker.square(8, NAVY))).isInstanceOf(ShapeNode.class); + } + + // --- the shape of the whole tree ---------------------------------------- + + @Test + void anUntouchedTimelineBuildsTheSameTreeItAlwaysHas() { + SectionNode timeline = timelineOf(t -> t + .entry(TimelineMarker.dot(8, NAVY), e -> e + .title("First").meta("2021 - now").body("body one")) + .entry(TimelineMarker.numbered(2, 14, NAVY, DocumentColor.WHITE), + e -> e.title("Second").body("body two"))); + + assertThat(timeline.children()).hasSize(2); + SectionNode first = entry(timeline, 0); + + assertThat(first.borders().hasAny()).as("no per-entry rail border").isFalse(); + assertThat(first.padding().left()).as("the default gutter").isEqualTo(DEFAULT_GUTTER, within(1e-9)); + assertThat(first.padding().bottom()) + .as("the default entry spacing").isEqualTo(DEFAULT_ENTRY_SPACING, within(1e-9)); + assertThat(first.children().get(0)).isInstanceOf(RowNode.class); + assertThat(first.children()).noneSatisfy(child -> + assertThat(child).isInstanceOf(LayerStackNode.class)); + + assertThat(header(first).children()).as("marker column then title column").hasSize(2); + assertThat(lastParagraph(first).text()).isEqualTo("body one"); + assertThat(entry(timeline, 1).padding().bottom()) + .as("no spacing after the last entry").isEqualTo(0.0, within(1e-9)); + } + + // --- helpers ------------------------------------------------------------- + + private static SectionNode timelineOf(Consumer spec) { + SectionNode root = new SectionBuilder().addTimeline(spec).build(); + return (SectionNode) root.children().get(0); + } + + /** + * One entry's section, reached through the anchor that wraps it. + * + *

Entries are anchored so the rail can be computed from where they land — the pass + * needs each entry's extent on each page, and an anchor is the only thing that carries + * it. The unwrapping lives here rather than in every test, and + * {@link #everyEntryIsAnchoredSoTheRailCanBeComputedFromWhereItLands()} is where the + * wrapper itself is asserted.

+ */ + private static SectionNode entry(SectionNode timeline, int index) { + DocumentNode anchored = timeline.children().get(index); + assertThat(anchored).isInstanceOf(LayoutAnchorNode.class); + return (SectionNode) anchored.children().get(0); + } + + private static RowNode header(SectionNode entry) { return entry.children().stream() + .filter(RowNode.class::isInstance) + .map(RowNode.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError("no header RowNode in the entry")); + } + + private static DocumentNode markerNode(TimelineMarker marker) { + SectionNode timeline = timelineOf(t -> t.entry(marker, e -> e.title("x"))); + return markerContent(header(entry(timeline, 0)).children().get(0)); + } + + /** + * What the marker's recipe drew, reached through the anchor that wraps it. + * + *

The column holds a {@code LayoutAnchorNode} holding the drawn marker, so that the + * finished layout reports one box per marker however many shapes it took to draw. The + * unwrapping lives here rather than in each test, and + * {@link #aMarkerIsWrappedInAnAnchorSoTheLayoutCanReportWhereItLanded()} is where the + * wrapper itself is asserted.

+ */ + private static DocumentNode markerContent(DocumentNode markerColumn) { + // Inside the anchor is the canvas of the marker's declared box: the recipe draws + // into that box rather than deciding it, which is what "declared, not measured" + // means. aMarkerIsGivenTheBoxItDeclaredWhateverItDrew() asserts the box itself. + DocumentNode canvas = markerAnchorIn(markerColumn).children().get(0); + assertThat(canvas).isInstanceOf(CanvasLayerNode.class); + return canvas.children().get(0).children().get(0); + } + + /** + * The anchor around one entry's marker. + * + *

An {@code AlignNode} sits between the column and the anchor: where the marker sits + * in the axis follows from its anchor — left edge to the left, centre to the centre — + * and the align is how that is expressed. The anchor stays around the marker itself, so + * it keeps reporting the marker's box and not the column's.

+ */ + private static DocumentNode markerAnchorIn(DocumentNode markerColumn) { + DocumentNode align = markerColumn.children().get(0); + assertThat(align).isInstanceOf(AlignNode.class); + DocumentNode anchor = align.children().get(0); + assertThat(anchor).isInstanceOf(LayoutAnchorNode.class); + return anchor; + } + + /** + * The stroke this timeline's rail will be drawn with. + * + *

Read off the owner rather than off a border, because the rail is no longer a + * border. This asserts the configuration itself instead of one of its side effects, + * which is the more direct claim anyway.

+ */ + private static DocumentStroke railStroke(SectionNode timeline) { + return ((TimelineRailOwner) ownerOf(timeline)).rail().stroke(); + } + + /** The owner every marker in one timeline anchors on. */ + private static Object ownerOf(SectionNode timeline) { + return ((LayoutAnchorNode) markerAnchorIn(header(entry(timeline, 0)).children().get(0))).id().groupKey(); + } + + private static List paragraphsOf(DocumentNode parent) { + return parent.children().stream() .filter(ParagraphNode.class::isInstance) .map(ParagraphNode.class::cast) + .toList(); + } + + private static List paragraphTexts(DocumentNode parent) { + return paragraphsOf(parent).stream().map(ParagraphNode::text).toList(); + } + + private static ParagraphNode lastParagraph(SectionNode entry) { + return paragraphsOf(entry).stream() .reduce((first, second) -> second) .orElseThrow(); } + + /** + * The node tree as indented {@code Kind[text]} lines. Structure, order and text — not + * styles, which the record types do not compare reliably anyway. + */ + private static String outline(DocumentNode node) { + StringBuilder out = new StringBuilder(); + outline(node, 0, out); + return out.toString(); + } + + private static void outline(DocumentNode node, int depth, StringBuilder out) { + out.append(" ".repeat(depth)).append(node.getClass().getSimpleName()); + if (node instanceof ParagraphNode paragraph) { + out.append('[').append(paragraph.text()).append(']'); + } + out.append('\n'); + for (DocumentNode child : node.children()) { + outline(child, depth + 1, out); + } + } } diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 514eb3968..f7149226b 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -71,6 +71,7 @@ Payload records live in `core` under | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (ZXing raster) | ✅ `PptxBarcodeFragmentRenderHandler` (identical ZXing raster) | ❌ | | Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders`; a composed cell writes paragraphs and their wrappers only — one built from an image or a list lands empty, and a fill's opacity is dropped since `w:shd` is opaque) | | Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning | +| Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ omitted: the rail is resolved fixed-layout geometry and `DocxSemanticBackend` consumes the semantic tree, never a `LayoutGraph`. A timeline's **content** exports in full — entries, titles, meta and bodies — and the export does not throw; only the drawn line is absent. Marker shapes are geometry and may be omitted for the same reason | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | | Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ❌ | | Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ❌ | diff --git a/docs/architecture/resolved-layout-seams.md b/docs/architecture/resolved-layout-seams.md new file mode 100644 index 000000000..946dd803e --- /dev/null +++ b/docs/architecture/resolved-layout-seams.md @@ -0,0 +1,87 @@ +# Resolved-layout seams + +Two capabilities let a built-in feature use geometry the layout has already worked out. +They look alike and are not interchangeable, and the difference is *when* they run: + +``` +ResolvedLayoutPass geometry is already laid out -> add drawing +ResolvedHorizontalBand resolved earlier in the same compile -> influences later measurement +``` + +Both are `@Internal` engine plumbing. Neither names a feature; the timeline is the first +consumer of each and appears below only as an example. + +--- + +## A. Resolved layout pass — drawing from settled geometry + +A node wrapped in a `LayoutAnchorNode` leaves one non-visual fragment per page it occupies, +carrying that page's slice of its border box. After the document is compiled, a +`ResolvedLayoutPass` reads those anchors and contributes fragments of its own. + +- It runs **after** layout. It can draw; it cannot move anything, change a width, or add a + page — a fragment for a page the document does not have is refused. +- Passes are discovered from the anchors themselves: an anchor's owner that is also a pass + is a feature saying it has something to draw. Nothing is registered. +- Ownership is object identity. Two features that anchor alike are still two features. + +*Example consumer:* the timeline rail. Marker anchors give the line its x, entry anchors give +it its two ends on each page, and the pass contributes one line fragment per occupied page. + +## B. Resolved horizontal band — measuring inside a settled column + +A row resolves where each of its columns starts and how wide it is — from points, from shares +of what is left, or from a mixture — and normally forgets the arithmetic. Wrapped in a +`HorizontalBandsNode`, it records each slot as `ResolvedHorizontalBand{x, width}` under an +identity; a later `HorizontalBandContentNode` naming the same identity is laid out inside one +of them. + +- It runs **during** the same compile, and the band is applied **before** the consumer is + measured. That order is the whole point: the width decides the wrapping, the wrapping + decides the height, and the height decides the pagination. A post-layout pass is too late + to change any of them. +- The consumer stays an ordinary vertical block, so it splits across pages normally and keeps + one x and one width on every page it reaches. +- Nothing becomes splittable that was not: a row is still laid out on one page. +- Fixed and weighted columns behave identically, because the band is read after the row + resolved it rather than recomputed from the sizing. +- Storage is per compile — `CompilerState` is rebuilt for every pass — and keyed by object + identity. There is no registry and nothing survives a document. +- Everything else fails closed and says which part of the arrangement is wrong: a column + nobody published, content that comes before its row, two rows under one identity, a column + the row does not have, a column that resolved to nothing, content in a fixed slot. + +*Example consumer:* a timeline entry's body under `markerOnRail()`. The entry's header row +publishes its columns; the body is laid out in the content one, so the rail — which now runs +through the axis column — has only markers left to pass through. + +### Why the band is not a second pass + +A block whose horizontal origin comes from a column resolved earlier in the same compile is +safe only because preparation is lazy and in document order: when the body is measured, the +row above it has already been laid out. Nothing is compiled twice — relocation is decided +before a block is compiled, by comparing its measured height against what is left of the page, +so there is no abandoned attempt to leave stale state behind. + +--- + +## Paint order + +The engine has no z-index. Draw order is list order, and the driver splices contributions into +one list: + +``` +page background + < under-body additions (immediately before the contributing feature's own content) + < body: containers, fills, text, markers + < over-body additions (after the whole body) + < zone chrome (header / footer) +``` + +`UNDER_BODY` means **under the contributing feature's own content**, not at the front of the +page's fragment list. The distinction is not academic: a fragment placed at the front also +sits beneath the fill of whatever the feature is inside, so a rail inside a filled panel was +painted first and covered a moment later — present in the geometry, absent from the page, and +invisible to every assertion that reads coordinates. The splice point is now the first +fragment the feature's own anchors produced on that page; a pass that anchored nothing on a +page keeps the front of the list, which is what a page-wide backdrop wants. diff --git a/docs/recipes.md b/docs/recipes.md index e8901c986..9b243c2f4 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -23,7 +23,7 @@ authoring API; public application code should not import | [Text direction](recipes/text-direction.md) | `TextDirection` — right-to-left paragraphs, `AUTO` resolved from the text, mixed lines, and the bundled Hebrew / Arabic families | | [Rich text](recipes/rich-text.md) | `RichText` mixed-style runs in one paragraph: bold/accent/styled segments, inline links, inline images, inline SVG icons, emoji shortcodes, inline shapes and checkboxes | | [Lists](recipes/lists.md) | `addList`: quick bulleted lists, marker customisation, nested lists with per-depth markers, spacing and styled items | -| [Timelines](recipes/timelines.md) | `addTimeline`: markers (dot / circle / numbered / square) on a connector rail, geometry and text-style controls, pagination opt-ins | +| [Timelines](recipes/timelines.md) | `addTimeline`: the leading / axis / content model, markers (dot / circle / numbered / square / custom), leading column, axis sizing, `markerOnRail()`, rail extent, pagination, backends | | [Barcodes](recipes/barcodes.md) | QR / Code 128 / Code 39 / EAN / UPC / PDF417 / DataMatrix, tinting, quiet zone, card centring | | [Images](recipes/images.md) | Sources (bytes/path), sizing precedence, STRETCH/CONTAIN/COVER fit modes, images in rows and cards | | [PDF chrome](recipes/pdf-chrome.md) | Metadata, watermarks, running header/footer with `{page}/{pages}/{date}`, protection, links and outline bookmarks | diff --git a/docs/recipes/timelines.md b/docs/recipes/timelines.md index d290acf58..7ce134b7f 100644 --- a/docs/recipes/timelines.md +++ b/docs/recipes/timelines.md @@ -1,11 +1,32 @@ # Timelines: markers on a connector rail -`addTimeline` builds a vertical timeline: a sequence of entries, each a -`TimelineMarker` sitting in a continuous connector rail, paired with its -content (title, meta, body). Pairing the marker with its entry — instead -of hand-placing a bullet plus a left margin per row — is the semantic -win. The rail auto-stretches to each entry's height, so it spans -variable-length content and reads as one continuous line. +`addTimeline` builds a vertical timeline: a sequence of entries, each a `TimelineMarker` +paired with its content, threaded on one continuous rail. Pairing the marker with its entry — +instead of hand-placing a bullet plus a left margin per row — is the semantic win. + +## The model + +``` +Timeline +├── Rail one continuous line, resolved from where the markers and entries landed +└── Entry ×N + ├── Leading optional, e.g. a date column + ├── Marker dot / circle / numbered / square / your own + └── Content title, meta, body — or a column of your own +``` + +Horizontally an entry is three columns: + +``` +LEADING | AXIS | CONTENT +``` + +- The **rail belongs to the axis**. Leading content sits to its left and never moves it. +- **Marker size does not move the axis.** A 6pt dot and a 24pt square share one rail. +- The axis is sized either as a **share** of the row or in **points** — both fully supported. +- The rail is **one logical line**, computed after layout from the resolved marker and entry + positions and contributed as one fragment per page it crosses. It is not a border repeated + on each entry. ## A basic timeline @@ -26,47 +47,200 @@ section.addTimeline(timeline -> timeline .body("Shipped the layout engine and the table system."))); ``` -Each entry slot is optional — a marker with only a title, or only a -body, renders fine. `e.add(content -> ...)` appends arbitrary extra -blocks below the body (chips, nested rows, lists), configured against -the entry's content section. +Each entry slot is optional — a marker with only a title, or only a body, renders fine. +`e.add(content -> ...)` appends arbitrary extra blocks below the body (chips, nested rows, +lists), configured against the entry's content section. + +This is the shape every timeline written before the advanced API uses, and it keeps its +placement: markers packed to the left of the axis, rail one gutter further left, body spanning +the entry. + +## Entries: two ways to fill one + +The convenience mode above resolves `title` / `meta` / `body` into styled paragraphs. The +advanced form hands you the content column instead: + +```java +section.addTimeline(timeline -> timeline + .entry(e -> e + .marker(TimelineMarker.numbered(1, 16, accent, DocumentColor.WHITE)) + .content(column -> column + .addParagraph("Anything you like") + .addTable(t -> t.headerRow("Stage", "Owner"))))); +``` + +- A marker is required, either way. +- The two modes are **mutually exclusive**: an entry that calls `content(...)` cannot also set + `title` / `meta` / `body`, and declaring the marker twice throws. +- Both spellings normalize into the same internal entry, so nothing downstream can tell which + one an author used. -## Marker kinds +## Leading column: DATE | AXIS | CONTENT + +```java +import com.demcha.compose.document.style.DocumentRowColumn; + +section.addTimeline(timeline -> timeline + .leadingColumn(DocumentRowColumn.fixed(64)) + .entry(e -> e.marker(TimelineMarker.dot(8, accent)) + .leading(date -> date.addParagraph("2023")) + .title("Senior Engineer") + .body("Led the layout engine rewrite.")) + .entry(e -> e.marker(TimelineMarker.dot(8, accent)) + .title("No date on this one"))); +``` + +- `leadingColumn(...)` belongs to the **timeline**, not to an entry: every entry reserves the + same column, so an entry that puts nothing in it still starts its marker where the others do. +- `fixed(...)` and `weight(...)` are supported. `auto()` is not: an auto column sized per entry + would be a different width on each row, and the axis — and with it the rail — would move. +- A leading column never pushes the rail out to the entry's boundary. The rail stays with the + axis, so the dates sit to the left of the line. + +## Axis width + +```java +section.addTimeline(timeline -> timeline.markerColumnWeight(0.12) …); // a share of the row +section.addTimeline(timeline -> timeline.axisWidth(28) …); // points +``` + +Two sizing strategies for the same column. A weight is a share of what the row has left, so it +grows with the page; `axisWidth` is points, so it does not. Declare one or the other — +configuring both throws — and neither is converted into the other. + +## Markers ```java import com.demcha.compose.document.style.DocumentStroke; -TimelineMarker.dot(8, accent); // solid filled dot -TimelineMarker.circle(10, null, // outlined ring - DocumentStroke.of(accent, 1.2)); -TimelineMarker.numbered(3, 16, accent, // numbered disc - DocumentColor.WHITE); -TimelineMarker.square(8, accent); // filled square +TimelineMarker.dot(8, accent); // solid filled dot +TimelineMarker.circle(10, null, DocumentStroke.of(accent, 1.2)); // outlined ring +TimelineMarker.numbered(3, 16, accent, DocumentColor.WHITE); // numbered disc +TimelineMarker.square(8, accent); // filled square +TimelineMarker.custom(18, 18, column -> column // anything you can draw + .addLayerStack(stack -> stack.back(ring).center(disc).center(pip))); ``` -`circle(size, fill, stroke)` takes an optional fill and/or outline — -pass a fill for a two-tone disc, or only a stroke for an empty ring. -`numbered(n, size, fill, textColor)` centres the step number in the -disc; the label scales with the disc size. A marker carries its own size -into the rail column, so mixed marker sizes in one timeline lay out -correctly. +`circle(size, fill, stroke)` takes an optional fill and/or outline — pass a fill for a two-tone +disc, or only a stroke for an empty ring. `numbered(n, size, fill, textColor)` centres the step +number in the disc; the label scales with the disc size. -## Rail and geometry +**A marker is the box it declares.** `custom(width, height, recipe)` reserves exactly that box: +the timeline lays out around it, and the marker's anchor — what the rail is derived from — is +that box. + +- The recipe may draw smaller than the box; the rest of the box is simply empty. +- It may draw larger; the drawing overflows visibly and the box does not grow. +- The box need not be square. +- How thick an outline is does not change it — ink spreads about a shape's edge, the declared + bounds do not follow it. + +An outlined circle filled with the page's own colour is worth knowing: with the markers on the +rail, the line passes behind each ring and the fill covers it, so the rail reads as broken at +every stop without any change to its geometry. + +```java +TimelineMarker.circle(14, DocumentColor.WHITE, DocumentStroke.of(accent, 1.2)); +``` + +## markerOnRail(): markers on the line, not beside it ```java section.addTimeline(timeline -> timeline - .connector(DocumentColor.rgb(150, 158, 172), 1.5) // rail colour + width - .gutter(8) // rail → marker/content gap - .markerGap(8) // marker → title gap - .markerColumnWeight(0.12) // widen for large numbered discs - .spacing(14) // vertical gap between entries - .entry(TimelineMarker.dot(8, accent), e -> e.title("Kick-off"))); + .markerOnRail() + .axisWidth(28) + .entry(TimelineMarker.dot(6, accent), e -> e.title("Small").body("…")) + .entry(TimelineMarker.numbered(2, 14, accent, DocumentColor.WHITE), e -> e.title("Medium")) + .entry(TimelineMarker.square(24, accent), e -> e.title("Large"))); ``` -The rail is a left accent border on each entry that spans the entry -spacing too, so the line never breaks between entries. Increase -`markerColumnWeight` (relative to a content weight of 1.0) when large -numbered discs crowd a narrow timeline. +Opting in aligns each marker's declared anchor with the axis. Today's on-rail anchor is the +marker's centre, so markers of different declared sizes share **one** rail x — each is placed +inside the resolved axis column rather than packed to its left. + +An entry's **body moves with it**, into the content column: with the rail inside the axis, a +body spanning the entry would be drawn through, so the body starts where the title starts and +the rail is left with only markers to cross. The body stays a normal vertical block — a long +one still splits across pages, keeping the same x and width on each — and it uses the column +the entry's own header row resolved, so a fixed axis and a weighted one behave alike. A leading +column works unchanged. + +A timeline that does not call this keeps the placement it has always had. + +## Rail configuration + +```java +section.addTimeline(timeline -> timeline + .connector(DocumentColor.rgb(150, 158, 172), 1.5) // shorthand: colour + width + .gutter(8) // rail → marker gap + .markerGap(8) // marker → content gap + .spacing(14) // vertical gap between entries + …); +``` + +```java +import com.demcha.compose.document.dsl.TimelineRailExtent; + +section.addTimeline(timeline -> timeline + .rail(r -> r + .stroke(DocumentStroke.of(accent, 2.0)) + .extent(TimelineRailExtent.MARKER_TO_MARKER)) + …); +``` + +`connector(colour, width)` is the shorthand for `rail(r -> r.stroke(...))` and produces the +same rail. Use one spelling or the other: a timeline that configures the rail **both ways** +throws, naming both calls. Repeating the same spelling is ordinary setter accumulation and +stays legal — `connector(colour, 0)` then `connector(null, width)` has always been a way to set +the two halves separately. A call that changes nothing (a null colour and a non-positive width) +is not a use and does not count. + +## Rail extent + +| Extent | What the line covers | +|---|---| +| `ENTRY_BOUNDS` *(default)* | Every entry, on each page. The spacing between entries belongs to the entry above it, so the line is continuous through the gaps — and there is no tail after the last entry. | +| `MARKER_TO_MARKER` | The same per-page bands, trimmed: the first page starts at the first marker's anchor, the last ends at the last marker's, and a page carrying no marker runs its whole band. One entry means no rail at all rather than a line of no length. | +| `TIMELINE_BOUNDS` | **Not implemented.** Declared and rejected with a message: on one page it is the same line as `ENTRY_BOUNDS`, and across pages there is nothing to measure it against. | + +Neither supported extent moves the rail sideways — where the line runs is the marker anchor's +business, how far it runs is the extent's. + +## Pagination + +A timeline paginates between entries by default, and a tall entry splits within itself — the +rail continues across the break. The one logical rail becomes one fragment per page it +occupies; nothing is repeated because content continued, and a marker is not redrawn on a +continuation page. Under `markerOnRail()` the body keeps the same content-column x and width on +every page it reaches. + +```java +section.addTimeline(timeline -> timeline + .keepTogether() // relocate the whole timeline to a fresh page + .keepEntriesTogether() // never split one entry across pages + .entry(TimelineMarker.dot(8, accent), e -> e.title("Atomic entry"))); +``` + +`keepTogether()` moves the whole timeline to the next page when it does not fit in the +remaining space but would fit on a fresh one — timelines taller than a page still flow. +`keepEntriesTogether()` keeps each entry whole while still allowing breaks between entries. +Same semantics as the section-level controls in the [keep-together recipe](keep-together.md). + +Only an entry's marker-plus-title row is atomic, so `AtomicNodeTooLargeException` is reachable +only in the degenerate case of a single marker row taller than a whole page. + +## Backends + +| | Rail | Content | +|---|---|---| +| **PDF** | ✅ drawn, one fragment per page, beneath the markers | ✅ | +| **PPTX** | ✅ same payload, same per-page fragments | ✅ | +| **DOCX** | ⚠️ omitted | ✅ entries, titles, meta and bodies all export | + +DOCX is a semantic export: it walks the document tree and never consumes the resolved layout +geometry the rail is made of, so the line is absent by construction rather than by defect. The +export does not throw and the timeline's content comes through in full. See the +[backend capability matrix](../architecture/backend-capability-matrix.md). ## Text styles @@ -101,30 +275,11 @@ section.addTimeline(timeline -> timeline .body("General availability."))); ``` -`titleStyle` / `metaStyle` / `bodyStyle` on the timeline set the -defaults for every entry; the two-argument `title(text, style)`, -`meta(text, style)`, and `body(text, style)` (or the matching +`titleStyle` / `metaStyle` / `bodyStyle` on the timeline set the defaults for every entry; the +two-argument `title(text, style)`, `meta(text, style)`, and `body(text, style)` (or the matching `*Style(...)` setters on the entry) override one entry. -## Pagination - -A timeline paginates between entries by default, and a tall entry splits -within itself — the rail continues across the page break. Two opt-in -controls tighten that (both `@since 1.8.0`): - -```java -section.addTimeline(timeline -> timeline - .keepTogether() // relocate the whole timeline to a fresh page - .keepEntriesTogether() // never split one entry across pages - .entry(TimelineMarker.dot(8, accent), e -> e.title("Atomic entry"))); -``` - -`keepTogether()` moves the whole timeline to the next page when it does -not fit in the remaining space but would fit on a fresh page — timelines -taller than a page still flow. `keepEntriesTogether()` keeps each entry -whole while still allowing breaks between entries. Same semantics as -the section-level controls in the -[keep-together recipe](keep-together.md). +--- Runnable demo: [TimelineDemoTest](../../qa/src/test/java/com/demcha/testing/visual/TimelineDemoTest.java) diff --git a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java index a7282ff12..e88f7f52d 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java @@ -184,6 +184,40 @@ public static Path generate() throws Exception { .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, GOLD), e -> e.title("Beta").meta("Mar 2026").body("First external users.")))); + feature(flow, "Timeline — dates beside the axis, markers on the rail", """ + section.addTimeline(t -> t + .markerOnRail() // markers centred on the line + .axisWidth(26) + .leadingColumn(DocumentRowColumn.fixed(56)) // DATE | AXIS | CONTENT + .entry(e -> e.marker(TimelineMarker.dot(6, TEAL)) + .leading(d -> d.addParagraph("2024")) + .title("Discovery").body("The body stays in the content column ...")) + .entry(e -> e.marker(TimelineMarker.circle(16, WHITE, DocumentStroke.of(TEAL, 1.2))) + .leading(d -> d.addParagraph("2025")) + .title("Build").body("An outlined marker breaks the line ...")) + .entry(e -> e.marker(TimelineMarker.square(22, GOLD)) + .leading(d -> d.addParagraph("2026")) + .title("Launch").body("Markers of any size share one rail.")))""", + demo -> demo.addTimeline(t -> t + .markerOnRail() + .axisWidth(26) + .leadingColumn(com.demcha.compose.document.style.DocumentRowColumn.fixed(56)) + .entry(e -> e.marker(com.demcha.compose.document.dsl.TimelineMarker.dot(6, TEAL)) + .leading(d -> d.addParagraph("2024")) + .title("Discovery") + .body("The body stays in the content column, so the rail never runs " + + "through the text.")) + .entry(e -> e.marker(com.demcha.compose.document.dsl.TimelineMarker.circle( + 16, DocumentColor.WHITE, DocumentStroke.of(TEAL, 1.2))) + .leading(d -> d.addParagraph("2025")) + .title("Build") + .body("An outlined marker filled with the page colour breaks the line " + + "cleanly — the rail is drawn beneath it.")) + .entry(e -> e.marker(com.demcha.compose.document.dsl.TimelineMarker.square(22, GOLD)) + .leading(d -> d.addParagraph("2026")) + .title("Launch") + .body("A 6pt dot, a 16pt ring and a 22pt square share one rail x.")))); + feature(flow, "Tables — zebra rows and a totals row", """ section.addTable(t -> t .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto(), DocumentTableColumn.auto()) diff --git a/knowledge/api/authoring.json b/knowledge/api/authoring.json index a7bf52901..21947648e 100644 --- a/knowledge/api/authoring.json +++ b/knowledge/api/authoring.json @@ -22,10 +22,10 @@ "graph-compose-testing:sources" ], "counts": { - "types": 233, - "methods": 2077, - "constants": 230, - "generated": 1111 + "types": 235, + "methods": 2088, + "constants": 233, + "generated": 1114 }, "packages": [ { @@ -15027,6 +15027,29 @@ } ] }, + { + "kind": "method", + "name": "rail", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineBuilder", + "params": [ + { + "type": "Consumer", + "name": "spec" + } + ] + }, + { + "kind": "method", + "name": "markerOnRail", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineBuilder", + "params": [] + }, { "kind": "method", "name": "gutter", @@ -15069,6 +15092,34 @@ } ] }, + { + "kind": "method", + "name": "axisWidth", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineBuilder", + "params": [ + { + "type": "double", + "name": "points" + } + ] + }, + { + "kind": "method", + "name": "leadingColumn", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineBuilder", + "params": [ + { + "type": "DocumentRowColumn", + "name": "column" + } + ] + }, { "kind": "method", "name": "spacing", @@ -15143,6 +15194,20 @@ } ] }, + { + "kind": "method", + "name": "entry", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineBuilder", + "params": [ + { + "type": "Consumer", + "name": "entry" + } + ] + }, { "kind": "method", "name": "keepTogether", @@ -15172,6 +15237,48 @@ ], "artifact": "graph-compose-core", "members": [ + { + "kind": "method", + "name": "marker", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineEntryBuilder", + "params": [ + { + "type": "TimelineMarker", + "name": "marker" + } + ] + }, + { + "kind": "method", + "name": "content", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineEntryBuilder", + "params": [ + { + "type": "Consumer", + "name": "content" + } + ] + }, + { + "kind": "method", + "name": "leading", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineEntryBuilder", + "params": [ + { + "type": "Consumer", + "name": "leading" + } + ] + }, { "kind": "method", "name": "title", @@ -15418,6 +15525,99 @@ "name": "fill" } ] + }, + { + "kind": "method", + "name": "custom", + "static": true, + "origin": "source", + "typeParameters": null, + "returns": "TimelineMarker", + "params": [ + { + "type": "double", + "name": "width" + }, + { + "type": "double", + "name": "height" + }, + { + "type": "Consumer", + "name": "recipe" + } + ] + } + ] + }, + { + "name": "TimelineRailBuilder", + "binaryName": "com.demcha.compose.document.dsl.TimelineRailBuilder", + "kind": "class", + "modifiers": [ + "final" + ], + "artifact": "graph-compose-core", + "members": [ + { + "kind": "method", + "name": "stroke", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineRailBuilder", + "params": [ + { + "type": "DocumentStroke", + "name": "stroke" + } + ] + }, + { + "kind": "method", + "name": "extent", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "TimelineRailBuilder", + "params": [ + { + "type": "TimelineRailExtent", + "name": "extent" + } + ] + } + ] + }, + { + "name": "TimelineRailExtent", + "binaryName": "com.demcha.compose.document.dsl.TimelineRailExtent", + "kind": "enum", + "modifiers": [ + "final" + ], + "artifact": "graph-compose-core", + "members": [ + { + "kind": "constant", + "name": "MARKER_TO_MARKER", + "static": true, + "origin": "generated", + "type": "TimelineRailExtent" + }, + { + "kind": "constant", + "name": "ENTRY_BOUNDS", + "static": true, + "origin": "generated", + "type": "TimelineRailExtent" + }, + { + "kind": "constant", + "name": "TIMELINE_BOUNDS", + "static": true, + "origin": "generated", + "type": "TimelineRailExtent" } ] }, diff --git a/knowledge/api/authoring.md b/knowledge/api/authoring.md index 693bbe7a0..5a00f1060 100644 --- a/knowledge/api/authoring.md +++ b/knowledge/api/authoring.md @@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se **GraphCompose version:** 2.4.0-SNAPSHOT -Types: 233 · methods: 2077 · constants: 230 · compiler-generated members: 1111 +Types: 235 · methods: 2088 · constants: 233 · compiler-generated members: 1114 ## com.demcha.compose @@ -1122,18 +1122,26 @@ Types: 233 · methods: 2077 · constants: 230 · compiler-generated members: 111 ### TimelineBuilder (class) - `TimelineBuilder connector(DocumentColor color, double width)` +- `TimelineBuilder rail(Consumer spec)` +- `TimelineBuilder markerOnRail()` - `TimelineBuilder gutter(double gutter)` - `TimelineBuilder markerGap(double gap)` - `TimelineBuilder markerColumnWeight(double weight)` +- `TimelineBuilder axisWidth(double points)` +- `TimelineBuilder leadingColumn(DocumentRowColumn column)` - `TimelineBuilder spacing(double spacing)` - `TimelineBuilder titleStyle(DocumentTextStyle style)` - `TimelineBuilder metaStyle(DocumentTextStyle style)` - `TimelineBuilder bodyStyle(DocumentTextStyle style)` - `TimelineBuilder entry(TimelineMarker marker, Consumer content)` +- `TimelineBuilder entry(Consumer entry)` - `TimelineBuilder keepTogether()` - `TimelineBuilder keepEntriesTogether()` ### TimelineEntryBuilder (class) +- `TimelineEntryBuilder marker(TimelineMarker marker)` +- `TimelineEntryBuilder content(Consumer content)` +- `TimelineEntryBuilder leading(Consumer leading)` - `TimelineEntryBuilder title(String title)` - `TimelineEntryBuilder title(String title, DocumentTextStyle style)` - `TimelineEntryBuilder titleStyle(DocumentTextStyle style)` @@ -1150,6 +1158,14 @@ Types: 233 · methods: 2077 · constants: 230 · compiler-generated members: 111 - `TimelineMarker circle(double size, DocumentColor fill, DocumentStroke stroke)` - `TimelineMarker numbered(int number, double size, DocumentColor fill, DocumentColor textColor)` - `TimelineMarker square(double size, DocumentColor fill)` +- `TimelineMarker custom(double width, double height, Consumer recipe)` + +### TimelineRailBuilder (class) +- `TimelineRailBuilder stroke(DocumentStroke stroke)` +- `TimelineRailBuilder extent(TimelineRailExtent extent)` + +### TimelineRailExtent (enum) +- constants: `MARKER_TO_MARKER`, `ENTRY_BOUNDS`, `TIMELINE_BOUNDS` ### TocBuilder (class) - `new TocBuilder()` diff --git a/knowledge/api/excluded.json b/knowledge/api/excluded.json index bf81274b3..89037fec6 100644 --- a/knowledge/api/excluded.json +++ b/knowledge/api/excluded.json @@ -3,7 +3,7 @@ "verifiedAgainst": "2.4.0-SNAPSHOT", "generator": "knowledge/tools/api-surface/extract-api.mjs", "note": "Public types and members deliberately kept out of every surface. An exclusion nobody can see is indistinguishable from a bug, so each one records why.", - "count": 173, + "count": 180, "excluded": [ { "binaryName": "com.demcha.compose.document.backend.fixed.pptx.handlers.PptxChromeRenderer", @@ -96,6 +96,20 @@ "artifact": "graph-compose-core", "reason": "package `document.dsl.internal` is implementation detail but carries no package-level @Internal; excluded by name until the annotation is added" }, + { + "binaryName": "com.demcha.compose.document.dsl.TimelineAxisSize$Fixed", + "package": "com.demcha.compose.document.dsl", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "nested type no admitted signature mentions" + }, + { + "binaryName": "com.demcha.compose.document.dsl.TimelineAxisSize$Weight", + "package": "com.demcha.compose.document.dsl", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "nested type no admitted signature mentions" + }, { "binaryName": "com.demcha.compose.document.emoji.EmojiLibrary", "package": "com.demcha.compose.document.emoji", @@ -194,6 +208,20 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout.definitions)" }, + { + "binaryName": "com.demcha.compose.document.layout.definitions.HorizontalBandContentDefinition", + "package": "com.demcha.compose.document.layout.definitions", + "kind": "class", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout.definitions)" + }, + { + "binaryName": "com.demcha.compose.document.layout.definitions.HorizontalBandsDefinition", + "package": "com.demcha.compose.document.layout.definitions", + "kind": "class", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout.definitions)" + }, { "binaryName": "com.demcha.compose.document.layout.definitions.ImageDefinition", "package": "com.demcha.compose.document.layout.definitions", @@ -362,6 +390,20 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout)" }, + { + "binaryName": "com.demcha.compose.document.layout.HorizontalBandContentNode", + "package": "com.demcha.compose.document.layout", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, + { + "binaryName": "com.demcha.compose.document.layout.HorizontalBandsNode", + "package": "com.demcha.compose.document.layout", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, { "binaryName": "com.demcha.compose.document.layout.LayoutAnchorId", "package": "com.demcha.compose.document.layout", @@ -747,6 +789,13 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout)" }, + { + "binaryName": "com.demcha.compose.document.layout.ResolvedHorizontalBand", + "package": "com.demcha.compose.document.layout", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, { "binaryName": "com.demcha.compose.document.layout.ResolvedLayoutAddition", "package": "com.demcha.compose.document.layout", diff --git a/qa/src/test/java/com/demcha/compose/document/api/HorizontalBandTest.java b/qa/src/test/java/com/demcha/compose/document/api/HorizontalBandTest.java new file mode 100644 index 000000000..b40b0fbe6 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/HorizontalBandTest.java @@ -0,0 +1,530 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.RowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.layout.HorizontalBandContentNode; +import com.demcha.compose.document.layout.HorizontalBandsNode; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentRowColumn; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.within; + +/** + * A column resolved by a row, used by content that comes after it. + * + *

A row works out where each column starts and how wide it is — from points, from shares, + * or from a mixture — and then forgets the arithmetic. Content that has to line up with one + * of those columns and is also long enough to cross a page cannot live inside the row: a row + * is laid out on one page and nothing about that is changing here. So the row publishes its + * columns, and a later sibling lays itself out inside one of them while staying an ordinary + * vertical block.

+ * + *

The published band is the slot, before the child in it applies its own margin, so + * it means the same thing whatever the column happened to contain. Everything here is + * asserted against the row's own resolved geometry rather than against numbers computed a + * second way — the point of the mechanism is that there is only one copy of the formula.

+ */ +class HorizontalBandTest { + + // --- the geometry the row resolved ----------------------------------------- + + @Test + void contentTakesTheColumnsXAndWidthFromTheRowThatResolvedIt() { + // Fixed first column: the band is the slot the row gave it, and the content that + // follows starts exactly there and is exactly that wide. + Object key = new Object(); + LayoutGraph graph = document(360, 300, flow -> { + flow.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(80), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("left")); + r.addSection(cell -> cell.addParagraph("right")); + }))); + flow.add(new HorizontalBandContentNode("", key, 1, + section(s -> s.addParagraph("in the second column")))); + }); + + PlacedNode secondColumn = rowColumn(graph, 1); + PlacedNode content = bandContent(graph); + assertThat(content.placementX()) + .as("the column's own x, not the page's") + .isEqualTo(secondColumn.placementX(), within(1e-9)); + assertThat(content.placementX() + content.placementWidth()) + .as("and it ends where the row ends, so the width is the column's") + .isEqualTo(rowRightEdge(graph), within(1e-9)); + } + + @Test + void aWeightedColumnWorksTheSameWayAndIsNotRecomputed() { + // The case a build-time indent cannot express: the column is a share of what the row + // has left, and nobody knows the number until the row is laid out. Read back, it is + // the same number the row used. + Object key = new Object(); + LayoutGraph graph = document(360, 300, flow -> { + flow.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.weight(0.25), DocumentRowColumn.weight(0.75)); + r.addSection(cell -> cell.addParagraph("left")); + r.addSection(cell -> cell.addParagraph("right")); + }))); + flow.add(new HorizontalBandContentNode("", key, 1, + section(s -> s.addParagraph("in the weighted column")))); + }); + + PlacedNode secondColumn = rowColumn(graph, 1); + PlacedNode content = bandContent(graph); + assertThat(content.placementX()).isEqualTo(secondColumn.placementX(), within(1e-9)); + assertThat(content.placementX() + content.placementWidth()) + .isEqualTo(rowRightEdge(graph), within(1e-9)); + assertThat(content.placementWidth()) + .as("the premise: a share of the row, not the whole of it") + .isLessThan(320.0); + } + + @Test + void aMixedRowResolvesEachColumnAndTheThirdIsStillTheThird() { + // Points, a share and a share: the arrangement a leading column produces. Asserted + // against all three of the row's own columns so a mechanism that quietly counted + // slots differently would not agree. + Object key = new Object(); + LayoutGraph graph = document(420, 300, flow -> { + flow.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(60), DocumentRowColumn.weight(0.15), + DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("date")); + r.addSection(cell -> cell.addParagraph("mark")); + r.addSection(cell -> cell.addParagraph("title")); + }))); + flow.add(new HorizontalBandContentNode("", key, 2, + section(s -> s.addParagraph("under the third column")))); + }); + + PlacedNode third = rowColumn(graph, 2); + PlacedNode content = bandContent(graph); + assertThat(content.placementX()).isEqualTo(third.placementX(), within(1e-9)); + assertThat(content.placementX() + content.placementWidth()) + .isEqualTo(rowRightEdge(graph), within(1e-9)); + assertThat(content.placementX()) + .as("and it really is the third: well right of the first two") + .isGreaterThan(rowColumn(graph, 1).placementX()); + } + + // --- pagination ------------------------------------------------------------- + + @Test + void aLongBodyCrossesPagesAndKeepsTheSameColumnOnEveryOne() { + // The whole reason the content is not in the row. Three pages, one x, one width, and + // no complaint that an atomic block does not fit. + Object key = new Object(); + LayoutGraph graph = document(320, 150, flow -> { + flow.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(60), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("mark")); + r.addSection(cell -> cell.addParagraph("title")); + }))); + flow.add(new HorizontalBandContentNode("", key, 1, + section(s -> s.addParagraph(longBody(60))))); + }); + + assertThat(graph.totalPages()).as("the premise: it spans pages").isGreaterThanOrEqualTo(3); + PlacedNode column = rowColumn(graph, 1); + PlacedNode content = bandContent(graph); + assertThat(content.startPage()).isZero(); + assertThat(content.endPage()).as("one block, several pages").isGreaterThanOrEqualTo(2); + assertThat(content.placementX()) + .as("no horizontal drift onto the continuation pages") + .isEqualTo(column.placementX(), within(1e-9)); + assertThat(content.placementX() + content.placementWidth()) + .as("and the width is not reset to the parent's on continuation") + .isEqualTo(rowRightEdge(graph), within(1e-9)); + + // Read off the text itself rather than the wrapper: every line of every page starts + // at the column, which is what a reader would check. + List lines = graph.nodes().stream() + .filter(n -> "ParagraphNode".equals(n.nodeKind())) + .filter(n -> n.placementWidth() > 1.0) + .filter(n -> n.startPage() > 0 || n.placementY() < column.placementY()) + .toList(); + assertThat(lines).isNotEmpty(); + assertThat(lines).allSatisfy(line -> assertThat(line.placementX()) + .isEqualTo(column.placementX(), within(1e-9))); + } + + @Test + void aPageBreakBetweenTheRowAndItsContentDoesNotLoseTheColumn() { + // The row is on one page and the content starts on the next. The column is a + // horizontal fact and pages are a vertical one, so the break is irrelevant — but + // only if the band outlives it, which is the point of storing it for the whole + // compilation rather than for the page it was resolved on. + Object key = new Object(); + LayoutGraph graph = document(320, 150, flow -> { + flow.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(60), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("mark")); + r.addSection(cell -> cell.addParagraph("title")); + }))); + flow.addParagraph(longBody(14)); + flow.add(new HorizontalBandContentNode("", key, 1, section(s -> s.addParagraph("after the break")))); + }); + + PlacedNode column = rowColumn(graph, 1); + PlacedNode content = bandContent(graph); + assertThat(content.startPage()).as("the premise: it starts on a later page").isGreaterThan(0); + assertThat(content.placementX()).isEqualTo(column.placementX(), within(1e-9)); + assertThat(content.placementX() + content.placementWidth()) + .isEqualTo(rowRightEdge(graph), within(1e-9)); + } + + // --- relocation --------------------------------------------------------------- + + @Test + void aKeptTogetherBlockThatRelocatesPublishesItsColumnsOnceAndUsesThem() { + // A block that does not fit in what is left of the page but fits on a fresh one. + // Whether that costs a false "two rows under one identity" depends on something the + // mechanism cannot see from the outside: whether the engine decides to move the + // block before compiling it, or compiles it and then abandons the attempt. Only the + // first is safe for anything a compile records, so it is asserted rather than + // assumed — and the assertion is that this lays out at all. + Object key = new Object(); + LayoutGraph graph = document(320, 200, flow -> { + for (int i = 0; i < 6; i++) { + flow.addParagraph("Filler line " + i + " taking a line of its own on this page."); + } + flow.addSection(block -> { + block.keepTogether(); + block.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(70), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("mark")); + r.addSection(cell -> cell.addParagraph("title")); + }))); + block.add(new HorizontalBandContentNode("", key, 1, + section(s -> s.addParagraph("A body of two lines, kept with its row.")))); + }); + }); + + PlacedNode column = rowColumn(graph, 1); + PlacedNode content = bandContent(graph); + assertThat(content.startPage()) + .as("the premise: the block did move to a fresh page") + .isGreaterThan(0); + assertThat(column.startPage()) + .as("and the row moved with it, which is what kept-together means") + .isEqualTo(content.startPage()); + assertThat(content.placementX()) + .as("the column of the placement that survived, not of an abandoned one") + .isEqualTo(column.placementX(), within(1e-9)); + assertThat(content.placementX() + content.placementWidth()) + .isEqualTo(rowRightEdge(graph), within(1e-9)); + } + + @Test + void theSameRelocationWithSharesInsteadOfPoints() { + // The half that cannot be checked at build time: the columns are shares, so the + // number only exists after the row is laid out — and after it is laid out on the + // page it ended up on. + Object key = new Object(); + LayoutGraph graph = document(320, 200, flow -> { + for (int i = 0; i < 6; i++) { + flow.addParagraph("Filler line " + i + " taking a line of its own on this page."); + } + flow.addSection(block -> { + block.keepTogether(); + block.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.weight(0.3), DocumentRowColumn.weight(0.7)); + r.addSection(cell -> cell.addParagraph("mark")); + r.addSection(cell -> cell.addParagraph("title")); + }))); + block.add(new HorizontalBandContentNode("", key, 1, + section(s -> s.addParagraph("A body of two lines, kept with its row.")))); + }); + }); + + PlacedNode column = rowColumn(graph, 1); + PlacedNode content = bandContent(graph); + assertThat(content.startPage()).isGreaterThan(0); + assertThat(content.placementX()).isEqualTo(column.placementX(), within(1e-9)); + assertThat(content.placementX() + content.placementWidth()) + .isEqualTo(rowRightEdge(graph), within(1e-9)); + } + + @Test + void aDocumentLaidOutMoreThanOncePublishesItsColumnsAfreshEachTime() { + // A page reference makes the whole document a fixed point: it is compiled, the page + // numbers are read off, and it is compiled again until they stop moving. Each of + // those is a compile, and each republishes the same identity — which is only safe + // because what a compile records belongs to that compile and nothing else. + Object key = new Object(); + LayoutGraph graph = document(320, 220, flow -> { + flow.addPageReference("later"); + flow.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(70), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("mark")); + r.addSection(cell -> cell.addParagraph("title")); + }))); + flow.add(new HorizontalBandContentNode("", key, 1, + section(s -> s.addParagraph("A body under the second column.")))); + flow.addParagraph("filler").addParagraph("filler").addParagraph("filler"); + flow.addSection("later", s -> s.addParagraph("The anchor this refers to.")); + }); + + PlacedNode column = rowColumn(graph, 1); + PlacedNode content = bandContent(graph); + assertThat(content.placementX()).isEqualTo(column.placementX(), within(1e-9)); + assertThat(content.placementX() + content.placementWidth()) + .isEqualTo(rowRightEdge(graph), within(1e-9)); + } + + // --- identity ---------------------------------------------------------------- + + @Test + void twoOwnersOnOnePageKeepTheirColumnsApart() { + // Same shape, same slot index, two identities: each consumer gets its own row's + // column. Nothing here is distinguishable by name, path or index — only by object. + Object first = new Object(); + Object second = new Object(); + LayoutGraph graph = document(400, 400, flow -> { + flow.add(new HorizontalBandsNode("", first, row(r -> { + r.columns(DocumentRowColumn.fixed(40), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("a")); + r.addSection(cell -> cell.addParagraph("A")); + }))); + flow.add(new HorizontalBandContentNode("", first, 1, section(s -> s.addParagraph("under A")))); + flow.add(new HorizontalBandsNode("", second, row(r -> { + r.columns(DocumentRowColumn.fixed(180), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("b")); + r.addSection(cell -> cell.addParagraph("B")); + }))); + flow.add(new HorizontalBandContentNode("", second, 1, section(s -> s.addParagraph("under B")))); + }); + + List contents = graph.nodes().stream() + .filter(n -> "HorizontalBandContentNode".equals(n.nodeKind())).toList(); + assertThat(contents).hasSize(2); + assertThat(contents.get(0).placementX()) + .as("the first consumer took the first row's column") + .isEqualTo(rowColumn(graph, 1, 0).placementX(), within(1e-9)); + assertThat(contents.get(1).placementX()) + .as("and the second the second's, 140pt further in") + .isEqualTo(rowColumn(graph, 1, 1).placementX(), within(1e-9)); + assertThat(contents.get(1).placementX() - contents.get(0).placementX()) + .isEqualTo(140.0, within(1e-9)); + } + + @Test + void anOwnerInsideASectionIsStillItsOwnOwner() { + // Nested producers: one at the top level and one inside a padded section. The inner + // pair is offset by the padding, the outer is not, and neither reads the other. + Object outer = new Object(); + Object inner = new Object(); + LayoutGraph graph = document(400, 400, flow -> { + flow.add(new HorizontalBandsNode("", outer, row(r -> { + r.columns(DocumentRowColumn.fixed(50), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("o")); + r.addSection(cell -> cell.addParagraph("O")); + }))); + flow.add(new HorizontalBandContentNode("", outer, 1, section(s -> s.addParagraph("outer")))); + flow.addSection(card -> { + card.padding(DocumentInsets.of(24)); + card.add(new HorizontalBandsNode("", inner, row(r -> { + r.columns(DocumentRowColumn.fixed(50), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("i")); + r.addSection(cell -> cell.addParagraph("I")); + }))); + card.add(new HorizontalBandContentNode("", inner, 1, section(s -> s.addParagraph("inner")))); + }); + }); + + List contents = graph.nodes().stream() + .filter(n -> "HorizontalBandContentNode".equals(n.nodeKind())).toList(); + assertThat(contents).hasSize(2); + assertThat(contents.get(1).placementX() - contents.get(0).placementX()) + .as("the card's padding moved the inner pair together, and only that") + .isEqualTo(24.0, within(1e-9)); + } + + // --- containers ---------------------------------------------------------------- + + @Test + void aContainerInsetMovesTheRowAndTheContentTogether() { + // Padding, margin and a card: the band carries the geometry the row was actually + // laid out with, so an inset that moves the row moves the content by the same + // number. Nothing derives an x from the page. + double plain = bandContentX(section -> { }); + assertThat(bandContentX(section -> section.padding(DocumentInsets.of(16)))) + .as("padding") + .isEqualTo(plain + 16.0, within(1e-9)); + assertThat(bandContentX(section -> section.margin(DocumentInsets.of(12)))) + .as("margin") + .isEqualTo(plain + 12.0, within(1e-9)); + assertThat(bandContentX(section -> section.padding(DocumentInsets.of(10)).cornerRadius(6))) + .as("a card") + .isEqualTo(plain + 10.0, within(1e-9)); + } + + // --- fail closed ------------------------------------------------------------------- + + @Test + void contentThatNamesAColumnNobodyPublishedIsRefused() { + Object never = new Object(); + assertThatIllegalStateException() + .isThrownBy(() -> document(360, 300, flow -> + flow.add(new HorizontalBandContentNode("", never, 0, + section(s -> s.addParagraph("orphan")))))) + .withMessageContaining("never") + .withMessageContaining("published"); + } + + @Test + void contentThatComesBeforeItsRowIsRefused() { + // Not a fallback to the parent's width: the row that would answer has not been laid + // out yet, and guessing would be a layout that looks deliberate and is not. + Object key = new Object(); + assertThatIllegalStateException() + .isThrownBy(() -> document(360, 300, flow -> { + flow.add(new HorizontalBandContentNode("", key, 1, section(s -> s.addParagraph("early")))); + flow.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(60), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("a")); + r.addSection(cell -> cell.addParagraph("b")); + }))); + })) + .withMessageContaining("laid out before it"); + } + + @Test + void twoRowsUnderOneIdentityAreRefused() { + Object key = new Object(); + assertThatIllegalStateException() + .isThrownBy(() -> document(360, 300, flow -> { + for (int i = 0; i < 2; i++) { + flow.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(60), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("a")); + r.addSection(cell -> cell.addParagraph("b")); + }))); + } + })) + .withMessageContaining("under one identity"); + } + + @Test + void aColumnTheRowDoesNotHaveIsRefused() { + Object key = new Object(); + assertThatIllegalStateException() + .isThrownBy(() -> document(360, 300, flow -> { + flow.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(60), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("a")); + r.addSection(cell -> cell.addParagraph("b")); + }))); + flow.add(new HorizontalBandContentNode("", key, 5, section(s -> s.addParagraph("nope")))); + })) + .withMessageContaining("asks for column 5"); + } + + @Test + void wrappingSomethingWithoutColumnsIsRefusedWhereItIsWritten() { + // At construction, not at layout: a wrapper around a paragraph would publish nothing + // and leave the consumer to fail later with a puzzle instead of the mistake. + assertThat(catchIllegalArgument(() -> new HorizontalBandsNode("", new Object(), + new ParagraphBuilder().text("not a row").build()))) + .contains("has none"); + } + + // --- helpers --------------------------------------------------------------------------- + + private static String catchIllegalArgument(Runnable action) { + try { + action.run(); + return ""; + } catch (IllegalArgumentException expected) { + return String.valueOf(expected.getMessage()); + } + } + + private static double bandContentX(Consumer shape) { + Object key = new Object(); + LayoutGraph graph = document(400, 300, flow -> flow.addSection(container -> { + shape.accept(container); + container.add(new HorizontalBandsNode("", key, row(r -> { + r.columns(DocumentRowColumn.fixed(60), DocumentRowColumn.weight(1.0)); + r.addSection(cell -> cell.addParagraph("a")); + r.addSection(cell -> cell.addParagraph("b")); + }))); + container.add(new HorizontalBandContentNode("", key, 1, section(s -> s.addParagraph("body")))); + })); + return bandContent(graph).placementX(); + } + + private static DocumentNode row(Consumer spec) { + RowBuilder builder = new RowBuilder(); + spec.accept(builder); + return builder.build(); + } + + private static DocumentNode section(Consumer spec) { + SectionBuilder builder = new SectionBuilder(); + spec.accept(builder); + return builder.build(); + } + + private static String longBody(int sentences) { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < sentences; i++) { + body.append("Sentence ").append(i).append(" of a body that keeps going. "); + } + return body.toString(); + } + + /** Where the only row's content ends — the right edge every last column shares. */ + private static double rowRightEdge(LayoutGraph graph) { + PlacedNode row = graph.nodes().stream() + .filter(n -> "RowNode".equals(n.nodeKind())) + .findFirst().orElseThrow(); + return row.placementX() + row.placementWidth(); + } + + /** The n-th column of the only row, as the row placed it. */ + private static PlacedNode rowColumn(LayoutGraph graph, int index) { + return rowColumn(graph, index, 0); + } + + private static PlacedNode rowColumn(LayoutGraph graph, int index, int rowOrdinal) { + List columns = graph.nodes().stream() + .filter(n -> n.parentPath() != null && n.parentPath().matches(".*RowNode\\[\\d+]$")) + .filter(n -> n.childIndex() == index) + .toList(); + return columns.get(rowOrdinal); + } + + private static PlacedNode bandContent(LayoutGraph graph) { + return graph.nodes().stream() + .filter(n -> "HorizontalBandContentNode".equals(n.nodeKind())) + .findFirst() + .orElseThrow(() -> new AssertionError("no band content in the graph")); + } + + private static LayoutGraph document(double width, double height, Consumer content) { + try (DocumentSession session = GraphCompose.document() + .pageSize(width, height).margin(DocumentInsets.of(20)).create()) { + PageFlowBuilder flow = session.pageFlow(); + content.accept(flow); + flow.build(); + return session.layoutGraph(); + } catch (RuntimeException failure) { + throw failure; + } catch (Exception failure) { + throw new IllegalStateException("layout failed", failure); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java new file mode 100644 index 000000000..a0f8e30d5 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java @@ -0,0 +1,206 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.TimelineBuilder; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.EllipseNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Where the axis column starts, and what a later phase may and may not read off it. + * + *

Measured, and the answer has two halves. The column's x is steady under markers + * of every size, which is the property a rail needs. Its width is not the column's: + * the section placed in the column shrinks to the marker it holds, so a 6pt dot reports 6pt + * and a 14pt disc 14pt in a column declared at 20. The declared width acts as a cap, not as + * the section's size.

+ * + *

That matters for the phases that derive the rail. {@code axisX + axisWidth * alignment} + * cannot take {@code axisWidth} from the graph — the column's own slot is not a node, and + * the number that is there belongs to the marker. Reading it would put the rail + * back where it is today, drifting with marker size. The marker's own resolved box is the + * thing to anchor on, which is what the resolved-layout seam reports.

+ * + *

Both sizing strategies are asserted, because they resolve by different routes: a + * weight is a share of what the row has left, a fixed width is points. Neither is + * converted into the other — there is no row width at the point where that conversion + * would have to happen — so each has to be shown to hold on its own.

+ */ +class TimelineAxisColumnLayoutTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + + @Test + void aFixedAxisStartsAtTheSameXUnderMarkersOfEverySize() throws Exception { + List axis = axisColumns(t -> t.axisWidth(20)); + + assertThat(axis).hasSize(3); + assertThat(axis.stream().map(PlacedNode::placementX)) + .as("same x under a 6pt dot, a 14pt disc and a 24pt one") + .containsOnly(axis.get(0).placementX()); + } + + @Test + void aWeightedAxisStartsAtTheSameXToo() throws Exception { + List axis = axisColumns(t -> t.markerColumnWeight(0.12)); + + assertThat(axis.stream().map(PlacedNode::placementX)) + .containsOnly(axis.get(0).placementX()); + } + + @Test + void theSectionInTheAxisIsTheWholeColumnAndTheMarkerIsPlacedWithinIt() throws Exception { + // Where the marker sits in the axis follows from its anchor, and the align that + // expresses that fills the column. So the column reports the width it was declared + // with, under markers of every size, and the marker's own box lives one level + // deeper — reported by its anchor, which is what a rail is derived from. It was the + // marker's width here before markers were placed rather than packed left, and a + // rail taken from that number sat at three different x down one timeline. + List widths = axisColumns(t -> t.axisWidth(20)).stream() + .map(PlacedNode::placementWidth).toList(); + + assertThat(widths) + .as("the column's own width, whatever the marker in it") + .containsExactly(20.0, 20.0, 20.0); + } + + @Test + void theDefaultAxisIsAShareOfTheRowAndSoMovesWithThePageWidth() throws Exception { + // The assertion no single-page snapshot can make, and the one that catches the + // conversion this phase forbids. Converting the default weight into points is + // right on exactly one page width: 0.10 resolves to 24pt on the 320pt page the + // layout snapshots use, so replacing the default with Fixed(24) leaves both of + // them green — and moves every timeline on any other width. Here the same + // timeline is laid out twice, and a share has to give two answers. + double narrow = contentColumnX(320); + double wide = contentColumnX(480); + + assertThat(narrow) + .as("a weighted axis is wider on a wider page, so the content starts further in") + .isLessThan(wide); + } + + @Test + void aFixedAxisDoesNotMoveWithThePageWidth() throws Exception { + // The other half of the same fact, and the reason both strategies exist: points + // are points. A caller who wants the markers the same distance from the edge on + // every page asks for this one. + assertThat(contentColumnX(320, t -> t.axisWidth(20))) + .as("same margin, same gutter, same 20pt axis — so the content starts in the same place") + .isEqualTo(contentColumnX(480, t -> t.axisWidth(20)), within(1e-9)); + } + + @Test + void aMarkerOfThreeShapesSitsExactlyWhereAMarkerOfOneDoes() throws Exception { + // The contract a rail will depend on: how a marker is built must not be visible in + // the geometry around it. Both of these declare 16×16; one draws a single ellipse + // and the other draws three stacked ones. + double single = axisAndContent(TimelineMarker.dot(16, INK)); + double composed = axisAndContent(TimelineMarker.custom(16, 16, column -> + column.addLayerStack(stack -> stack + .back(circle(16, INK)) + .center(circle(10, DocumentColor.WHITE)) + .center(circle(4, INK))))); + + assertThat(composed) + .as("three fragments or one, the content beside the marker starts in the same place") + .isEqualTo(single, within(1e-9)); + } + + @Test + void aWeightAndAPointWidthAreNotTheSameNumberAndAreNotTreatedAsOne() throws Exception { + // The reason the two survive to the layout separately: on this page the default + // weight and a 20pt request land in different places. A builder that "helpfully" + // converted a weight into points would have to pick one page width to be right on, + // and would move every timeline already written on any other width. + double weighted = axisColumns(t -> t.markerColumnWeight(0.10)).get(0).placementX(); + double fixed = axisColumns(t -> t.axisWidth(20)).get(0).placementX(); + + assertThat(weighted).isEqualTo(fixed, within(1e-9)); + + // Same x — both columns start at the entry's left edge — but the content beside + // them does not, because the axis they reserve is a different width. + double weightedContent = contentColumns(t -> t.markerColumnWeight(0.10)).get(0).placementX(); + double fixedContent = contentColumns(t -> t.axisWidth(20)).get(0).placementX(); + assertThat(weightedContent).isNotEqualTo(fixedContent); + } + + /** The axis column of each entry, in entry order. */ + private static List axisColumns(Consumer sizing) throws Exception { + return headerColumns(sizing, 0); + } + + /** The content column beside the marker, in entry order. */ + private static List contentColumns(Consumer sizing) throws Exception { + return headerColumns(sizing, 1); + } + + private static List headerColumns(Consumer sizing, int index) throws Exception { + return columnsOf(timeline(sizing), index); + } + + private static List columnsOf(LayoutGraph graph, int index) { + return graph.nodes().stream() + .filter(node -> node.parentPath() != null && node.parentPath().matches(".*RowNode\\[\\d+]$")) + .filter(node -> node.childIndex() == index) + .toList(); + } + + private static EllipseNode circle(double size, DocumentColor fill) { + return new EllipseNode("marker", size, size, fill, null, null, null, null, null); + } + + /** Where the content beside one marker starts, with a fixed axis wide enough to hold it. */ + private static double axisAndContent(TimelineMarker marker) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow().addTimeline(t -> t + .connector(RAIL, 1.5) + .axisWidth(20) + .entry(marker, e -> e.title("Beside"))).build(); + return columnsOf(session.layoutGraph(), 1).get(0).placementX(); + } + } + + /** Where the content beside the marker starts, on a page of the given width. */ + private static double contentColumnX(double pageWidth) throws Exception { + return contentColumnX(pageWidth, t -> { }); + } + + private static double contentColumnX(double pageWidth, Consumer sizing) throws Exception { + return columnsOf(timeline(sizing, pageWidth), 1).get(0).placementX(); + } + + private static LayoutGraph timeline(Consumer sizing) throws Exception { + return timeline(sizing, 320); + } + + private static LayoutGraph timeline(Consumer sizing, double pageWidth) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(pageWidth, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow().addTimeline(t -> { + t.connector(RAIL, 1.5); + sizing.accept(t); + t.entry(TimelineMarker.dot(6, INK), e -> e.title("Small")) + .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), e -> e.title("Medium")) + .entry(TimelineMarker.numbered(3, 24, INK, DocumentColor.WHITE), e -> e.title("Large")); + }).build(); + return session.layoutGraph(); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineBodyColumnTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineBodyColumnTest.java new file mode 100644 index 000000000..7e3d7cd66 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineBodyColumnTest.java @@ -0,0 +1,289 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.TimelineBuilder; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentRowColumn; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Where an entry's body sits once the rail runs through the axis. + * + *

With the rail beside the axis — every timeline written before there was a choice — a + * body spans the entry and clears the line by the gutter, and none of this applies. With + * {@code markerOnRail()} the line moved into the middle of the axis column, and a body + * spanning the entry would be drawn through. So the body is laid out in the content column + * instead:

+ * + *
+ * LEADING | AXIS | CONTENT
+ *         |  ●   | title
+ *         |  │   | body line 1
+ *         |  │   | body line 2
+ *         |  ●   | next entry
+ * 
+ * + *

It is in the column, not in the row: a row is laid out on one page, and an entry has to + * be able to be longer than a page. The column it uses is the one the header row resolved, + * published for it — so a share and a width resolve the same way, and neither is recomputed + * here.

+ */ +class TimelineBodyColumnTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + + @Test + void theBodyStartsAndEndsWhereTheContentColumnDoes() { + // The claim, in the row's own numbers: the body is the content column, not an + // indent that happens to look like it. + LayoutGraph graph = timeline(360, 320, t -> t.markerOnRail().axisWidth(28) + .entry(TimelineMarker.dot(10, INK), e -> e + .title("Title").body("A body long enough to wrap onto a second line here."))); + + PlacedNode contentColumn = contentColumn(graph); + PlacedNode body = body(graph); + assertThat(body.placementX()) + .as("bodyX is the content column's x") + .isEqualTo(contentColumn.placementX(), within(1e-9)); + assertThat(body.placementX() + body.placementWidth()) + .as("and it runs to the same right edge") + .isEqualTo(rowRightEdge(graph), within(1e-9)); + } + + @Test + void theRailIsLeftOfTheBodyAndNoTextCrossesIt() { + // The defect this closes, stated as a comparison rather than as a picture: every + // paragraph the entry draws starts to the right of the line. + LayoutGraph graph = timeline(360, 320, t -> t.markerOnRail().axisWidth(28) + .entry(TimelineMarker.dot(10, INK), e -> e + .title("Title").meta("meta") + .body("A body long enough to wrap onto a second line on this page."))); + + double railX = rails(graph).get(0).x(); + PlacedNode body = body(graph); + assertThat(railX).as("railX < bodyX").isLessThan(body.placementX()); + + assertThat(graph.fragments().stream() + .filter(f -> f.payload() != null + && f.payload().getClass().getSimpleName().contains("Paragraph")) + .toList()) + .as("no text of the entry is drawn across the rail") + .allSatisfy(text -> assertThat(text.x()).isGreaterThan(railX)); + } + + @Test + void aFixedAxisAndAWeightedOnePutTheBodyInTheSamePlaceRelativeToTheirColumns() { + // The reason this goes through the resolved column rather than through points: a + // share is not a number until the row is laid out, and both have to work. + for (Consumer axis : List.>of( + t -> t.axisWidth(28), t -> t.markerColumnWeight(0.18))) { + LayoutGraph graph = timeline(360, 320, t -> { + t.markerOnRail(); + axis.accept(t); + t.entry(TimelineMarker.dot(10, INK), e -> e.title("Title").body("A body that wraps.")); + }); + + assertThat(body(graph).placementX()) + .as("the body is the content column, whichever way the axis was sized") + .isEqualTo(contentColumn(graph).placementX(), within(1e-9)); + assertThat(rails(graph).get(0).x()).isLessThan(body(graph).placementX()); + } + } + + @Test + void aLeadingColumnMovesTheBodyWithTheAxisAndNotPastIt() { + // Three columns now, and the body belongs to the third. The date column is to the + // left of the rail, the body to the right of it. + LayoutGraph graph = timeline(420, 320, t -> t.markerOnRail().axisWidth(28) + .leadingColumn(DocumentRowColumn.fixed(60)) + .entry(e -> e.marker(TimelineMarker.dot(10, INK)) + .leading(d -> d.addParagraph("2023")) + .title("Title").body("A body that wraps onto a second line."))); + + double railX = rails(graph).get(0).x(); + PlacedNode body = body(graph); + assertThat(body.placementX()).isEqualTo(contentColumn(graph).placementX(), within(1e-9)); + assertThat(railX).isLessThan(body.placementX()); + assertThat(body.placementX()) + .as("and well right of the date column it is not in") + .isGreaterThan(60.0); + } + + @Test + void aBodyLongerThanAPageStillSplitsAndKeepsItsColumn() { + // What the row could not do. Two pages at least, one x, one width, and no complaint + // that an atomic block does not fit. + LayoutGraph graph = timeline(320, 170, t -> t.markerOnRail().axisWidth(24) + .entry(TimelineMarker.dot(10, INK), e -> e.title("Runs on").body(longBody(40)))); + + PlacedNode body = body(graph); + assertThat(graph.totalPages()).as("the premise: it crosses pages").isGreaterThanOrEqualTo(2); + assertThat(body.startPage()).isZero(); + assertThat(body.endPage()).as("one body, several pages").isGreaterThanOrEqualTo(1); + assertThat(body.placementX()) + .as("continuation pages keep the same bodyX") + .isEqualTo(contentColumn(graph).placementX(), within(1e-9)); + assertThat(body.placementX() + body.placementWidth()) + .as("and the same bodyWidth") + .isEqualTo(rowRightEdge(graph), within(1e-9)); + + double railX = rails(graph).get(0).x(); + assertThat(rails(graph).stream().map(PlacedFragment::x).distinct()) + .as("and the rail is still one line") + .hasSize(1); + assertThat(railX).isLessThan(body.placementX()); + } + + @Test + void aTimelineThatDoesNotAskForTheRailKeepsItsBodyWhereItAlwaysWas() { + // The other half, and the one that must not move: no markerOnRail, no published + // column, body spanning the entry and clearing the rail by the gutter. + LayoutGraph graph = timeline(360, 320, t -> t.gutter(8) + .entry(TimelineMarker.dot(10, INK), e -> e.title("Title").body("A body that wraps."))); + + assertThat(graph.nodes()).as("nothing publishes and nothing consumes") + .noneSatisfy(n -> assertThat(n.nodeKind()).contains("Band")); + + double railX = rails(graph).get(0).x(); + PlacedFragment bodyText = graph.fragments().stream() + .filter(f -> f.payload() != null + && f.payload().getClass().getSimpleName().contains("Paragraph")) + .reduce((first, second) -> second) + .orElseThrow(); + assertThat(bodyText.x()) + .as("the body still starts one gutter right of the rail, as it always has") + .isEqualTo(railX + 8.0, within(1e-9)); + } + + @Test + void anEntryKeptTogetherAndMovedToAFreshPageStillHasItsBodyInTheColumn() { + // The relocation case. An entry that will not fit in what is left of the page moves + // whole, and the column its body uses is the one the row resolved where it ended + // up — not one left over from a placement that was never used. + LayoutGraph graph = document(320, 220, flow -> { + for (int i = 0; i < 7; i++) { + flow.addParagraph("Filler line " + i + " taking a line of its own on this page."); + } + flow.addTimeline(t -> t.connector(RAIL, 1.5).markerOnRail().axisWidth(24) + .keepEntriesTogether() + .entry(TimelineMarker.dot(10, INK), e -> e + .title("Moved whole") + .body("A body of a couple of lines, kept with its marker row."))); + }); + + PlacedNode body = body(graph); + PlacedNode contentColumn = contentColumn(graph); + assertThat(body.startPage()).as("the premise: it moved").isGreaterThan(0); + assertThat(contentColumn.startPage()) + .as("marker row and body moved together") + .isEqualTo(body.startPage()); + assertThat(body.placementX()) + .as("bodyX is the content column of the placement that survived") + .isEqualTo(contentColumn.placementX(), within(1e-9)); + assertThat(body.placementX() + body.placementWidth()) + .isEqualTo(rowRightEdge(graph), within(1e-9)); + assertThat(rails(graph).get(0).x()).as("railX < bodyX").isLessThan(body.placementX()); + } + + @Test + void aWholeTimelineKeptTogetherAndMovedKeepsTheSameInvariants() { + // The same question one level up: the timeline as a unit, with two entries, moved to + // a fresh page. Two rows, two identities, and each body in its own row's column. + LayoutGraph graph = document(320, 240, flow -> { + for (int i = 0; i < 7; i++) { + flow.addParagraph("Filler line " + i + " taking a line of its own on this page."); + } + flow.addTimeline(t -> t.connector(RAIL, 1.5).markerOnRail().axisWidth(24) + .keepTogether() + .entry(TimelineMarker.dot(10, INK), e -> e.title("First").body("A short body.")) + .entry(TimelineMarker.dot(10, INK), e -> e.title("Second").body("Another short body."))); + }); + + List bodies = graph.nodes().stream() + .filter(n -> "HorizontalBandContentNode".equals(n.nodeKind())).toList(); + List columns = graph.nodes().stream() + .filter(n -> n.parentPath() != null && n.parentPath().matches(".*RowNode\\[\\d+]$")) + .filter(n -> n.childIndex() == 1) + .toList(); + + assertThat(bodies).hasSize(2); + assertThat(columns).hasSize(2); + assertThat(bodies.get(0).startPage()).as("the premise: the timeline moved").isGreaterThan(0); + for (int i = 0; i < 2; i++) { + assertThat(bodies.get(i).placementX()) + .as("entry %d: bodyX is its own row's content column", i) + .isEqualTo(columns.get(i).placementX(), within(1e-9)); + assertThat(rails(graph).get(0).x()).isLessThan(bodies.get(i).placementX()); + } + } + + // --- helpers --------------------------------------------------------------- + + private static PlacedNode body(LayoutGraph graph) { + return graph.nodes().stream() + .filter(n -> "HorizontalBandContentNode".equals(n.nodeKind())) + .findFirst() + .orElseThrow(() -> new AssertionError("the body is not in a published column")); + } + + /** The content column of the first entry's header row, as the row placed it. */ + private static PlacedNode contentColumn(LayoutGraph graph) { + List columns = graph.nodes().stream() + .filter(n -> n.parentPath() != null && n.parentPath().matches(".*RowNode\\[\\d+]$")) + .toList(); + return columns.get(columns.size() - 1); + } + + private static double rowRightEdge(LayoutGraph graph) { + PlacedNode row = graph.nodes().stream() + .filter(n -> "RowNode".equals(n.nodeKind())) + .findFirst().orElseThrow(); + return row.placementX() + row.placementWidth(); + } + + private static List rails(LayoutGraph graph) { + return graph.fragments().stream().filter(f -> "@timeline-rail".equals(f.path())).toList(); + } + + private static String longBody(int sentences) { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < sentences; i++) { + body.append("Sentence ").append(i).append(" of a body that keeps going. "); + } + return body.toString(); + } + + private static LayoutGraph timeline(double width, double height, Consumer spec) { + return document(width, height, flow -> flow.addTimeline(t -> { + t.connector(RAIL, 1.5); + spec.accept(t); + })); + } + + private static LayoutGraph document(double width, double height, Consumer content) { + try (DocumentSession session = GraphCompose.document() + .pageSize(width, height).margin(DocumentInsets.of(20)).create()) { + PageFlowBuilder flow = session.pageFlow(); + content.accept(flow); + flow.build(); + return session.layoutGraph(); + } catch (RuntimeException failure) { + throw failure; + } catch (Exception failure) { + throw new IllegalStateException("layout failed", failure); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineCompatibilityTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineCompatibilityTest.java new file mode 100644 index 000000000..2b70f78a8 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineCompatibilityTest.java @@ -0,0 +1,475 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.TimelineBuilder; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.dsl.TimelineRailExtent; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.ResolvedLayoutAnchor; +import com.demcha.compose.document.layout.ResolvedLayoutMetadata; +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.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.within; + +/** + * What has to stay true for code written before the rail moved. + * + *

The rail used to be a left border on every entry section and is now one line + * contributed after layout. That is invisible to a caller only if the geometry is + * identical, the page count is identical, and the places a timeline could already be put + * still take one — so those are the claims here, rather than anything about the new API.

+ * + *

The measurement behind them was a cross-branch one and cannot live in a test: the same + * eleven documents, written against the pre-rework builder alone, laid out on this branch and + * on the base, and the two graphs diffed. Every placed node matched to the digit; the 31 + * per-entry borders became 16 rail fragments covering the same span on the same x, worst + * |Δx| 0 and worst |Δy| 1.4e-14 over sixteen page-instances. What remains here is the part + * that can be re-checked on one branch: that those invariants still hold.

+ */ +class TimelineCompatibilityTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + + // --- the pre-rework builder ------------------------------------------------ + + @Test + void everyMethodTheOldBuilderHadStillCompilesAndStillLaysOut() { + // A source-compatibility test, and the assertion is that it compiles: every method + // TimelineBuilder and TimelineEntryBuilder published before the rework, called in + // one expression. Adding an overload is how a source break usually arrives — the + // call that used to resolve stops resolving — so both entry(...) spellings are here + // together, the marker-first one and the one that names its marker inside. + LayoutGraph graph = timeline(340, 340, t -> t + .connector(RAIL, 2.0) + .gutter(10) + .markerGap(8) + .markerColumnWeight(0.18) + .spacing(14) + .titleStyle(DocumentTextStyle.builder().size(12).build()) + .metaStyle(DocumentTextStyle.builder().size(7).build()) + .bodyStyle(DocumentTextStyle.builder().size(9).build()) + .keepTogether() + .keepEntriesTogether() + .entry(TimelineMarker.dot(8, INK), e -> e + .title("Title").title("Title", DocumentTextStyle.builder().size(11).build()) + .titleStyle(DocumentTextStyle.builder().size(11).build()) + .meta("Meta").meta("Meta", DocumentTextStyle.builder().size(7).build()) + .metaStyle(DocumentTextStyle.builder().size(7).build()) + .body("Body").body("Body", DocumentTextStyle.builder().size(9).build()) + .bodyStyle(DocumentTextStyle.builder().size(9).build()) + .add(extra -> extra.addParagraph("Added"))) + .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), + e -> e.title("Second")) + .entry(TimelineMarker.circle(12, DocumentColor.WHITE, DocumentStroke.of(INK, 1.0)), + e -> e.title("Third")) + .entry(TimelineMarker.square(10, INK), e -> e.title("Fourth"))); + + assertThat(rails(graph)).as("and the rail such a timeline draws is still one line").hasSize(1); + } + + @Test + void aTimelineThatAsksForNothingNewKeepsTheMarkersWhereTheyWere() { + // The default, stated as the thing an old document depends on: markers packed to + // the left of their column and the rail one gutter further left. Every new choice + // is opt-in, so a builder that names none of them must land here. + LayoutGraph graph = timeline(340, 300, t -> t.gutter(9) + .entry(TimelineMarker.dot(6, INK), e -> e.title("Small").body("Body.")) + .entry(TimelineMarker.square(22, INK), e -> e.title("Large").body("Body."))); + + List markers = markers(graph); + assertThat(markers.get(0).x()) + .as("a 6pt and a 22pt marker still share a left edge, not a centre") + .isEqualTo(markers.get(1).x(), within(1e-9)); + assertThat(rails(graph).get(0).x()) + .as("and the rail is that edge less the gutter") + .isEqualTo(markers.get(0).x() - 9.0, within(1e-9)); + } + + // --- pagination ------------------------------------------------------------- + + @Test + void thePagesALegacyTimelineNeedsAreThePagesItTakes() { + // Page counts, pinned. The rail is contributed after layout and may not add or + // remove a page; these three shapes are the ones where it would show — a body that + // runs off the page, an entry taller than a page, and a timeline that starts near + // the bottom of one. + assertThat(pagesOf(paginatedScene())).as("body across a break").isEqualTo(2); + assertThat(pagesOf(veryTallScene())).as("an entry taller than four pages").isEqualTo(5); + assertThat(pagesOf(nearBottomScene())).as("started low, still one page").isEqualTo(1); + } + + @Test + void everyRailFragmentBelongsToOnePageAndStaysInsideIt() { + // No duplicates, none missing, none reaching into a margin. Checked on the shapes + // that cross boundaries, where a rail assembled from anchors could be handed a box + // resolved on a different page. + for (Map.Entry scene : Map.of( + "paginated", paginatedScene(), + "very tall", veryTallScene(), + "near bottom", nearBottomScene()).entrySet()) { + LayoutGraph graph = scene.getValue(); + List rails = rails(graph); + List occupied = entries(graph).stream() + .map(ResolvedLayoutAnchor::pageIndex).distinct().sorted().toList(); + + assertThat(rails.stream().map(PlacedFragment::pageIndex).sorted()) + .as("%s: one rail on each page the entries occupy, and on no other", scene.getKey()) + .containsExactlyElementsOf(occupied); + assertThat(rails.stream().map(PlacedFragment::x).distinct()) + .as("%s: continuation pages do not shift it sideways", scene.getKey()) + .hasSize(1); + + double height = graph.canvas().height(); + assertThat(rails).allSatisfy(rail -> { + assertThat(rail.y()).as("%s: inside the bottom margin", scene.getKey()) + .isGreaterThanOrEqualTo(20.0 - 1e-9); + assertThat(rail.y() + rail.height()).as("%s: inside the top margin", scene.getKey()) + .isLessThanOrEqualTo(height - 20.0 + 1e-9); + }); + } + } + + @Test + void nothingInTheDocumentMovesBecauseTheRailChoseADifferentExtent() { + // The rail is drawing, not layout. Two graphs of one document, ENTRY_BOUNDS and + // MARKER_TO_MARKER: the rail differs and every anchor, and the page count, do not. + LayoutGraph bounded = paginatedScene(TimelineRailExtent.ENTRY_BOUNDS); + LayoutGraph trimmed = paginatedScene(TimelineRailExtent.MARKER_TO_MARKER); + + assertThat(trimmed.totalPages()).isEqualTo(bounded.totalPages()); + assertThat(box(markers(trimmed))).as("markers").isEqualTo(box(markers(bounded))); + assertThat(box(entries(trimmed))).as("entries").isEqualTo(box(entries(bounded))); + assertThat(rails(trimmed).get(0).height()) + .as("the premise: the rail itself did change") + .isNotEqualTo(rails(bounded).get(0).height()); + } + + // --- where a timeline can be put --------------------------------------------- + + @Test + void aTimelineInsideARowCellIsRefusedTheWayItAlwaysWas() { + // Not a regression and not a new limit: a timeline lays its entries out in rows, a + // row cannot hold a row, and that was true before any of this. It is pinned because + // the marker is wrapped in more levels now, and a wrapper that turned into a row + // would change the message rather than the outcome. + assertThatIllegalStateException() + .isThrownBy(() -> { + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 300).margin(DocumentInsets.of(20)).create()) { + session.pageFlow().addRow(row -> { + row.columns(DocumentRowColumn.fixed(120), DocumentRowColumn.weight(1.0)); + row.addSection(cell -> cell.addParagraph("Beside it")); + row.addSection(cell -> cell.addTimeline(t -> t.connector(RAIL, 1.5) + .entry(TimelineMarker.dot(8, INK), e -> e.title("In a cell")))); + }).build(); + session.layoutGraph(); + } + }) + .withMessageContaining("cannot contain a nested horizontal row"); + } + + @Test + void aTimelineMeasuresToTheContainerItIsInAndNotToThePage() { + // Width measurement, which is where a feature that reads resolved geometry goes + // wrong quietly: the rail comes from the marker's resolved box, so a container that + // narrows the timeline has to move the rail with it. A page-width assumption + // anywhere would leave the rail behind in exactly these two containers. + double onThePage = rails(timeline(360, 300, TimelineCompatibilityTest::plainEntries)).get(0).x(); + + double padded; + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 300).margin(DocumentInsets.of(20)).create()) { + session.pageFlow().addSection(section -> { + section.padding(DocumentInsets.of(16)); + section.addTimeline(t -> { + t.connector(RAIL, 1.5); + plainEntries(t); + }); + }).build(); + padded = rails(session.layoutGraph()).get(0).x(); + } + + assertThat(padded) + .as("sixteen points of padding move the whole timeline sixteen points in") + .isEqualTo(onThePage + 16.0, within(1e-9)); + + double withMargin; + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 300).margin(DocumentInsets.of(20)).create()) { + session.pageFlow().addSection(section -> { + section.margin(DocumentInsets.of(12)); + section.addTimeline(t -> { + t.connector(RAIL, 1.5); + plainEntries(t); + }); + }).build(); + withMargin = rails(session.layoutGraph()).get(0).x(); + } + + assertThat(withMargin) + .as("and a margin moves it just as padding does") + .isEqualTo(onThePage + 12.0, within(1e-9)); + + double inACard; + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 300).margin(DocumentInsets.of(20)).create()) { + session.pageFlow().addSection(outer -> { + outer.padding(DocumentInsets.of(10)); + outer.addSection(card -> { + card.padding(DocumentInsets.of(8)).cornerRadius(6); + card.addTimeline(t -> { + t.connector(RAIL, 1.5); + plainEntries(t); + }); + }); + }).build(); + inACard = rails(session.layoutGraph()).get(0).x(); + } + + assertThat(inACard) + .as("and a card inside a section adds both insets, with nothing lost between them") + .isEqualTo(onThePage + 18.0, within(1e-9)); + } + + // --- markers ------------------------------------------------------------------- + + @Test + void everyKindOfMarkerOfOneSizeResolvesToOneBox() { + // The rail reads a box, never a shape. Five constructions of a 16pt marker — one of + // them not a single node — and one answer, so nothing downstream can be depending on + // a marker being an ellipse. The box is the one the recipe draws: a marker that + // declares a size and draws another reports what it drew, so these are written to + // draw what they declare, which is what every factory here does. + for (Map.Entry marker : Map.of( + "dot", TimelineMarker.dot(16, INK), + "square", TimelineMarker.square(16, INK), + "numbered", TimelineMarker.numbered(7, 16, INK, DocumentColor.WHITE), + "outlined circle", TimelineMarker.circle(16, DocumentColor.WHITE, DocumentStroke.of(INK, 1.5)), + "composed", TimelineMarker.custom(16, 16, column -> column + .addLayerStack(stack -> stack + .back(circleNode(16, INK)) + .center(circleNode(9, DocumentColor.WHITE))))).entrySet()) { + LayoutGraph graph = timeline(320, 260, t -> t.markerOnRail().axisWidth(24) + .entry(marker.getValue(), e -> e.title("Marker").body("Body."))); + ResolvedLayoutAnchor anchor = markers(graph).get(0); + + assertThat(anchor.width()).as("%s: width", marker.getKey()).isEqualTo(16.0, within(1e-9)); + assertThat(anchor.height()).as("%s: height", marker.getKey()).isEqualTo(16.0, within(1e-9)); + assertThat(rails(graph).get(0).x()) + .as("%s: and the rail through its centre", marker.getKey()) + .isEqualTo(anchor.pointX(0.5), within(1e-9)); + } + } + + @Test + void howThickAMarkersOutlineIsDoesNotMoveAnything() { + // A stroke is painted about the shape's edge, so a thick one covers more of the + // page than a thin one while declaring the same box. The declared box is what the + // timeline reserves and what the anchor reports, and neither may follow the ink. + LayoutGraph thin = timeline(320, 260, t -> t.markerOnRail().axisWidth(24) + .entry(TimelineMarker.circle(14, DocumentColor.WHITE, DocumentStroke.of(INK, 0.5)), + e -> e.title("Thin").body("Body."))); + LayoutGraph thick = timeline(320, 260, t -> t.markerOnRail().axisWidth(24) + .entry(TimelineMarker.circle(14, DocumentColor.WHITE, DocumentStroke.of(INK, 4.0)), + e -> e.title("Thick").body("Body."))); + + assertThat(box(markers(thick))).as("same box, thin outline or thick").isEqualTo(box(markers(thin))); + assertThat(rails(thick).get(0).x()).as("same rail") + .isEqualTo(rails(thin).get(0).x(), within(1e-9)); + } + + // --- paint order ----------------------------------------------------------------- + + @Test + void theRailIsPaintedBeforeTheEntrysTextAndNotOnlyBeforeItsMarkers() { + // UNDER_BODY means under the body, and the body of a timeline is mostly text. Draw + // order is list order in this engine, so the whole contract is an index comparison: + // every rail fragment precedes every paragraph the timeline draws. + LayoutGraph graph = timeline(320, 300, t -> t.markerOnRail().axisWidth(24) + .entry(TimelineMarker.dot(10, INK), e -> e.title("First").body("A body.")) + .entry(TimelineMarker.dot(10, INK), e -> e.title("Second").body("Another body."))); + + int lastRail = -1; + int firstPaint = Integer.MAX_VALUE; + for (int i = 0; i < graph.fragments().size(); i++) { + PlacedFragment fragment = graph.fragments().get(i); + String kind = fragment.payload() == null ? "" : fragment.payload().getClass().getSimpleName(); + if ("@timeline-rail".equals(fragment.path())) { + lastRail = Math.max(lastRail, i); + } else if (kind.contains("Paragraph") || kind.contains("Ellipse")) { + firstPaint = Math.min(firstPaint, i); + } + } + assertThat(lastRail).as("there is a rail at all").isNotEqualTo(-1); + assertThat(firstPaint).as("and something painted over it").isNotEqualTo(Integer.MAX_VALUE); + assertThat(lastRail) + .as("every rail fragment comes first, so nothing of the entry is hidden by it") + .isLessThan(firstPaint); + } + + @Test + void aRailInsideAFilledPanelIsStillOnThePage() throws Exception { + // A rail is drawn under the body, and it used to be drawn under the *document's* + // body: first in the fragment list, before everything. That is beneath the fill of + // whatever the timeline is inside, so a timeline in a card lost its rail — present + // in the graph, absent from the page, and invisible to every geometry assertion + // there is. The only instrument that can see it is a rendered pixel. + assertThat(railPixelsPainted(section -> { })) + .as("the control: with nothing over it the rail paints") + .isGreaterThan(0); + assertThat(railPixelsPainted(section -> section.softPanel(DocumentColor.WHITE, 8, 14))) + .as("and a white panel around it does not swallow it") + .isGreaterThan(0); + assertThat(railPixelsPainted(section -> section.softPanel(DocumentColor.rgb(245, 245, 250), 8, 14))) + .as("nor a tinted one") + .isGreaterThan(0); + } + + /** How many pixels of exactly the rail's colour survive to the rendered page. */ + private static int railPixelsPainted(Consumer panel) throws Exception { + byte[] pdf; + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 220).margin(DocumentInsets.of(20)).create()) { + session.pageFlow().addSection(section -> { + panel.accept(section); + section.addTimeline(t -> t.connector(RAIL, 1.5) + .entry(TimelineMarker.numbered(1, 14, INK, DocumentColor.WHITE), + e -> e.title("Kickoff").meta("Jan 2026").body("Scope agreed.")) + .entry(TimelineMarker.dot(8, INK), + e -> e.title("Beta").meta("Mar 2026").body("First external users."))); + }).build(); + pdf = session.toPdfBytes(); + } + try (PDDocument document = Loader.loadPDF(pdf)) { + BufferedImage page = new PDFRenderer(document).renderImageWithDPI(0, 72); + int painted = 0; + for (int y = 0; y < page.getHeight(); y++) { + for (int x = 0; x < page.getWidth(); x++) { + int pixel = page.getRGB(x, y); + if (((pixel >> 16) & 0xff) == 150 && ((pixel >> 8) & 0xff) == 158 + && (pixel & 0xff) == 172) { + painted++; + } + } + } + return painted; + } + } + + // --- scenes ----------------------------------------------------------------------- + + private static void plainEntries(TimelineBuilder t) { + t.entry(TimelineMarker.dot(8, INK), e -> e.title("First").body("Body one.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Body two.")); + } + + private static LayoutGraph paginatedScene() { + return paginatedScene(null); + } + + private static LayoutGraph paginatedScene(TimelineRailExtent extent) { + return timeline(320, 170, t -> { + t.spacing(14); + if (extent != null) { + t.rail(rail -> rail.extent(extent)); + } + t.entry(TimelineMarker.dot(8, INK), e -> e.title("Runs on").body(longBody(30))) + .entry(TimelineMarker.dot(8, INK), e -> e.title("And ends here")); + }); + } + + private static LayoutGraph veryTallScene() { + return timeline(300, 140, t -> t + .entry(TimelineMarker.dot(8, INK), e -> e.title("Enormous").body(longBody(60)))); + } + + private static LayoutGraph nearBottomScene() { + return document(320, 200, flow -> { + flow.addParagraph("Filler one.").addParagraph("Filler two.").addParagraph("Filler three.") + .addParagraph("Filler four.").addParagraph("Filler five.").addParagraph("Filler six."); + flow.addTimeline(t -> t.connector(RAIL, 1.5) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Starts low").body("Body one.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Body two."))); + }); + } + + // --- helpers ------------------------------------------------------------------------ + + private static String box(List anchors) { + StringBuilder out = new StringBuilder(); + for (ResolvedLayoutAnchor anchor : anchors) { + out.append(String.format(java.util.Locale.ROOT, "p%d[%.9f,%.9f,%.9f,%.9f] ", + anchor.pageIndex(), anchor.x(), anchor.y(), anchor.width(), anchor.height())); + } + return out.toString(); + } + + private static com.demcha.compose.document.node.EllipseNode circleNode(double size, DocumentColor fill) { + return new com.demcha.compose.document.node.EllipseNode( + "marker", size, size, fill, null, null, null, null, null); + } + + private static String longBody(int sentences) { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < sentences; i++) { + body.append("Sentence ").append(i).append(" of a body that keeps going. "); + } + return body.toString(); + } + + private static int pagesOf(LayoutGraph graph) { + return graph.totalPages(); + } + + private static List rails(LayoutGraph graph) { + return graph.fragments().stream().filter(f -> "@timeline-rail".equals(f.path())).toList(); + } + + private static List entries(LayoutGraph graph) { + return ResolvedLayoutMetadata.from(graph).anchors().stream() + .filter(a -> "ENTRY".equals(a.id().kind().toString())).toList(); + } + + private static List markers(LayoutGraph graph) { + return ResolvedLayoutMetadata.from(graph).anchors().stream() + .filter(a -> "MARKER".equals(a.id().kind().toString())).toList(); + } + + private static LayoutGraph timeline(double width, double height, Consumer spec) { + return document(width, height, flow -> flow.addTimeline(t -> { + t.connector(RAIL, 1.5); + spec.accept(t); + })); + } + + private static LayoutGraph document(double width, double height, Consumer content) { + try (DocumentSession session = GraphCompose.document() + .pageSize(width, height).margin(DocumentInsets.of(20)).create()) { + PageFlowBuilder flow = session.pageFlow(); + content.accept(flow); + flow.build(); + return session.layoutGraph(); + } catch (Exception failure) { + throw new IllegalStateException("layout failed", failure); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineEntrySplitProbeTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineEntrySplitProbeTest.java new file mode 100644 index 000000000..34db1e548 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineEntrySplitProbeTest.java @@ -0,0 +1,58 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +/** + * PHASE 0b SPIKE — throwaway. Delete or promote before the PR. + * + *

No horizontal composite flows across pages: {@code Axis.HORIZONTAL} routes to + * {@code compileHorizontalRow} and {@code Axis.STACK} to the stacked compiler, both + * atomic; only the vertical path reaches {@code compileComposite}, which flows.

+ * + *

So a single three-column container cannot hold splittable content. The question + * this probe answers is whether the shape already in use works instead: an entry that is + * a vertical section (flows) whose marker sits in an atomic header row, with the body as + * siblings underneath. If the entry section spans two pages while its header row stays + * whole on one, the structure is viable and the rework does not need a new container.

+ */ +class TimelineEntrySplitProbeTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 60, 160); + + @Test + void doesOneLongEntryContinueOntoTheNextPage() throws Exception { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < 40; i++) { + body.append("Body sentence number ").append(i) + .append(" carrying enough words to take a whole line of its own. "); + } + + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 160) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .entry(TimelineMarker.dot(8, INK), e -> e + .title("Senior Engineer") + .meta("2023 — Present") + .body(body.toString()))) + .build(); + + LayoutGraph graph = session.layoutGraph(); + System.out.println("PROBE pages=" + graph.totalPages()); + for (PlacedNode n : graph.nodes()) { + System.out.printf("PROBE %-14s p%d..%d h=%7.2f%n", + n.nodeKind(), n.startPage(), n.endPage(), n.placementHeight()); + } + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineLayoutSnapshotTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineLayoutSnapshotTest.java new file mode 100644 index 000000000..ea725e37d --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineLayoutSnapshotTest.java @@ -0,0 +1,82 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.testing.layout.LayoutSnapshotAssertions; +import org.junit.jupiter.api.Test; + +/** + * Freezes the geometry the timeline DSL renders today, exactly. + * + *

The rail is about to stop being a per-entry left border and become one logical + * axis anchored to resolved markers. Nothing pinned the current output: the DSL had + * three node-level tests and a blank-page pixel smoke, and the {@code timeline_minimal} + * baselines in this repository belong to the CV preset, which renders through + * {@code TimelineAxisWidget} and never calls {@code addTimeline}.

+ * + *

A node-level test and an exact snapshot answer different questions and the rework + * needs both: the node test says the builder still sets the gutter, the snapshot says + * the gutter still lands where it did. A 3 pt drift passes the first and fails the + * second.

+ */ +class TimelineLayoutSnapshotTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + + @Test + void classicTimelineGeometryIsPinned() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 260) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(14) + .entry(TimelineMarker.dot(8, INK), e -> e + .title("Senior Engineer") + .meta("2023 - Present") + .body("Led the layout engine rewrite.")) + .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), e -> e + .title("Engineer") + .meta("2021 - 2023") + .body("Shipped the pagination compiler.")) + .entry(TimelineMarker.square(8, INK), e -> e + .title("Junior Engineer") + .meta("2019 - 2021"))) + .build(); + + LayoutSnapshotAssertions.assertMatches(session, "document/timeline_classic"); + } + } + + @Test + void aTimelineThatCrossesAPageBoundaryIsPinnedToo() throws Exception { + // The rail's page behaviour is the part the rework is most likely to move, so + // the freeze has to cover a timeline that actually paginates — today three + // separate borders happen to abut across the break. + StringBuilder body = new StringBuilder(); + for (int i = 0; i < 12; i++) { + body.append("Sentence ").append(i).append(" of a body long enough to run on. "); + } + + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 170) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .entry(TimelineMarker.dot(8, INK), e -> e + .title("First").meta("2023").body(body.toString())) + .entry(TimelineMarker.dot(8, INK), e -> e + .title("Second").meta("2021").body("Short."))) + .build(); + + LayoutSnapshotAssertions.assertMatches(session, "document/timeline_paginated"); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineLeadingColumnLayoutTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineLeadingColumnLayoutTest.java new file mode 100644 index 000000000..6eb9cd399 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineLeadingColumnLayoutTest.java @@ -0,0 +1,122 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedNode; +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.testing.layout.LayoutSnapshotAssertions; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The one thing a leading column has to do: line up across entries. + * + *

A {@code DATE | ● | CONTENT} timeline is only that layout if every entry's marker + * starts at the same x. The dates are not the same length — that is the whole point of the + * column — so the width cannot come from the text, and this asserts the consequence rather + * than the mechanism: the same three column positions in every entry, including in an entry + * that put nothing in its leading column at all.

+ * + *

Measured before it was designed. With {@link DocumentRowColumn#auto()} the same two + * rows put their markers 131pt apart, because an auto column is sized from its own row's + * content; {@code fixed} and {@code weight} both aligned exactly. That is why + * {@code leadingColumn(auto())} is rejected at the call rather than shipped as a layout + * that happens to look right whenever the dates are the same length.

+ */ +class TimelineLeadingColumnLayoutTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + + @Test + void everyEntrysColumnsStartAtTheSameXWhateverItsDateSays() throws Exception { + LayoutGraph graph = timeline(DocumentRowColumn.fixed(56)); + + Map> byColumn = columnPositions(graph); + assertThat(byColumn).as("three columns, four entries").hasSize(3); + assertThat(byColumn.get(0)).as("every leading column").hasSize(4); + + byColumn.forEach((index, positions) -> assertThat(positions) + .as("column %d starts at one x in every entry, not one per date length", index) + .containsOnly(positions.get(0))); + + // And they are three distinct columns, not one collapsed on top of another. + assertThat(List.of(byColumn.get(0).get(0), byColumn.get(1).get(0), byColumn.get(2).get(0))) + .isSorted() + .doesNotHaveDuplicates(); + } + + @Test + void aWeightedLeadingColumnAlignsJustAsAFixedOneDoes() throws Exception { + // Both are decided by the row rather than by the text, which is the property that + // matters; the two are offered so the caller can pick points or a share, not + // because one of them aligns and the other does not. + columnPositions(timeline(DocumentRowColumn.weight(0.35))) + .forEach((index, positions) -> assertThat(positions) + .as("column %d", index) + .containsOnly(positions.get(0))); + } + + @Test + void aLeadingColumnTimelineGeometryIsPinned() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 260) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow().addTimeline(entries(DocumentRowColumn.fixed(56))).build(); + LayoutSnapshotAssertions.assertMatches(session, "document/timeline_leading_column"); + } + } + + /** Four entries: three dates of very different lengths, and one with no date at all. */ + private static Consumer entries(DocumentRowColumn column) { + return t -> t + .connector(RAIL, 1.5) + .leadingColumn(column) + .entry(e -> e.marker(TimelineMarker.dot(8, INK)) + .leading(d -> d.addParagraph("2023")) + .title("Senior Engineer").body("Led the layout engine rewrite.")) + .entry(e -> e.marker(TimelineMarker.dot(8, INK)) + .leading(d -> d.addParagraph("September 2024 - present")) + .title("Engineer").body("Shipped the pagination compiler.")) + .entry(e -> e.marker(TimelineMarker.square(8, INK)) + .leading(d -> d.addParagraph("Q1")) + .title("Junior Engineer")) + .entry(e -> e.marker(TimelineMarker.dot(8, INK)) + .title("No date at all")); + } + + private static LayoutGraph timeline(DocumentRowColumn column) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 400) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow().addTimeline(entries(column)).build(); + return session.layoutGraph(); + } + } + + /** + * Each header row's column boxes, keyed by column index, in entry order. + * + *

The row's own children, not its descendants — {@code parentPath} is a full path, + * so a plain "contains RowNode" also collects every paragraph inside every column and + * groups them under the same index.

+ */ + private static Map> columnPositions(LayoutGraph graph) { + return graph.nodes().stream() + .filter(node -> node.parentPath() != null && node.parentPath().matches(".*RowNode\\[\\d+]$")) + .collect(Collectors.groupingBy(PlacedNode::childIndex, TreeMap::new, + Collectors.mapping(PlacedNode::placementX, Collectors.toList()))); + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java new file mode 100644 index 000000000..5fd3dd637 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java @@ -0,0 +1,258 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.TimelineBuilder; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.ResolvedLayoutAnchor; +import com.demcha.compose.document.layout.ResolvedLayoutMetadata; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Every timeline marker now reports where it landed. + * + *

This is the first use of the resolved-layout seam: each marker is wrapped so the + * finished graph carries one anchor per marker, and a later phase computes the rail from + * those rather than from a section border. What is asserted here is what that later phase + * will depend on — one anchor per marker, on the page the marker is actually on, at the + * marker's own box and not its column's, and independent of how many shapes the marker + * took to draw.

+ * + *

The anchors are read straight out of the compiled graph. Nothing registers a pass yet; + * a pass is what Phase 9 adds, and it will read exactly this.

+ */ +class TimelineMarkerAnchorTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + + @Test + void everyMarkerLeavesOneAnchorInTheFinishedGraph() throws Exception { + LayoutGraph graph = timeline(360, t -> t + .entry(TimelineMarker.dot(8, INK), e -> e.title("First")) + .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), e -> e.title("Second")) + .entry(TimelineMarker.square(10, INK), e -> e.title("Third"))); + + List anchors = markerAnchors(graph); + assertThat(anchors).hasSize(3); + assertThat(anchors.stream().map(a -> a.id().index())) + .as("indexed by entry, in document order") + .containsExactly(0, 1, 2); + assertThat(anchors.stream().map(a -> a.id().groupKey()).distinct()) + .as("one owner for the timeline") + .hasSize(1); + } + + @Test + void anAnchorIsTheMarkersOwnBoxAndNotItsColumns() throws Exception { + // The distinction the seam exists for. The marker column is 20pt wide here and the + // marker is 8pt; a rail taking the column would sit 6pt off the ink. + LayoutGraph graph = timeline(360, t -> t + .axisWidth(20) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Only"))); + + ResolvedLayoutAnchor anchor = markerAnchors(graph).get(0); + PlacedFragment ellipse = graph.fragments().stream() + .filter(f -> f.payload() != null + && f.payload().getClass().getSimpleName().contains("Ellipse")) + .findFirst().orElseThrow(); + + assertThat(anchor.width()).isEqualTo(8.0, within(1e-9)); + assertThat(anchor.height()).isEqualTo(8.0, within(1e-9)); + assertThat(anchor.x()).isEqualTo(ellipse.x(), within(1e-9)); + assertThat(anchor.y()).isEqualTo(ellipse.y(), within(1e-9)); + } + + @Test + void aMarkerOfThreeShapesLeavesTheSameOneAnchorAsAMarkerOfOne() throws Exception { + // The binding contract: the number of fragments a marker draws must not be + // visible to whatever anchors on it. + ResolvedLayoutAnchor single = onlyAnchor(TimelineMarker.dot(16, INK)); + ResolvedLayoutAnchor composed = onlyAnchor(TimelineMarker.custom(16, 16, column -> + column.addLayerStack(stack -> stack + .back(circle(16, INK)) + .center(circle(10, DocumentColor.WHITE)) + .center(circle(4, INK))))); + + assertThat(composed.x()).isEqualTo(single.x(), within(1e-9)); + assertThat(composed.y()).isEqualTo(single.y(), within(1e-9)); + assertThat(composed.width()).isEqualTo(single.width(), within(1e-9)); + assertThat(composed.height()).isEqualTo(single.height(), within(1e-9)); + } + + @Test + void anAnchorsLeftEdgeHoldsStillWhileItsCentreMovesWithTheMarker() throws Exception { + // Measured, and it is the whole reason the rail is anchored by a fraction plus an + // offset rather than by "the centre". Markers of different sizes share a left edge + // — they are left-packed in their column — so the centre of a 6pt marker and of a + // 24pt one are 9pt apart. A rail that wanted to pass through both centres could + // not; a rail placed at the left edge plus a constant can. + LayoutGraph graph = timeline(360, t -> t + .axisWidth(28) + .entry(TimelineMarker.dot(6, INK), e -> e.title("Small")) + .entry(TimelineMarker.dot(24, INK), e -> e.title("Large"))); + + List anchors = markerAnchors(graph); + assertThat(anchors.get(0).x()) + .as("same left edge whatever the marker's size") + .isEqualTo(anchors.get(1).x(), within(1e-9)); + assertThat(anchors.get(1).pointX(0.5) - anchors.get(0).pointX(0.5)) + .as("while the centres are half the size difference apart") + .isEqualTo(9.0, within(1e-9)); + } + + @Test + void anAnchorReportsThePageItsMarkerIsActuallyOn() throws Exception { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < 14; i++) { + body.append("Sentence ").append(i).append(" of a body long enough to run on. "); + } + LayoutGraph graph = timeline(320, 170, t -> t + .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body(body.toString())) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Short."))); + + assertThat(graph.totalPages()).isGreaterThan(1); + List anchors = markerAnchors(graph); + assertThat(anchors).hasSize(2); + assertThat(anchors.get(0).pageIndex()).isZero(); + assertThat(anchors.get(1).pageIndex()) + .as("the second marker is pushed onto the next page and says so") + .isEqualTo(1); + } + + @Test + void twoTimelinesOnOnePageKeepTheirMarkersApart() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 400).margin(DocumentInsets.of(20)).create()) { + session.pageFlow() + .addTimeline(t -> t.connector(RAIL, 1.5) + .entry(TimelineMarker.dot(8, INK), e -> e.title("A1")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("A2"))) + .addTimeline(t -> t.connector(RAIL, 1.5) + .entry(TimelineMarker.dot(8, INK), e -> e.title("B1"))) + .build(); + + List anchors = + markerAnchors(session.layoutGraph()); + assertThat(anchors).hasSize(3); + + Object first = anchors.get(0).id().groupKey(); + Object second = anchors.get(2).id().groupKey(); + assertThat(first).isNotSameAs(second); + assertThat(ResolvedLayoutMetadata.from(session.layoutGraph()) + .anchors(first, anchors.get(0).id().kind())) + .as("asking for one timeline's owner returns that timeline's markers only") + .hasSize(2); + } + } + + private static com.demcha.compose.document.node.EllipseNode circle(double size, DocumentColor fill) { + return new com.demcha.compose.document.node.EllipseNode( + "marker", size, size, fill, null, null, null, null, null); + } + + @Test + void aMarkerIsGivenTheBoxItDeclaredWhateverItDrew() throws Exception { + // The declaration is the marker. A recipe that draws less than its box leaves the + // rest of it empty rather than shrinking the box, and one that draws more overflows + // rather than growing it — so the number the caller wrote is the number the column + // reserves, the anchor reports, and the rail is derived from. + ResolvedLayoutAnchor roomToSpare = anchorInAWideAxis( + TimelineMarker.custom(30, 30, column -> column.add(circle(10, INK)))); + assertThat(roomToSpare.width()).as("declared 30, drew 10").isEqualTo(30.0, within(1e-9)); + assertThat(roomToSpare.height()).isEqualTo(30.0, within(1e-9)); + + ResolvedLayoutAnchor overflowing = anchorInAWideAxis( + TimelineMarker.custom(10, 10, column -> column.add(circle(30, INK)))); + assertThat(overflowing.width()).as("declared 10, drew 30").isEqualTo(10.0, within(1e-9)); + assertThat(overflowing.height()).isEqualTo(10.0, within(1e-9)); + + ResolvedLayoutAnchor oblong = anchorInAWideAxis( + TimelineMarker.custom(24, 8, column -> column.add(circle(6, INK)))); + assertThat(oblong.width()).as("a box does not have to be square").isEqualTo(24.0, within(1e-9)); + assertThat(oblong.height()).isEqualTo(8.0, within(1e-9)); + } + + @Test + void aBoxWiderThanItsAxisKeepsItsBoxAndOverflowsTheColumn() throws Exception { + // The one place two declarations disagree, and the marker's is the one that wins: a + // 30pt box asked for inside a 20pt axis resolves to 30 and overflows the column + // rather than being squeezed into it. It has to be that way round — the rail is + // derived from this box, so a clamped box would put the line somewhere neither the + // marker nor the axis asked for. + assertThat(onlyAnchor(TimelineMarker.custom(30, 30, column -> column.add(circle(10, INK)))).width()) + .as("a 30pt box asked for inside axisWidth(20)") + .isEqualTo(30.0, within(1e-9)); + } + + @Test + void everyBuiltInFactoryDeclaresTheSizeItWasGiven() throws Exception { + // And the four that shipped before any of this draw exactly their declared box, so + // making the declaration authoritative moved none of them. The ring is stroked at + // 4pt: ink spreads about a shape's edge, and the box may not follow it. + for (TimelineMarker marker : List.of( + TimelineMarker.dot(12, INK), + TimelineMarker.square(12, INK), + TimelineMarker.numbered(3, 12, INK, DocumentColor.WHITE), + TimelineMarker.circle(12, DocumentColor.WHITE, + com.demcha.compose.document.style.DocumentStroke.of(INK, 4.0)))) { + ResolvedLayoutAnchor anchor = onlyAnchor(marker); + assertThat(anchor.width()).isEqualTo(12.0, within(1e-9)); + assertThat(anchor.height()).isEqualTo(12.0, within(1e-9)); + } + } + + /** + * The marker anchors only. + * + *

A timeline anchors its entries as well as its markers — the rail needs each + * entry's extent on each page — so a test about markers has to say which it means.

+ */ + private static List markerAnchors(LayoutGraph graph) { + return ResolvedLayoutMetadata.from(graph).anchors().stream() + .filter(a -> "MARKER".equals(a.id().kind().toString())) + .toList(); + } + + private static ResolvedLayoutAnchor onlyAnchor(TimelineMarker marker) throws Exception { + LayoutGraph graph = timeline(360, t -> t + .axisWidth(20) + .entry(marker, e -> e.title("Only"))); + return markerAnchors(graph).get(0); + } + + /** The same, in an axis wide enough that the column is not the binding constraint. */ + private static ResolvedLayoutAnchor anchorInAWideAxis(TimelineMarker marker) throws Exception { + LayoutGraph graph = timeline(360, t -> t + .axisWidth(40) + .entry(marker, e -> e.title("Only"))); + return markerAnchors(graph).get(0); + } + + private static LayoutGraph timeline(double pageWidth, Consumer spec) throws Exception { + return timeline(pageWidth, 400, spec); + } + + private static LayoutGraph timeline(double pageWidth, double pageHeight, + Consumer spec) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(pageWidth, pageHeight) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow().addTimeline(t -> { + t.connector(RAIL, 1.5); + spec.accept(t); + }).build(); + return session.layoutGraph(); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineRailGeometryTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineRailGeometryTest.java new file mode 100644 index 000000000..234fd0b31 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineRailGeometryTest.java @@ -0,0 +1,473 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.TimelineBuilder; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.dsl.TimelineRailExtent; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.ResolvedLayoutAnchor; +import com.demcha.compose.document.layout.ResolvedLayoutMetadata; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentRowColumn; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.within; + +/** + * The rail as one logical line, computed from resolved anchors. + * + *

It used to be a left border repeated on every entry section, which is why it sat at + * the entry's edge, could not stop at the markers, and drew a slightly darker row wherever + * two entries abutted. It is now contributed by a pass reading the anchors the layout + * resolved: x from the marker anchor, the two ends from the extent, and one + * fragment per page it appears on.

+ * + *

The two are independent, and the tests keep them so. A marker anchor moves the line + * sideways and never changes where it starts; an extent changes where it starts and never + * moves it sideways.

+ */ +class TimelineRailGeometryTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + + // --- extent --------------------------------------------------------------- + + @Test + void entryBoundsIsExactlyTheUnionOfTheEntriesResolvedSlices() throws Exception { + // The compatibility contract, stated as an equation rather than as a picture. The + // slices are the same boxes the per-entry border used to be drawn on — measured at + // Δ = 0 against it — so a rail equal to their union is the rail that shipped. + LayoutGraph graph = timeline(320, 300, t -> t + .spacing(14) + .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body("Body one.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Body two.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Third").body("Body three."))); + + List rails = rails(graph); + List entries = entryAnchors(graph); + assertThat(rails).as("one page, one rail").hasSize(1); + assertThat(entries).hasSize(3); + + double top = entries.stream().mapToDouble(a -> a.pointY(1.0)).max().orElseThrow(); + double bottom = entries.stream().mapToDouble(ResolvedLayoutAnchor::y).min().orElseThrow(); + assertThat(rails.get(0).y() + rails.get(0).height()).isEqualTo(top, within(1e-9)); + assertThat(rails.get(0).y()).isEqualTo(bottom, within(1e-9)); + + // And the union is contiguous, which is why one line can replace three: an entry's + // spacing is padding inside its own box, so the gaps are covered and there is no + // tail after the last entry. + assertThat(entries.get(0).y()).isEqualTo(entries.get(1).pointY(1.0), within(1e-9)); + assertThat(entries.get(1).y()).isEqualTo(entries.get(2).pointY(1.0), within(1e-9)); + } + + @Test + void markerToMarkerStopsAtTheMarkersAndEntryBoundsDoesNot() throws Exception { + Consumer entries = t -> t + .spacing(14) + .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body("Body one.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Body two.")); + + PlacedFragment bounded = rails(timeline(320, 300, entries)).get(0); + PlacedFragment betweenMarkers = rails(timeline(320, 300, t -> { + t.rail(rail -> rail.extent(TimelineRailExtent.MARKER_TO_MARKER)); + entries.accept(t); + })).get(0); + + assertThat(betweenMarkers.y() + betweenMarkers.height()) + .as("nothing above the first marker") + .isLessThan(bounded.y() + bounded.height()); + assertThat(betweenMarkers.y()) + .as("nothing below the last") + .isGreaterThan(bounded.y()); + assertThat(betweenMarkers.x()) + .as("and the extent moved neither end sideways") + .isEqualTo(bounded.x(), within(1e-9)); + } + + @Test + void markerToMarkerRunsBetweenTheAnchorPointsItIsNamedFor() throws Exception { + LayoutGraph graph = timeline(320, 300, t -> t + .rail(rail -> rail.extent(TimelineRailExtent.MARKER_TO_MARKER)) + .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body("Body one.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Body two."))); + + List markers = markerAnchors(graph); + PlacedFragment rail = rails(graph).get(0); + assertThat(rail.y() + rail.height()) + .as("the first marker's anchor point, vertically its centre") + .isEqualTo(markers.get(0).pointY(0.5), within(1e-9)); + assertThat(rail.y()).isEqualTo(markers.get(1).pointY(0.5), within(1e-9)); + } + + @Test + void oneEntryWithMarkerToMarkerEmitsNoRailAtAll() throws Exception { + // A rail of no length is not a shorter rail: nothing reaches a backend, rather + // than a zero- or negative-height fragment for one to cope with. + LayoutGraph graph = timeline(320, 300, t -> t + .rail(rail -> rail.extent(TimelineRailExtent.MARKER_TO_MARKER)) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Only").body("Body."))); + + assertThat(rails(graph)).isEmpty(); + assertThat(markerAnchors(graph)).as("the marker itself still renders").hasSize(1); + } + + @Test + void oneEntryWithEntryBoundsStillDrawsItsRail() throws Exception { + // The other extent has no such degenerate case — an entry has height. + assertThat(rails(timeline(320, 300, t -> t + .entry(TimelineMarker.dot(8, INK), e -> e.title("Only").body("Body."))))) + .hasSize(1); + } + + @Test + void timelineBoundsIsRejectedRatherThanResolvedToItsNeighbour() throws Exception { + assertThatIllegalArgumentException() + .isThrownBy(() -> timeline(320, 300, t -> t + .rail(rail -> rail.extent(TimelineRailExtent.TIMELINE_BOUNDS)) + .entry(TimelineMarker.dot(8, INK), e -> e.title("x")))) + .withMessageContaining("not implemented"); + } + + // --- the marker anchor, which is a different question ---------------------- + + @Test + void theRailSitsOneGutterLeftOfTheMarkerWhateverTheMarkerSize() throws Exception { + // The measured reason the anchor is an edge plus a constant rather than a centre: + // a centre moves with the marker, an edge does not. Two timelines, markers of 6pt + // and 24pt, and one rail x. + double small = rails(timeline(320, 300, t -> t.gutter(8) + .entry(TimelineMarker.dot(6, INK), e -> e.title("x")))).get(0).x(); + double large = rails(timeline(320, 300, t -> t.gutter(8) + .entry(TimelineMarker.dot(24, INK), e -> e.title("x")))).get(0).x(); + + assertThat(large).isEqualTo(small, within(1e-9)); + } + + @Test + void markerOnRailPlacesEveryMarkerAnchorOnTheRail() throws Exception { + // The contract in its own terms, read off resolved geometry rather than off the + // spec that asked for it: for each entry, railX is that marker's own centre. + LayoutGraph graph = timeline(360, 320, t -> t + .markerOnRail() + .axisWidth(28) + .entry(TimelineMarker.dot(12, INK), e -> e.title("Dot").body("Body.")) + .entry(TimelineMarker.square(12, INK), e -> e.title("Square").body("Body.")) + .entry(TimelineMarker.numbered(3, 12, INK, DocumentColor.WHITE), + e -> e.title("Numbered").body("Body."))); + + double railX = rails(graph).get(0).x(); + List markers = markerAnchors(graph); + assertThat(markers).hasSize(3); + assertThat(markers).allSatisfy(marker -> assertThat(railX) + .as("relativeX 0.5, offsetX 0 — the marker's own centre") + .isEqualTo(marker.x() + marker.width() / 2, within(1e-9))); + } + + @Test + void markerOnRailPutsMarkersOfEverySizeOnTheSameLine() throws Exception { + // The case that found the defect. Markers were left-packed in the axis column, so + // a 6pt and a 24pt marker had centres 9pt apart and only the first sat on the rail. + // Placement now follows the anchor — a centre anchor centres the marker in its + // column — so every centre is the column's centre, whatever the marker's size. + for (Consumer axis : List.>of( + t -> t.axisWidth(28), t -> t.markerColumnWeight(0.18))) { + LayoutGraph graph = timeline(360, 340, t -> { + t.markerOnRail(); + axis.accept(t); + t.entry(TimelineMarker.dot(6, INK), e -> e.title("Small").body("Body.")) + .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), + e -> e.title("Medium").body("Body.")) + .entry(TimelineMarker.square(24, INK), e -> e.title("Large").body("Body.")); + }); + + double railX = rails(graph).get(0).x(); + List markers = markerAnchors(graph); + assertThat(markers).hasSize(3); + assertThat(markers.stream().map(ResolvedLayoutAnchor::width)) + .as("the premise: three different sizes") + .containsExactly(6.0, 14.0, 24.0); + assertThat(markers).allSatisfy(marker -> assertThat(railX) + .as("every marker's centre is the axis, fixed axis or weighted") + .isEqualTo(marker.x() + marker.width() / 2, within(1e-9))); + } + } + + @Test + void theMarkerOnRailScenarioTheVisualBaselineDraws() throws Exception { + // The geometry behind timeline-dsl/marker-on-rail: DATE | ● | CONTENT with markers + // of three sizes. The baseline shows it; this says what it is. + LayoutGraph graph = timeline(360, 210, t -> t + .spacing(12) + .markerOnRail() + .axisWidth(28) + .leadingColumn(DocumentRowColumn.fixed(54)) + .entry(e -> e.marker(TimelineMarker.dot(6, INK)) + .leading(d -> d.addParagraph("2023")).title("Senior").body("Body.")) + .entry(e -> e.marker(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE)) + .leading(d -> d.addParagraph("2021")).title("Engineer").body("Body.")) + .entry(e -> e.marker(TimelineMarker.square(24, INK)) + .leading(d -> d.addParagraph("2019")).title("Junior").body("Body."))); + + double railX = rails(graph).get(0).x(); + List markers = markerAnchors(graph); + assertThat(markers.stream().map(ResolvedLayoutAnchor::width)) + .as("three genuinely different markers") + .containsExactly(6.0, 14.0, 24.0); + assertThat(markers.stream().map(m -> m.pointX(0.5)).distinct()) + .as("one declared anchor x between them") + .hasSize(1); + assertThat(markers).allSatisfy(marker -> + assertThat(railX).isEqualTo(marker.pointX(0.5), within(1e-9))); + assertThat(railX) + .as("and the dates are in their own column, left of the axis") + .isGreaterThan(entryAnchors(graph).get(0).x() + 40.0); + } + + @Test + void theLeftEdgeAnchorStillLeavesMarkersWhereTheyHaveAlwaysBeen() throws Exception { + // The other arm of the same question, and the one that must not move: markers of + // different sizes share a left edge, which is why the default rail is an edge plus + // a constant rather than a centre. + LayoutGraph graph = timeline(360, 340, t -> t + .axisWidth(28) + .entry(TimelineMarker.dot(6, INK), e -> e.title("Small").body("Body.")) + .entry(TimelineMarker.square(24, INK), e -> e.title("Large").body("Body."))); + + List markers = markerAnchors(graph); + assertThat(markers.get(0).x()) + .as("left-packed, as before") + .isEqualTo(markers.get(1).x(), within(1e-9)); + assertThat(rails(graph).get(0).x()) + .isEqualTo(markers.get(0).x() - 8.0, within(1e-9)); + } + + @Test + void markerOnRailHoldsForEveryShapeAndForBothExtents() throws Exception { + // The extent decides the two ends and must not touch the x. Same three markers, + // both extents, one answer. + for (TimelineRailExtent extent : List.of(TimelineRailExtent.ENTRY_BOUNDS, + TimelineRailExtent.MARKER_TO_MARKER)) { + LayoutGraph graph = timeline(360, 320, t -> t + .markerOnRail() + .rail(rail -> rail.extent(extent)) + .axisWidth(28) + .entry(TimelineMarker.dot(12, INK), e -> e.title("Dot").body("Body.")) + .entry(TimelineMarker.square(12, INK), e -> e.title("Square").body("Body."))); + + double railX = rails(graph).get(0).x(); + assertThat(markerAnchors(graph)).allSatisfy(marker -> assertThat(railX) + .as("%s must not move the rail sideways", extent) + .isEqualTo(marker.x() + marker.width() / 2, within(1e-9))); + } + } + + @Test + void markerOnRailWithALeadingColumnStillPutsTheMarkerOnTheLine() throws Exception { + LayoutGraph graph = timeline(400, 320, t -> t + .markerOnRail() + .axisWidth(28) + .leadingColumn(DocumentRowColumn.fixed(56)) + .entry(e -> e.marker(TimelineMarker.dot(12, INK)) + .leading(d -> d.addParagraph("2023")).title("First").body("Body.")) + .entry(e -> e.marker(TimelineMarker.dot(12, INK)) + .leading(d -> d.addParagraph("2021")).title("Second").body("Body."))); + + double railX = rails(graph).get(0).x(); + assertThat(markerAnchors(graph)).allSatisfy(marker -> assertThat(railX) + .isEqualTo(marker.x() + marker.width() / 2, within(1e-9))); + assertThat(railX) + .as("and the date column sits to the left of it, not pushing it out") + .isGreaterThan(entryAnchors(graph).get(0).x() + 40.0); + } + + @Test + void markerOnRailWithMarkerToMarkerRunsBetweenTheMarkerCentres() throws Exception { + LayoutGraph graph = timeline(360, 320, t -> t + .markerOnRail() + .rail(rail -> rail.extent(TimelineRailExtent.MARKER_TO_MARKER)) + .axisWidth(28) + .entry(TimelineMarker.dot(12, INK), e -> e.title("First").body("Body.")) + .entry(TimelineMarker.dot(12, INK), e -> e.title("Second").body("Body."))); + + List markers = markerAnchors(graph); + PlacedFragment rail = rails(graph).get(0); + assertThat(rail.y() + rail.height()) + .isEqualTo(markers.get(0).pointY(0.5), within(1e-9)); + assertThat(rail.y()).isEqualTo(markers.get(1).pointY(0.5), within(1e-9)); + } + + @Test + void markerOnRailIsDrawnUnderTheMarkersItCrosses() throws Exception { + // It matters more here than anywhere: the line now passes through the markers, so + // whether it is over or under them is visible on the page. + LayoutGraph graph = timeline(360, 320, t -> t + .markerOnRail() + .axisWidth(28) + .entry(TimelineMarker.dot(14, INK), e -> e.title("First").body("Body.")) + .entry(TimelineMarker.dot(14, INK), e -> e.title("Second").body("Body."))); + + int lastRail = -1; + int firstMarker = Integer.MAX_VALUE; + for (int i = 0; i < graph.fragments().size(); i++) { + PlacedFragment fragment = graph.fragments().get(i); + if ("@timeline-rail".equals(fragment.path())) { + lastRail = Math.max(lastRail, i); + } else if (fragment.payload() != null + && fragment.payload().getClass().getSimpleName().contains("Ellipse")) { + firstMarker = Math.min(firstMarker, i); + } + } + assertThat(lastRail).isNotEqualTo(-1).isLessThan(firstMarker); + } + + @Test + void markerOnRailPutsTheLineThroughTheMarkerInstead() throws Exception { + LayoutGraph graph = timeline(320, 300, t -> t + .markerOnRail() + .entry(TimelineMarker.dot(12, INK), e -> e.title("First").body("Body.")) + .entry(TimelineMarker.dot(12, INK), e -> e.title("Second").body("Body."))); + + ResolvedLayoutAnchor marker = markerAnchors(graph).get(0); + assertThat(rails(graph).get(0).x()) + .as("through the marker's centre") + .isEqualTo(marker.pointX(0.5), within(1e-9)); + } + + @Test + void aLeadingColumnAddsContentLeftOfTheAxisAndDoesNotMoveTheRail() throws Exception { + // LEADING | AXIS | CONTENT. The rail belongs to the axis, so a date column appears + // to the left of it rather than pushing it out to the entry's boundary. + LayoutGraph graph = timeline(360, 300, t -> t + .leadingColumn(DocumentRowColumn.fixed(56)) + .entry(e -> e.marker(TimelineMarker.dot(8, INK)) + .leading(d -> d.addParagraph("2023")).title("First").body("Body.")) + .entry(e -> e.marker(TimelineMarker.dot(8, INK)) + .leading(d -> d.addParagraph("2021")).title("Second").body("Body."))); + + PlacedFragment rail = rails(graph).get(0); + ResolvedLayoutAnchor marker = markerAnchors(graph).get(0); + ResolvedLayoutAnchor entry = entryAnchors(graph).get(0); + + assertThat(rail.x()) + .as("one gutter left of the marker, as always") + .isEqualTo(marker.x() - 8.0, within(1e-9)); + assertThat(rail.x()) + .as("which is well right of the entry's own edge, because the date is there") + .isGreaterThan(entry.x() + 40.0); + } + + // --- pages and draw order -------------------------------------------------- + + @Test + void aTimelineCrossingPagesGivesOneFragmentPerPageBoundedByThatPage() throws Exception { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < 26; i++) { + body.append("Sentence ").append(i).append(" of a body long enough to run on and on. "); + } + LayoutGraph graph = timeline(320, 150, t -> t + .spacing(14) + .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body(body.toString())) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Short."))); + + assertThat(graph.totalPages()).isGreaterThan(2); + List rails = rails(graph); + assertThat(rails).as("one logical rail, one fragment per page").hasSize(graph.totalPages()); + + for (PlacedFragment rail : rails) { + List onPage = entryAnchors(graph).stream() + .filter(a -> a.pageIndex() == rail.pageIndex()).toList(); + double top = onPage.stream().mapToDouble(a -> a.pointY(1.0)).max().orElseThrow(); + double bottom = onPage.stream().mapToDouble(ResolvedLayoutAnchor::y).min().orElseThrow(); + assertThat(rail.y() + rail.height()) + .as("page %d top", rail.pageIndex()).isEqualTo(top, within(1e-9)); + assertThat(rail.y()) + .as("page %d bottom", rail.pageIndex()).isEqualTo(bottom, within(1e-9)); + } + // Not one physical line pretending to span pages: every fragment is on its own page + // and none of them reaches beyond it. + assertThat(rails.stream().map(PlacedFragment::pageIndex).distinct()) + .hasSize(graph.totalPages()); + } + + @Test + void theRailIsDrawnBeneathTheMarkersItPassesUnder() throws Exception { + // There is no z in this engine — draw order is list order — so "under" is a + // statement about the fragment list and has to be asserted there. + LayoutGraph graph = timeline(320, 300, t -> t + .entry(TimelineMarker.dot(10, INK), e -> e.title("First")) + .entry(TimelineMarker.dot(10, INK), e -> e.title("Second"))); + + int lastRail = -1; + int firstMarker = Integer.MAX_VALUE; + for (int i = 0; i < graph.fragments().size(); i++) { + PlacedFragment fragment = graph.fragments().get(i); + if ("@timeline-rail".equals(fragment.path())) { + lastRail = Math.max(lastRail, i); + } else if (fragment.payload() != null + && fragment.payload().getClass().getSimpleName().contains("Ellipse")) { + firstMarker = Math.min(firstMarker, i); + } + } + assertThat(lastRail).isNotEqualTo(-1); + assertThat(lastRail) + .as("every rail fragment precedes every marker, so a filled marker covers it") + .isLessThan(firstMarker); + } + + @Test + void twoTimelinesOnAPageDrawTwoRails() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 400).margin(DocumentInsets.of(20)).create()) { + session.pageFlow() + .addTimeline(t -> t.connector(RAIL, 1.5) + .entry(TimelineMarker.dot(8, INK), e -> e.title("A1").body("Body.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("A2").body("Body."))) + .addTimeline(t -> t.connector(RAIL, 3.0) + .entry(TimelineMarker.dot(8, INK), e -> e.title("B1").body("Body.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("B2").body("Body."))) + .build(); + + List rails = rails(session.layoutGraph()); + assertThat(rails).hasSize(2); + assertThat(rails.get(0).width()).as("each with its own stroke").isEqualTo(1.5, within(1e-9)); + assertThat(rails.get(1).width()).isEqualTo(3.0, within(1e-9)); + assertThat(rails.get(0).y()).as("and its own extent").isGreaterThan(rails.get(1).y()); + } + } + + // --- helpers --------------------------------------------------------------- + + private static List rails(LayoutGraph graph) { + return graph.fragments().stream().filter(f -> "@timeline-rail".equals(f.path())).toList(); + } + + private static List entryAnchors(LayoutGraph graph) { + return ResolvedLayoutMetadata.from(graph).anchors().stream() + .filter(a -> "ENTRY".equals(a.id().kind().toString())).toList(); + } + + private static List markerAnchors(LayoutGraph graph) { + return ResolvedLayoutMetadata.from(graph).anchors().stream() + .filter(a -> "MARKER".equals(a.id().kind().toString())).toList(); + } + + private static LayoutGraph timeline(double width, double height, + Consumer spec) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(width, height).margin(DocumentInsets.of(20)).create()) { + session.pageFlow().addTimeline(t -> { + t.connector(RAIL, 1.5); + spec.accept(t); + }).build(); + return session.layoutGraph(); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineRailSpikeTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineRailSpikeTest.java new file mode 100644 index 000000000..583a9256b --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineRailSpikeTest.java @@ -0,0 +1,86 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +/** + * PHASE 0a SPIKE — throwaway. Delete or promote before the PR. + * + *

Answers three questions the plan blocks on, by measurement rather than reading: + * (1) can a post-layout pass find the timeline markers in a resolved {@code LayoutGraph}, + * (2) what coordinate convention do {@code PlacedNode} boxes and {@code PlacedFragment} + * coordinates share, and (3) does the marker box move when the marker's size changes — + * which is what a rail derived from anchors must be immune to.

+ */ +class TimelineRailSpikeTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 60, 160); + + @Test + void dumpResolvedTimelineGeometry() throws Exception { + System.out.println("=== markers at size 8 ==="); + dump(8.0); + System.out.println(); + System.out.println("=== markers at size 20 (rail must not move) ==="); + dump(20.0); + } + + private static void dump(double markerSize) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 260) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(14) + .entry(TimelineMarker.dot(markerSize, INK), entry("First")) + .entry(TimelineMarker.dot(markerSize, INK), entry("Second")) + .entry(TimelineMarker.dot(markerSize, INK), entry("Third"))) + .build(); + + LayoutGraph graph = session.layoutGraph(); + System.out.println("pages=" + graph.totalPages() + + " canvas=" + graph.canvas().width() + "x" + graph.canvas().height()); + + System.out.println("-- nodes (kind, name, computed, placement, pages) --"); + for (PlacedNode n : graph.nodes()) { + System.out.printf( + " %-16s %-22s computed=(%7.2f,%7.2f) placement=(%7.2f,%7.2f %6.2fx%6.2f) p%d..%d%n", + n.nodeKind(), truncate(n.semanticName()), + n.computedX(), n.computedY(), + n.placementX(), n.placementY(), n.placementWidth(), n.placementHeight(), + n.startPage(), n.endPage()); + } + + System.out.println("-- fragments (payload, box) --"); + List fragments = graph.fragments(); + for (PlacedFragment f : fragments) { + String payload = f.payload() == null ? "null" : f.payload().getClass().getSimpleName(); + System.out.printf(" %-28s page=%d box=(%7.2f,%7.2f %6.2fx%6.2f) path=%s%n", + payload, f.pageIndex(), f.x(), f.y(), f.width(), f.height(), truncate(f.path())); + } + } + } + + private static Consumer entry(String title) { + return e -> e.title(title).meta("2024").body("Body text for " + title + "."); + } + + private static String truncate(String s) { + if (s == null) { + return "-"; + } + return s.length() <= 40 ? s : "…" + s.substring(s.length() - 39); + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineSplitSpikeTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineSplitSpikeTest.java new file mode 100644 index 000000000..d396fb4b9 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineSplitSpikeTest.java @@ -0,0 +1,79 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.EllipseNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableCell; +import org.junit.jupiter.api.Test; + +/** + * PHASE 0b SPIKE — throwaway. Delete or promote before the PR. + * + *

The plan blocks on one question: is there a {@code leading | axis | content} + * structure whose content can continue onto the next page? {@code RowNode} cannot — + * {@code RowDefinition} is ATOMIC and rows refuse to nest. {@code TableDefinition} is + * the only SPLITTABLE horizontal container, and {@code RowBuilder} rejects tables + * precisely because "tables are splittable and would conflict with the row's atomic + * pagination".

+ * + *

So: build the three columns as a table, force a page break through it, and answer + * two things — does it actually split, and does a marker sitting in a cell still appear + * as a {@code PlacedNode} the rail pass could anchor to?

+ */ +class TimelineSplitSpikeTest { + + private static final DocumentColor INK = DocumentColor.rgb(20, 60, 160); + + @Test + void doesAThreeColumnTableSplitAndKeepMarkersVisible() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 200) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow() + .addTable(t -> { + t.columns( + DocumentTableColumn.fixed(80), + DocumentTableColumn.fixed(24), + DocumentTableColumn.fixed(120)); + for (int i = 1; i <= 14; i++) { + t.rowCells( + DocumentTableCell.text("2024-0" + i), + new DocumentTableCell(java.util.List.of(), null, 1, 1, + new EllipseNode("marker" + i, 8, 8, INK, null, null, null, null, null)), + DocumentTableCell.lines("Entry " + i, "second line")); + } + }) + .build(); + + LayoutGraph graph = session.layoutGraph(); + System.out.println("SPIKE0B pages=" + graph.totalPages()); + + for (PlacedNode n : graph.nodes()) { + if ("Ellipse".equals(n.nodeKind()) || n.nodeKind().contains("Ellipse") + || "Table".equals(n.nodeKind()) || n.nodeKind().contains("Table")) { + System.out.printf("SPIKE0B node %-14s name=%-10s box=(%7.2f,%7.2f %6.2fx%6.2f) p%d..%d%n", + n.nodeKind(), n.semanticName(), + n.placementX(), n.placementY(), n.placementWidth(), n.placementHeight(), + n.startPage(), n.endPage()); + } + } + + long ellipseFragments = 0; + for (PlacedFragment f : graph.fragments()) { + String p = f.payload() == null ? "" : f.payload().getClass().getSimpleName(); + if (p.contains("Ellipse")) { + ellipseFragments++; + System.out.printf("SPIKE0B ellipseFragment page=%d box=(%7.2f,%7.2f %6.2fx%6.2f)%n", + f.pageIndex(), f.x(), f.y(), f.width(), f.height()); + } + } + System.out.println("SPIKE0B ellipseFragments=" + ellipseFragments); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineVisualScenarioGeometryTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineVisualScenarioGeometryTest.java new file mode 100644 index 000000000..b0f365848 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineVisualScenarioGeometryTest.java @@ -0,0 +1,414 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.TimelineBuilder; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.dsl.TimelineRailExtent; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.ResolvedLayoutAnchor; +import com.demcha.compose.document.layout.ResolvedLayoutMetadata; +import com.demcha.compose.document.node.EllipseNode; +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.DocumentStroke; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * The invariants the finished visual model rests on, and the geometry behind each scene. + * + *

{@link TimelineRailGeometryTest} builds the rail up a piece at a time — what the extent + * decides, what the anchor decides, that neither touches the other. This one asks the + * questions that only exist once all of it is assembled and drawn: does the line hold one x + * for a whole timeline, does a date of any length leave it alone, does a page ever receive a + * fragment reaching outside its own band, and is a marker made of three shapes really + * indistinguishable from a plain one.

+ * + *

Several of these carry a pixel baseline too, and where they do the comment names it. + * The division is deliberate: a baseline shows that something is painted, and in + * what order, and earns its bytes only when the shape itself is the claim; an exact + * coordinate is a number, and a number belongs in an assertion.

+ * + *

No layout snapshot appears here, and that is measured rather than preferred: a snapshot + * records {@code nodes}, and the rail is a {@code PlacedFragment}. Grep either committed + * timeline snapshot for {@code timeline-rail} and the count is zero — a snapshot cannot see + * the rail at all, so re-recording one would prove nothing about it.

+ */ +class TimelineVisualScenarioGeometryTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + private static final double MARGIN = 18.0; + + // --- one rail, one x ------------------------------------------------------- + + @Test + void aDateOfAnyLengthLeavesTheRailExactlyWhereTheAxisPutIt() throws Exception { + // Scenario 3, as an equality rather than as "to the left of". The column width is + // the caller's declaration and is meant to move the axis; the content poured into + // that column is not, and a date that wraps to a second line must not shift the + // line the whole timeline hangs on. + double shortDates = railX(leadingScene("2023", "2021")); + double longDates = railX(leadingScene("September 2023 - present", "January 2021")); + + assertThat(longDates) + .as("the rail belongs to the axis, and the axis does not read the dates") + .isEqualTo(shortDates, within(1e-9)); + } + + @Test + void oneTimelineHasOneRailXHoweverManyPagesItCrossesAndWhicheverExtent() throws Exception { + // The rail is one logical line. Across pages it arrives as several fragments, and + // "one line" means those fragments agree about x — including under MARKER_TO_MARKER, + // which changes both ends on the outer pages and must change nothing else. + for (TimelineRailExtent extent : List.of(TimelineRailExtent.ENTRY_BOUNDS, + TimelineRailExtent.MARKER_TO_MARKER)) { + LayoutGraph graph = paginated(extent); + + assertThat(graph.totalPages()) + .as("the premise: it does cross pages") + .isGreaterThanOrEqualTo(3); + assertThat(rails(graph)) + .as("%s: one fragment per page", extent) + .hasSize(graph.totalPages()); + assertThat(rails(graph).stream().map(PlacedFragment::x).distinct()) + .as("%s: and one x between them", extent) + .hasSize(1); + } + } + + @Test + void noRailFragmentReachesOutsideThePageItIsOn() throws Exception { + // Scenario 7's hard invariant. A rail derived from anchors could in principle be + // handed a box belonging to another page and paint into the margin; every fragment + // is checked against the band its own page actually has. + LayoutGraph graph = paginated(TimelineRailExtent.ENTRY_BOUNDS); + double width = graph.canvas().width(); + double height = graph.canvas().height(); + assertThat(graph.canvas().innerHeight()) + .as("the premise: the band is the page less its margins") + .isEqualTo(height - 2 * MARGIN, within(1e-9)); + + assertThat(rails(graph)).allSatisfy(rail -> { + assertThat(rail.y()).as("bottom").isGreaterThanOrEqualTo(MARGIN - 1e-9); + assertThat(rail.y() + rail.height()) + .as("top").isLessThanOrEqualTo(height - MARGIN + 1e-9); + assertThat(rail.x()).as("left").isGreaterThanOrEqualTo(MARGIN - 1e-9); + assertThat(rail.x() + rail.width()) + .as("right").isLessThanOrEqualTo(width - MARGIN + 1e-9); + }); + } + + // --- the two extents, on one scene ------------------------------------------ + + @Test + void theTwoExtentsDifferInWhereTheyStopAndInNothingElse() throws Exception { + // The pair of baselines timeline-dsl/entry-bounds and timeline-dsl/marker-to-marker + // draw: the same page, the same three entries, one argument different. Scenarios 5 + // and 6, asserted against each other so that "shorter" is a comparison and not an + // impression. + PlacedFragment bounded = rails(extentScene(TimelineRailExtent.ENTRY_BOUNDS)).get(0); + LayoutGraph trimmed = extentScene(TimelineRailExtent.MARKER_TO_MARKER); + PlacedFragment betweenMarkers = rails(trimmed).get(0); + List markers = markerAnchors(trimmed); + + assertThat(betweenMarkers.x()).as("same x").isEqualTo(bounded.x(), within(1e-9)); + assertThat(betweenMarkers.width()) + .as("same stroke").isEqualTo(bounded.width(), within(1e-9)); + assertThat(betweenMarkers.y() + betweenMarkers.height()) + .as("it begins at the first marker's own centre") + .isEqualTo(markers.get(0).pointY(0.5), within(1e-9)) + .isLessThan(bounded.y() + bounded.height()); + assertThat(betweenMarkers.y()) + .as("and ends at the last one's") + .isEqualTo(markers.get(2).pointY(0.5), within(1e-9)) + .isGreaterThan(bounded.y()); + } + + @Test + void entryBoundsCoversTheSpacingBetweenEntriesOfVeryDifferentHeights() throws Exception { + // Scenario 6. Three entries whose heights differ by more than a factor of two, with + // 16pt of spacing between them — and one unbroken line, because an entry's spacing + // is padding inside its own slice rather than a gap between two of them. If it were + // a gap, this is the scene where a rail assembled per entry would show it. + LayoutGraph graph = extentScene(TimelineRailExtent.ENTRY_BOUNDS); + List entries = entryAnchors(graph); + PlacedFragment rail = rails(graph).get(0); + + assertThat(entries).hasSize(3); + assertThat(entries.get(0).height()) + .as("the premise: the first entry is much the tallest") + .isGreaterThan(2 * entries.get(1).height()); + assertThat(entries.get(0).y()) + .as("no gap between the first slice and the second") + .isEqualTo(entries.get(1).pointY(1.0), within(1e-9)); + assertThat(entries.get(1).y()) + .isEqualTo(entries.get(2).pointY(1.0), within(1e-9)); + assertThat(rail.height()) + .as("so one fragment spans all three, and the spacing between them") + .isEqualTo(entries.get(0).pointY(1.0) - entries.get(2).y(), within(1e-9)); + } + + // --- pagination ------------------------------------------------------------- + + @Test + void markerToMarkerAcrossPagesStartsAtTheFirstMarkerAndEndsAtTheLast() throws Exception { + // Scenario 8, where the extent and pagination meet — and neither had been asked + // about the other. The first page is trimmed at the top, the last at the bottom, + // and the page between them carries no marker at all, so it is trimmed at neither + // end and runs the whole band its entry occupies. + LayoutGraph graph = paginated(TimelineRailExtent.MARKER_TO_MARKER); + List rails = rails(graph); + List markers = markerAnchors(graph); + List entries = entryAnchors(graph); + + int lastPage = graph.totalPages() - 1; + assertThat(markers).hasSize(2); + assertThat(graph.totalPages()).as("the premise: at least one page between them") + .isGreaterThanOrEqualTo(3); + assertThat(markers.get(0).pageIndex()).as("first marker, first page").isZero(); + assertThat(markers.get(1).pageIndex()).as("last marker, last page").isEqualTo(lastPage); + assertThat(markers.stream().map(ResolvedLayoutAnchor::pageIndex)) + .as("the premise: the middle page has no marker on it") + .doesNotContain(1); + + assertThat(rails.get(0).y() + rails.get(0).height()) + .as("page 0 begins at the first marker") + .isEqualTo(markers.get(0).pointY(0.5), within(1e-9)); + assertThat(rails.get(lastPage).y()) + .as("the last page ends at the last marker") + .isEqualTo(markers.get(1).pointY(0.5), within(1e-9)); + + List middle = entries.stream() + .filter(a -> a.pageIndex() == 1).toList(); + assertThat(middle).as("and something of the first entry is on the middle page").isNotEmpty(); + assertThat(rails.get(1).y() + rails.get(1).height()) + .as("which is trimmed at neither end") + .isEqualTo(middle.stream().mapToDouble(a -> a.pointY(1.0)).max().orElseThrow(), + within(1e-9)); + assertThat(rails.get(1).y()) + .isEqualTo(middle.stream().mapToDouble(ResolvedLayoutAnchor::y).min().orElseThrow(), + within(1e-9)); + } + + // --- markers of any construction -------------------------------------------- + + @Test + void aHollowMarkerIsAnchoredByItsBoxAndHasTheRailDrawnUnderIt() throws Exception { + // Scenario 9, and the geometry half of what timeline-dsl/outlined-marker draws. A + // ring filled with the page's own colour reads as a clean break in the line — but + // only because the line is painted first. Draw order is list order in this engine, + // so "under" is a statement about the fragment list and is asserted there. + LayoutGraph graph = outlinedScene(); + List markers = markerAnchors(graph); + double railX = rails(graph).get(0).x(); + + assertThat(markers.stream().map(ResolvedLayoutAnchor::width)) + .as("three rings, three sizes, one declared box each") + .containsExactly(10.0, 14.0, 20.0); + assertThat(markers).allSatisfy(marker -> assertThat(railX) + .as("each ring centred on the line that runs through it") + .isEqualTo(marker.pointX(0.5), within(1e-9))); + + assertThat(lastRailIndex(graph)) + .as("the rail is painted before the rings, so each ring's fill covers it") + .isNotEqualTo(-1) + .isLessThan(firstEllipseIndex(graph)); + } + + @Test + void aMarkerOfThreeShapesRailsExactlyAsAPlainOneOfTheSameBox() throws Exception { + // Scenario 10, stated as an identity. The timeline is told a box and handed a + // recipe; what the recipe draws inside that box is none of its business, and the + // proof is that swapping a dot for three stacked ellipses of the same declared size + // moves neither the anchor nor the rail by anything at all. + LayoutGraph plain = customMarkerScene(TimelineMarker.dot(16, INK)); + LayoutGraph composed = customMarkerScene(TimelineMarker.custom(16, 16, column -> + column.addLayerStack(stack -> stack + .back(circle(16, INK, null)) + .center(circle(10, DocumentColor.WHITE, null)) + .center(circle(4, INK, null))))); + + assertThat(rails(composed).get(0).x()) + .as("same rail") + .isEqualTo(rails(plain).get(0).x(), within(1e-9)); + + ResolvedLayoutAnchor plainMarker = markerAnchors(plain).get(0); + ResolvedLayoutAnchor composedMarker = markerAnchors(composed).get(0); + assertThat(composedMarker.x()).as("same x").isEqualTo(plainMarker.x(), within(1e-9)); + assertThat(composedMarker.y()).as("same y").isEqualTo(plainMarker.y(), within(1e-9)); + assertThat(composedMarker.width()).isEqualTo(plainMarker.width(), within(1e-9)); + assertThat(composedMarker.height()).isEqualTo(plainMarker.height(), within(1e-9)); + + assertThat(composed.fragments().size()) + .as("the premise: it really is drawn out of more pieces") + .isGreaterThan(plain.fragments().size()); + } + + // --- two timelines ----------------------------------------------------------- + + @Test + void twoTimelinesWithDifferentAnchorsDoNotShareARail() throws Exception { + // The last invariant, sharpened: two timelines on one page, one on each anchor. + // The owners are separate, so the rails are — and each rail answers to its own + // markers, which is the assertion an owner mix-up would fail. + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 420).margin(DocumentInsets.of(MARGIN)).create()) { + session.pageFlow() + .addTimeline(t -> t.connector(RAIL, 1.5).axisWidth(28).gutter(8) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Left edge").body("Body.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Left edge too").body("Body."))) + .addTimeline(t -> t.connector(RAIL, 1.5).axisWidth(28).markerOnRail() + .entry(TimelineMarker.dot(20, INK), e -> e.title("On the rail").body("Body.")) + .entry(TimelineMarker.dot(20, INK), e -> e.title("On it too").body("Body."))) + .build(); + LayoutGraph graph = session.layoutGraph(); + + List rails = rails(graph); + assertThat(rails).hasSize(2); + assertThat(rails.get(1).x()) + .as("the anchored one sits well right of the one drawn beside its markers") + .isGreaterThan(rails.get(0).x() + 8.0); + + List markers = markerAnchors(graph); + assertThat(markers).hasSize(4); + assertThat(rails.get(0).x()) + .as("the first rail answers to the first timeline's markers") + .isEqualTo(markers.get(0).x() - 8.0, within(1e-9)); + assertThat(rails.get(1).x()) + .as("and the second to the second's") + .isEqualTo(markers.get(2).pointX(0.5), within(1e-9)); + } + } + + // --- scenes ------------------------------------------------------------------ + + /** The scene both extent baselines draw; they differ by this argument and nothing else. */ + private static LayoutGraph extentScene(TimelineRailExtent extent) throws Exception { + return timeline(300, 250, t -> t + .spacing(16) + .rail(rail -> rail.extent(extent)) + .entry(TimelineMarker.dot(9, INK), e -> e + .title("Tall entry").meta("2023 - Present") + .body("A body long enough to run to three lines on a page this " + + "narrow, so that this entry is plainly the tallest here.")) + .entry(TimelineMarker.dot(9, INK), e -> e.title("One line only")) + .entry(TimelineMarker.dot(9, INK), e -> e + .title("Middling").body("Two lines, more or less."))); + } + + /** The scene timeline-dsl/paginated-marker-to-marker draws: two entries, three pages. */ + private static LayoutGraph paginated(TimelineRailExtent extent) throws Exception { + return timeline(300, 150, t -> t + .spacing(14) + .markerOnRail() + .axisWidth(24) + .rail(rail -> rail.extent(extent)) + .entry(TimelineMarker.dot(10, INK), e -> e.title("Runs on").body(longBody())) + .entry(TimelineMarker.dot(10, INK), e -> e.title("And ends here"))); + } + + /** The scene timeline-dsl/outlined-marker draws: rings filled with the page's own colour. */ + private static LayoutGraph outlinedScene() throws Exception { + DocumentStroke ring = DocumentStroke.of(INK, 1.2); + return timeline(300, 210, t -> t + .spacing(12) + .markerOnRail() + .axisWidth(26) + .entry(TimelineMarker.circle(10, DocumentColor.WHITE, ring), e -> e + .title("Hollow").body("The line stops inside the ring.")) + .entry(TimelineMarker.circle(14, DocumentColor.WHITE, ring), e -> e + .title("Hollow, larger").body("And starts again below it.")) + .entry(TimelineMarker.circle(20, DocumentColor.WHITE, ring), e -> e + .title("Hollow, larger still").body("Whatever the diameter."))); + } + + private static LayoutGraph customMarkerScene(TimelineMarker marker) throws Exception { + return timeline(320, 260, t -> t + .markerOnRail() + .axisWidth(24) + .entry(marker, e -> e.title("Whatever it is made of").body("Body.")) + .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Body."))); + } + + private static Consumer leadingScene(String first, String second) { + return t -> t + .leadingColumn(DocumentRowColumn.fixed(60)) + .entry(e -> e.marker(TimelineMarker.dot(8, INK)) + .leading(d -> d.addParagraph(first)).title("First").body("Body.")) + .entry(e -> e.marker(TimelineMarker.dot(8, INK)) + .leading(d -> d.addParagraph(second)).title("Second").body("Body.")); + } + + private static String longBody() { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < 40; i++) { + body.append("Sentence ").append(i).append(" of a body that keeps going. "); + } + return body.toString(); + } + + // --- helpers ------------------------------------------------------------------ + + private static int lastRailIndex(LayoutGraph graph) { + int last = -1; + for (int i = 0; i < graph.fragments().size(); i++) { + if ("@timeline-rail".equals(graph.fragments().get(i).path())) { + last = i; + } + } + return last; + } + + private static int firstEllipseIndex(LayoutGraph graph) { + for (int i = 0; i < graph.fragments().size(); i++) { + PlacedFragment fragment = graph.fragments().get(i); + if (fragment.payload() != null + && fragment.payload().getClass().getSimpleName().contains("Ellipse")) { + return i; + } + } + return Integer.MAX_VALUE; + } + + private static EllipseNode circle(double size, DocumentColor fill, DocumentStroke stroke) { + return new EllipseNode("marker", size, size, fill, stroke, null, null, null, null); + } + + private static double railX(Consumer spec) throws Exception { + return rails(timeline(360, 300, spec)).get(0).x(); + } + + private static List rails(LayoutGraph graph) { + return graph.fragments().stream().filter(f -> "@timeline-rail".equals(f.path())).toList(); + } + + private static List entryAnchors(LayoutGraph graph) { + return ResolvedLayoutMetadata.from(graph).anchors().stream() + .filter(a -> "ENTRY".equals(a.id().kind().toString())).toList(); + } + + private static List markerAnchors(LayoutGraph graph) { + return ResolvedLayoutMetadata.from(graph).anchors().stream() + .filter(a -> "MARKER".equals(a.id().kind().toString())).toList(); + } + + private static LayoutGraph timeline(double width, double height, + Consumer spec) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(width, height).margin(DocumentInsets.of(MARGIN)).create()) { + session.pageFlow().addTimeline(t -> { + t.connector(RAIL, 1.5); + spec.accept(t); + }).build(); + return session.layoutGraph(); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/backend/DocxTransparentWrapperTest.java b/qa/src/test/java/com/demcha/compose/document/backend/DocxTransparentWrapperTest.java new file mode 100644 index 000000000..e8ae2e74b --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/backend/DocxTransparentWrapperTest.java @@ -0,0 +1,98 @@ +package com.demcha.compose.document.backend; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.layout.LayoutAnchorId; +import com.demcha.compose.document.layout.LayoutAnchorNode; +import com.demcha.compose.document.node.AlignNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.HorizontalAlign; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xwpf.extractor.XWPFWordExtractor; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A wrapper that says something about geometry must not take its content with it. + * + *

{@code DocxSemanticBackend} walks the semantic tree and writes what it recognises. A + * node it does not recognise is not written — and if that node is a wrapper, everything + * below it goes too. That is what happened when timelines started anchoring their entries: + * the export succeeded, produced a well-formed document, and lost every word in it.

+ * + *

A layout anchor reports where its child landed; an alignment says where in the + * available width to put it. Word lays text out itself, so neither survives as geometry — + * but both have exactly one child, and the child is the document. These cases are written + * against the wrappers rather than against a timeline, because the contract is the + * backend's: anything transparent stays transparent, whoever put it there.

+ */ +class DocxTransparentWrapperTest { + + private enum Kind { PROBE } + + @Test + void anAnchorAtDocumentLevelKeepsItsParagraph() { + assertThat(exported(flow -> flow.add(anchor(paragraph("anchored at the top level"))))) + .contains("anchored at the top level"); + } + + @Test + void anAlignAtDocumentLevelKeepsItsParagraph() { + assertThat(exported(flow -> flow.add( + new AlignNode(paragraph("aligned at the top level"), HorizontalAlign.CENTER)))) + .contains("aligned at the top level"); + } + + @Test + void bothNestedTogetherKeepTheParagraph() { + assertThat(exported(flow -> flow.add(new AlignNode( + anchor(paragraph("aligned and anchored")), HorizontalAlign.CENTER)))) + .contains("aligned and anchored"); + } + + @Test + void aRowCellKeepsWhatTheWrappersInsideItHold() { + // The second walker. A row exports as a one-row table, and its cells have their own + // traversal — a wrapper handled at document level and missed here loses the subtree + // just as completely, which is exactly the shape a timeline's marker column has. + assertThat(exported(flow -> flow.addRow(row -> { + row.addSection(cell -> cell.add( + new AlignNode(anchor(paragraph("inside a cell")), HorizontalAlign.CENTER))); + row.addParagraph("beside it"); + }))) + .contains("inside a cell") + .contains("beside it"); + } + + private static DocumentNode anchor(DocumentNode child) { + return new LayoutAnchorNode("", new LayoutAnchorId(new Object(), Kind.PROBE, 0), child); + } + + private static DocumentNode paragraph(String text) { + return new ParagraphBuilder().text(text).build(); + } + + private static String exported(Consumer content) { + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 260).margin(DocumentInsets.of(20)).create()) { + PageFlowBuilder flow = session.pageFlow(); + content.accept(flow); + flow.build(); + byte[] docx = session.export(new DocxSemanticBackend()); + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx)); + XWPFWordExtractor extractor = new XWPFWordExtractor(document)) { + return extractor.getText(); + } + } catch (Exception failure) { + throw new IllegalStateException("export failed", failure); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/backend/TimelineRailAcrossBackendsTest.java b/qa/src/test/java/com/demcha/compose/document/backend/TimelineRailAcrossBackendsTest.java new file mode 100644 index 000000000..c932e04cc --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/backend/TimelineRailAcrossBackendsTest.java @@ -0,0 +1,134 @@ +package com.demcha.compose.document.backend; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xwpf.extractor.XWPFWordExtractor; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * What each backend does with a timeline whose rail is post-layout geometry. + * + *

The rail stopped being a border on a semantic node and became a fragment contributed + * after layout. That is fine for a backend that consumes a {@code LayoutGraph} and + * invisible to one that walks the semantic tree, so the three are asked separately rather + * than assumed to agree.

+ * + *
    + *
  • PDF and PPTX are fixed-layout: they see the rail and draw it. The + * payload is the one a section's own decoration already uses, so no handler had to + * be invented — which is exactly what this checks, since a payload no backend knows + * fails at export rather than at layout.
  • + *
  • DOCX is semantic: {@code DocxSemanticBackend} never sees a layout graph, so + * it cannot see the rail. The contract is that the timeline's content still + * exports, that the export does not throw, and that the omission is written down — + * not that a warning is raised, which this architecture cannot produce.
  • + *
+ */ +class TimelineRailAcrossBackendsTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + + @Test + void pdfDrawsTheRailAndTheTimelineRendersUnchangedByTheBackendChoice() throws Exception { + try (DocumentSession session = timeline()) { + assertThat(session.toPdfBytes()).isNotEmpty(); + assertThat(session.layoutGraph().fragments()) + .as("the rail reached the graph the pdf backend consumes") + .anySatisfy(f -> assertThat(f.path()).isEqualTo("@timeline-rail")); + } + } + + @Test + void pptxRendersARailBearingTimelineWithoutInventingAHandler() throws Exception { + // The rail's payload is a shape, which every fixed backend already handles. If it + // were not, this would throw at export — the failure mode a layout test cannot see. + try (DocumentSession session = timeline()) { + byte[] deck = session.toPptxBytes(); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(deck))) { + assertThat(show.getSlides()).isNotEmpty(); + assertThat(show.getSlides().get(0).getShapes()) + .as("the slide carries the timeline") + .isNotEmpty(); + } + } + } + + @Test + void docxExportsTheTimelinesContentWithoutTheRailAndWithoutThrowing() throws Exception { + // The documented contract, asserted rather than described. DOCX walks the semantic + // tree; the rail is not in it, so the rail is absent by construction — and the + // entries' text has to be there all the same. + try (DocumentSession session = timeline()) { + assertThat(docxText(session)) + .as("every entry's content survives the export") + .contains("Senior Engineer") + .contains("Engineer") + .contains("Led the layout engine rewrite."); + } + } + + @Test + void theTextExtractorItselfSeesARowsCells() throws Exception { + // The control. A RowNode exports as a one-row table, so document.getParagraphs() + // alone cannot see anything laid out in a row — a timeline's title and meta among + // them. If this assertion fails, the extractor is the problem and not the backend. + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 260).margin(DocumentInsets.of(20)).create()) { + PageFlowBuilder flow = session.pageFlow(); + flow.addRow(row -> row.addParagraph("left cell").addParagraph("right cell")); + flow.build(); + + assertThat(docxText(session)).contains("left cell").contains("right cell"); + } + } + + /** Everything the export says, paragraphs and table cells alike. */ + private static String docxText(DocumentSession session) { + byte[][] out = new byte[1][]; + assertThatCode(() -> out[0] = session.export(new DocxSemanticBackend())) + .as("a rail the semantic backend cannot see must not fail the export") + .doesNotThrowAnyException(); + assertThat(out[0]).isNotEmpty(); + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(out[0])); + XWPFWordExtractor extractor = new XWPFWordExtractor(document)) { + return extractor.getText(); + } catch (Exception failure) { + throw new IllegalStateException("could not read the exported document", failure); + } + } + + private static DocumentSession timeline() throws Exception { + DocumentSession session = GraphCompose.document() + .pageSize(360, 260) + .margin(DocumentInsets.of(20)) + .create(); + PageFlowBuilder flow = session.pageFlow(); + content().accept(flow); + flow.build(); + return session; + } + + private static Consumer content() { + return flow -> flow.addTimeline(t -> t + .connector(RAIL, 1.5) + .entry(TimelineMarker.dot(8, INK), e -> e + .title("Senior Engineer").meta("2023 - Present") + .body("Led the layout engine rewrite.")) + .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), e -> e + .title("Engineer").meta("2021 - 2023"))); + } +} diff --git a/qa/src/test/java/com/demcha/testing/visual/TimelineRailVisualTest.java b/qa/src/test/java/com/demcha/testing/visual/TimelineRailVisualTest.java new file mode 100644 index 000000000..cac5344a0 --- /dev/null +++ b/qa/src/test/java/com/demcha/testing/visual/TimelineRailVisualTest.java @@ -0,0 +1,332 @@ +package com.demcha.testing.visual; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.TimelineMarker; +import com.demcha.compose.document.dsl.TimelineRailExtent; +import com.demcha.compose.document.node.EllipseNode; +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.DocumentStroke; +import com.demcha.compose.testing.visual.PdfVisualRegression; +import org.junit.jupiter.api.Test; + +/** + * What a timeline looks like: the finished visual model, one baseline per scene. + * + *

The first of these was recorded before the rail rework, to catch what a coordinate + * cannot: a rail in the wrong colour, or not drawn at all, moves no geometry and passes every + * snapshot. It has not changed since, which is the compatibility claim of the whole rework and + * the reason it is still here.

+ * + *

These images are a coarse net, deliberately — see the budget below. The sharp claims + * about paint order live where a platform cannot blur them: on the fragment list, and in a + * render that counts rail-coloured pixels rather than comparing two pictures.

+ * + *

The rest are the scenes the rework made possible or made ambiguous — markers strung on + * the line rather than beside it, a rail trimmed to the outer markers, a rail crossing pages, + * a ring the line disappears behind. Each is paired with an assertion in + * {@code TimelineVisualScenarioGeometryTest} that says in numbers what the picture shows, and + * the two extents are drawn on one identical scene so the pair can be read as a diff.

+ * + *

Deliberately small pages. A full-A4 baseline drifts across platforms by more than the + * signal it carries; a tight page keeps the comparison meaningful.

+ */ +class TimelineRailVisualTest { + + private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172); + private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70); + + // The baselines are committed as Windows-rendered PNGs and compared on Linux CI, where + // PDFBox text rasterisation drifts: measured on these very scenes, 716 to 2 539 pixels of + // a structurally identical page, worst per-channel delta 202. A budget is therefore not + // optional, and the same one ShapeContainerVisualRegressionTest arrived at for the same + // reason is the right order of magnitude. + // + // Be clear about what that costs. A paint-order flip on these scenes moves 129 to 178 + // pixels — an order of magnitude *below* the drift — so these images cannot be the guard + // for it, and they are not: whether the rail is painted under the markers and under the + // text is asserted on the fragment list itself (TimelineRailGeometryTest, + // TimelineVisualScenarioGeometryTest), and whether it survives an opaque panel is asserted + // by counting rail-coloured pixels on a rendered page (TimelineCompatibilityTest), which + // asks a question no platform difference can answer wrongly. What these baselines catch is + // gross visual change: a rail not drawn at all, a marker missing, content moving column, + // a colour swapped, a scene reflowing. + private static final PdfVisualRegression VISUAL = PdfVisualRegression.standard() + .perPixelTolerance(6) + .mismatchedPixelBudget(3_000); + + // The paginated scene is a wall of body text on a small page, so text — the part that + // drifts — is most of the image: 6 598 pixels of 45 000 on the same comparison. Kept + // separate rather than loosening every scene to the worst one. + private static final PdfVisualRegression VISUAL_TEXT_DENSE = PdfVisualRegression.standard() + .perPixelTolerance(6) + .mismatchedPixelBudget(8_000); + + @Test + void classicTimelineLooksTheWayItDoesToday() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 240) + .margin(DocumentInsets.of(18)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(12) + .entry(TimelineMarker.dot(6, INK), e -> e + .title("Senior Engineer") + .meta("2023 - Present") + .body("Led the layout engine rewrite.")) + .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), e -> e + .title("Engineer") + .meta("2021 - 2023")) + .entry(TimelineMarker.square(9, INK), e -> e + .title("Junior Engineer") + .meta("2019 - 2021"))) + .build(); + + VISUAL.assertMatchesBaseline("timeline-dsl/classic", session); + } + } + + @Test + void aDateColumnRendersBesideTheRailAndTheMarkersStayInLine() throws Exception { + // The layout test says the columns line up; this says the page actually draws that + // way — that the dates are painted in a column of their own rather than wrapping + // into the marker's, and that the rail is still one line down the left. The third + // entry has no date, which is where an empty column would collapse if it did. + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 200) + .margin(DocumentInsets.of(18)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(12) + .leadingColumn(DocumentRowColumn.fixed(70)) + .entry(e -> e.marker(TimelineMarker.dot(6, INK)) + .leading(d -> d.addParagraph("2023")) + .title("Senior Engineer") + .body("Led the layout engine rewrite.")) + .entry(e -> e.marker(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE)) + .leading(d -> d.addParagraph("Sept 2021")) + .title("Engineer")) + .entry(e -> e.marker(TimelineMarker.square(9, INK)) + .title("No date at all"))) + .build(); + + VISUAL.assertMatchesBaseline("timeline-dsl/leading-column", session); + } + } + + @Test + void aCustomMarkerIsDrawnWhateverItIsMadeOf() throws Exception { + // A marker the timeline has never heard of: a ring, a disc and a pip stacked, and + // a bordered pill. Both declare their own box and neither needed a line in + // TimelineBuilder. The baseline is here because "it lays out" and "it is painted" + // are different claims — a marker recipe that drew nothing would pass every layout + // assertion in this repository. + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 170) + .margin(DocumentInsets.of(18)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(12) + .axisWidth(22) + .entry(TimelineMarker.custom(18, 18, column -> column + .addLayerStack(stack -> stack + .back(circle(18, INK)) + .center(circle(11, DocumentColor.WHITE)) + .center(circle(5, INK)))), + e -> e.title("Composed of three").meta("one declared box")) + .entry(TimelineMarker.custom(22, 12, column -> column + .addShape(shape -> shape + .size(22, 12) + .cornerRadius(6) + .fillColor(DocumentColor.WHITE) + .stroke(DocumentStroke.of(INK, 1.0)) + .margin(DocumentInsets.zero()))), + e -> e.title("Not square either"))) + .build(); + + VISUAL.assertMatchesBaseline("timeline-dsl/custom-marker", session); + } + } + + @Test + void markersOfEverySizeSitOnOneRailWhenTheyAreAnchoredToIt() throws Exception { + // DATE | ● | CONTENT, with markers of 6, 14 and 24pt. The geometry is asserted in + // TimelineRailGeometryTest — every marker's centre is the rail's x, to 1e-9. This + // is the picture of it: one straight axis with three very different markers strung + // on it, and the dates in their own column to its left. + try (DocumentSession session = GraphCompose.document() + .pageSize(360, 210) + .margin(DocumentInsets.of(18)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(12) + .markerOnRail() + .axisWidth(28) + .leadingColumn(DocumentRowColumn.fixed(54)) + .entry(e -> e.marker(TimelineMarker.dot(6, INK)) + .leading(d -> d.addParagraph("2023")) + .title("Senior Engineer") + .body("A small dot, centred on the axis.")) + .entry(e -> e.marker(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE)) + .leading(d -> d.addParagraph("2021")) + .title("Engineer") + .body("A numbered disc, centred on the same axis.")) + .entry(e -> e.marker(TimelineMarker.square(24, INK)) + .leading(d -> d.addParagraph("2019")) + .title("Junior Engineer") + .body("And a square four times the dot's size."))) + .build(); + + VISUAL.assertMatchesBaseline("timeline-dsl/marker-on-rail", session); + } + } + + @Test + void markersOfEverySizeSitOnOneRailWithNoDateColumnToHelp() throws Exception { + // The same three sizes as the scene above, with the date column taken away. Two + // pictures rather than one because a leading column is the first thing suspected + // when a line bends, and here there is none to suspect: 6, 14 and 24pt on one axis. + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 200) + .margin(DocumentInsets.of(18)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(12) + .markerOnRail() + .axisWidth(28) + .entry(TimelineMarker.dot(6, INK), e -> e + .title("Small").body("Six points across.")) + .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), e -> e + .title("Medium").body("Fourteen, and numbered.")) + .entry(TimelineMarker.square(24, INK), e -> e + .title("Large").body("Twenty-four, and square."))) + .build(); + + VISUAL.assertMatchesBaseline("timeline-dsl/marker-on-rail-sizes", session); + } + } + + @Test + void entryBoundsRunsThroughEveryEntryAndOverTheSpacingBetweenThem() throws Exception { + // Half of a pair: this scene and the next differ by one argument and nothing else. + // Entries of deliberately different heights, 16pt apart — and the line covers the + // gaps, because an entry's spacing is padding inside its own box rather than a hole + // between two boxes. That is the default every existing timeline draws. + try (DocumentSession session = extentScene(TimelineRailExtent.ENTRY_BOUNDS)) { + VISUAL.assertMatchesBaseline("timeline-dsl/entry-bounds", session); + } + } + + @Test + void markerToMarkerStopsAtTheOuterMarkersOnTheVerySameScene() throws Exception { + // The other half. Same page, same entries, same rail — and now the line begins at + // the first marker and ends at the last, with the tall entry's body hanging below + // it. Read against its twin, the diff is the two ends and nothing else. + try (DocumentSession session = extentScene(TimelineRailExtent.MARKER_TO_MARKER)) { + VISUAL.assertMatchesBaseline("timeline-dsl/marker-to-marker", session); + } + } + + @Test + void aPaginatedTimelineDrawsItsRailOnEveryPageItReaches() throws Exception { + // Three pages out of two entries, and the only scene here that a single page cannot + // show at all: the line starts at the first marker, runs the full band of the page + // that holds nothing but the first entry's body, and stops at the second marker on + // the last. Three fragments of one logical rail, each bounded by its own page. + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 150) + .margin(DocumentInsets.of(18)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(14) + .markerOnRail() + .axisWidth(24) + .rail(rail -> rail.extent(TimelineRailExtent.MARKER_TO_MARKER)) + .entry(TimelineMarker.dot(10, INK), e -> e + .title("Runs on").body(longBody())) + .entry(TimelineMarker.dot(10, INK), e -> e + .title("And ends here"))) + .build(); + + VISUAL_TEXT_DENSE.assertMatchesBaseline("timeline-dsl/paginated-marker-to-marker", session); + } + } + + @Test + void aHollowMarkerBreaksTheRailCleanlyBecauseTheRailIsUnderIt() throws Exception { + // TimelineMarker.circle(size, fill, stroke) with the page's own colour as the fill: + // where the line passes through a ring it is covered, so it reads as broken at each + // stop instead of crossing three of them. Pure paint order — the geometry is + // identical either way — which is why only a picture can be the evidence. + DocumentStroke ring = DocumentStroke.of(INK, 1.2); + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 210) + .margin(DocumentInsets.of(18)) + .create()) { + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(12) + .markerOnRail() + .axisWidth(26) + .entry(TimelineMarker.circle(10, DocumentColor.WHITE, ring), e -> e + .title("Hollow").body("The line stops inside the ring.")) + .entry(TimelineMarker.circle(14, DocumentColor.WHITE, ring), e -> e + .title("Hollow, larger").body("And starts again below it.")) + .entry(TimelineMarker.circle(20, DocumentColor.WHITE, ring), e -> e + .title("Hollow, larger still").body("Whatever the diameter."))) + .build(); + + VISUAL.assertMatchesBaseline("timeline-dsl/outlined-marker", session); + } + } + + /** The scene both extent baselines draw; they differ by this argument and nothing else. */ + private static DocumentSession extentScene(TimelineRailExtent extent) throws Exception { + DocumentSession session = GraphCompose.document() + .pageSize(300, 250) + .margin(DocumentInsets.of(18)) + .create(); + session.pageFlow() + .addTimeline(t -> t + .connector(RAIL, 1.5) + .spacing(16) + .rail(rail -> rail.extent(extent)) + .entry(TimelineMarker.dot(9, INK), e -> e + .title("Tall entry").meta("2023 - Present") + .body("A body long enough to run to three lines on a page this " + + "narrow, so that this entry is plainly the tallest here.")) + .entry(TimelineMarker.dot(9, INK), e -> e.title("One line only")) + .entry(TimelineMarker.dot(9, INK), e -> e + .title("Middling").body("Two lines, more or less."))) + .build(); + return session; + } + + /** Long enough to take the first entry across two page breaks on a 150pt page. */ + private static String longBody() { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < 40; i++) { + body.append("Sentence ").append(i).append(" of a body that keeps going. "); + } + return body.toString(); + } + + private static EllipseNode circle(double size, DocumentColor fill) { + return new EllipseNode("marker", size, size, fill, null, null, null, null, null); + } +} diff --git a/qa/src/test/resources/layout-snapshots/document/timeline_classic.json b/qa/src/test/resources/layout-snapshots/document/timeline_classic.json new file mode 100644 index 000000000..f750283f5 --- /dev/null +++ b/qa/src/test/resources/layout-snapshots/document/timeline_classic.json @@ -0,0 +1,1247 @@ +{ + "formatVersion" : "2.0", + "canvas" : { + "pageWidth" : 320.0, + "pageHeight" : 260.0, + "innerWidth" : 280.0, + "innerHeight" : 220.0, + "margin" : { + "top" : 20.0, + "right" : 20.0, + "bottom" : 20.0, + "left" : 20.0 + } + }, + "totalPages" : 1, + "nodes" : [ { + "path" : "ContainerNode[0]", + "entityName" : null, + "entityKind" : "ContainerNode", + "parentPath" : null, + "childIndex" : 0, + "depth" : 1, + "layer" : 1, + "computedX" : 20.0, + "computedY" : 126.312, + "placementX" : 20.0, + "placementY" : 126.312, + "placementWidth" : 280.0, + "placementHeight" : 113.688, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 113.688, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]", + "childIndex" : 0, + "depth" : 2, + "layer" : 2, + "computedX" : 20.0, + "computedY" : 126.312, + "placementX" : 20.0, + "placementY" : 126.312, + "placementWidth" : 280.0, + "placementHeight" : 113.688, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 113.688, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 3, + "layer" : 3, + "computedX" : 20.0, + "computedY" : 193.175, + "placementX" : 20.0, + "placementY" : 193.175, + "placementWidth" : 280.0, + "placementHeight" : 46.825, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 46.825, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 20.0, + "computedY" : 193.175, + "placementX" : 20.0, + "placementY" : 193.175, + "placementWidth" : 280.0, + "placementHeight" : 46.825, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 46.825, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 14.0, + "left" : 8.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "entityName" : null, + "entityKind" : "RowNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 219.962, + "placementX" : 28.0, + "placementY" : 219.962, + "placementWidth" : 272.0, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 272.0, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.0, + "computedY" : 232.0, + "placementX" : 28.0, + "placementY" : 232.0, + "placementWidth" : 24.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 24.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "entityName" : null, + "entityKind" : "Align", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 28.0, + "computedY" : 232.0, + "placementX" : 28.0, + "placementY" : 232.0, + "placementWidth" : 24.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 24.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "childIndex" : 0, + "depth" : 8, + "layer" : 8, + "computedX" : 28.0, + "computedY" : 232.0, + "placementX" : 28.0, + "placementY" : 232.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "entityName" : "marker", + "entityKind" : "CanvasLayerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 9, + "layer" : 9, + "computedX" : 28.0, + "computedY" : 232.0, + "placementX" : 28.0, + "placementY" : 232.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "childIndex" : 0, + "depth" : 10, + "layer" : 10, + "computedX" : 28.0, + "computedY" : 232.0, + "placementX" : 28.0, + "placementY" : 232.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/EllipseNode[0]", + "entityName" : null, + "entityKind" : "EllipseNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 11, + "layer" : 11, + "computedX" : 28.0, + "computedY" : 232.0, + "placementX" : 28.0, + "placementY" : 232.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 60.0, + "computedY" : 219.962, + "placementX" : 60.0, + "placementY" : 219.962, + "placementWidth" : 84.359, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 84.359, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 229.825, + "placementX" : 60.0, + "placementY" : 229.825, + "placementWidth" : 84.359, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 84.359, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 1, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 219.962, + "placementX" : 60.0, + "placementY" : 219.962, + "placementWidth" : 55.752, + "placementHeight" : 7.863, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 55.752, + "contentHeight" : 7.863, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 207.175, + "placementX" : 28.0, + "placementY" : 207.175, + "placementWidth" : 124.621, + "placementHeight" : 8.787, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 124.621, + "contentHeight" : 8.787, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]", + "childIndex" : 1, + "depth" : 3, + "layer" : 3, + "computedX" : 20.0, + "computedY" : 146.35, + "placementX" : 20.0, + "placementY" : 146.35, + "placementWidth" : 280.0, + "placementHeight" : 46.825, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 46.825, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 20.0, + "computedY" : 146.35, + "placementX" : 20.0, + "placementY" : 146.35, + "placementWidth" : 280.0, + "placementHeight" : 46.825, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 46.825, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 14.0, + "left" : 8.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "entityName" : null, + "entityKind" : "RowNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 173.137, + "placementX" : 28.0, + "placementY" : 173.137, + "placementWidth" : 272.0, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 272.0, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.0, + "computedY" : 179.175, + "placementX" : 28.0, + "placementY" : 179.175, + "placementWidth" : 24.0, + "placementHeight" : 14.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 24.0, + "contentHeight" : 14.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "entityName" : null, + "entityKind" : "Align", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 28.0, + "computedY" : 179.175, + "placementX" : 28.0, + "placementY" : 179.175, + "placementWidth" : 24.0, + "placementHeight" : 14.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 24.0, + "contentHeight" : 14.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "childIndex" : 0, + "depth" : 8, + "layer" : 8, + "computedX" : 28.0, + "computedY" : 179.175, + "placementX" : 28.0, + "placementY" : 179.175, + "placementWidth" : 14.0, + "placementHeight" : 14.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 14.0, + "contentHeight" : 14.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "entityName" : "marker", + "entityKind" : "CanvasLayerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 9, + "layer" : 9, + "computedX" : 28.0, + "computedY" : 179.175, + "placementX" : 28.0, + "placementY" : 179.175, + "placementWidth" : 14.0, + "placementHeight" : 14.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 14.0, + "contentHeight" : 14.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "childIndex" : 0, + "depth" : 10, + "layer" : 10, + "computedX" : 28.0, + "computedY" : 179.175, + "placementX" : 28.0, + "placementY" : 179.175, + "placementWidth" : 14.0, + "placementHeight" : 14.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 14.0, + "contentHeight" : 14.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/ShapeContainerNode[0]", + "entityName" : null, + "entityKind" : "ShapeContainerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 11, + "layer" : 11, + "computedX" : 28.0, + "computedY" : 179.175, + "placementX" : 28.0, + "placementY" : 179.175, + "placementWidth" : 14.0, + "placementHeight" : 14.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 14.0, + "contentHeight" : 14.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/ShapeContainerNode[0]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/ShapeContainerNode[0]", + "childIndex" : 0, + "depth" : 12, + "layer" : 12, + "computedX" : 28.0, + "computedY" : 182.937, + "placementX" : 28.0, + "placementY" : 182.937, + "placementWidth" : 14.0, + "placementHeight" : 6.475, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 14.0, + "contentHeight" : 6.475, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 60.0, + "computedY" : 173.137, + "placementX" : 60.0, + "placementY" : 173.137, + "placementWidth" : 47.069, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 47.069, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 183.0, + "placementX" : 60.0, + "placementY" : 183.0, + "placementWidth" : 47.069, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 47.069, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 1, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 173.137, + "placementX" : 60.0, + "placementY" : 173.137, + "placementWidth" : 45.365, + "placementHeight" : 7.863, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 45.365, + "contentHeight" : 7.863, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 160.35, + "placementX" : 28.0, + "placementY" : 160.35, + "placementWidth" : 138.349, + "placementHeight" : 8.787, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 138.349, + "contentHeight" : 8.787, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]", + "childIndex" : 2, + "depth" : 3, + "layer" : 3, + "computedX" : 20.0, + "computedY" : 126.312, + "placementX" : 20.0, + "placementY" : 126.312, + "placementWidth" : 280.0, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 20.0, + "computedY" : 126.312, + "placementX" : 20.0, + "placementY" : 126.312, + "placementWidth" : 280.0, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 8.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]", + "entityName" : null, + "entityKind" : "RowNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 126.312, + "placementX" : 28.0, + "placementY" : 126.312, + "placementWidth" : 272.0, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 272.0, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.0, + "computedY" : 138.35, + "placementX" : 28.0, + "placementY" : 138.35, + "placementWidth" : 24.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 24.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "entityName" : null, + "entityKind" : "Align", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 28.0, + "computedY" : 138.35, + "placementX" : 28.0, + "placementY" : 138.35, + "placementWidth" : 24.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 24.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "childIndex" : 0, + "depth" : 8, + "layer" : 8, + "computedX" : 28.0, + "computedY" : 138.35, + "placementX" : 28.0, + "placementY" : 138.35, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "entityName" : "marker", + "entityKind" : "CanvasLayerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 9, + "layer" : 9, + "computedX" : 28.0, + "computedY" : 138.35, + "placementX" : 28.0, + "placementY" : 138.35, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "childIndex" : 0, + "depth" : 10, + "layer" : 10, + "computedX" : 28.0, + "computedY" : 138.35, + "placementX" : 28.0, + "placementY" : 138.35, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/TimelineMarkerSquare[0]", + "entityName" : "TimelineMarkerSquare", + "entityKind" : "ShapeNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 11, + "layer" : 11, + "computedX" : 28.0, + "computedY" : 138.35, + "placementX" : 28.0, + "placementY" : 138.35, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 60.0, + "computedY" : 126.312, + "placementX" : 60.0, + "placementY" : 126.312, + "placementWidth" : 83.743, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 83.743, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 136.175, + "placementX" : 60.0, + "placementY" : 136.175, + "placementWidth" : 83.743, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 83.743, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 1, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 126.312, + "placementX" : 60.0, + "placementY" : 126.312, + "placementWidth" : 45.365, + "placementHeight" : 7.863, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 45.365, + "contentHeight" : 7.863, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + } ] +} diff --git a/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json b/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json new file mode 100644 index 000000000..10014b031 --- /dev/null +++ b/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json @@ -0,0 +1,1667 @@ +{ + "formatVersion" : "2.0", + "canvas" : { + "pageWidth" : 360.0, + "pageHeight" : 260.0, + "innerWidth" : 320.0, + "innerHeight" : 220.0, + "margin" : { + "top" : 20.0, + "right" : 20.0, + "bottom" : 20.0, + "left" : 20.0 + } + }, + "totalPages" : 1, + "nodes" : [ { + "path" : "ContainerNode[0]", + "entityName" : null, + "entityKind" : "ContainerNode", + "parentPath" : null, + "childIndex" : 0, + "depth" : 1, + "layer" : 1, + "computedX" : 20.0, + "computedY" : 97.5, + "placementX" : 20.0, + "placementY" : 97.5, + "placementWidth" : 320.0, + "placementHeight" : 142.5, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 142.5, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]", + "childIndex" : 0, + "depth" : 2, + "layer" : 2, + "computedX" : 20.0, + "computedY" : 97.5, + "placementX" : 20.0, + "placementY" : 97.5, + "placementWidth" : 320.0, + "placementHeight" : 142.5, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 142.5, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 3, + "layer" : 3, + "computedX" : 20.0, + "computedY" : 200.262, + "placementX" : 20.0, + "placementY" : 200.262, + "placementWidth" : 320.0, + "placementHeight" : 39.738, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 39.738, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 20.0, + "computedY" : 200.262, + "placementX" : 20.0, + "placementY" : 200.262, + "placementWidth" : 320.0, + "placementHeight" : 39.738, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 39.738, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 14.0, + "left" : 8.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "entityName" : null, + "entityKind" : "RowNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 227.05, + "placementX" : 28.0, + "placementY" : 227.05, + "placementWidth" : 312.0, + "placementHeight" : 12.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 312.0, + "contentHeight" : 12.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.0, + "computedY" : 227.05, + "placementX" : 28.0, + "placementY" : 227.05, + "placementWidth" : 31.136, + "placementHeight" : 12.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 31.136, + "contentHeight" : 12.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 28.0, + "computedY" : 227.05, + "placementX" : 28.0, + "placementY" : 227.05, + "placementWidth" : 31.136, + "placementHeight" : 12.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 31.136, + "contentHeight" : 12.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 92.0, + "computedY" : 232.0, + "placementX" : 92.0, + "placementY" : 232.0, + "placementWidth" : 21.818, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 21.818, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]", + "entityName" : null, + "entityKind" : "Align", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 92.0, + "computedY" : 232.0, + "placementX" : 92.0, + "placementY" : 232.0, + "placementWidth" : 21.818, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 21.818, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]", + "childIndex" : 0, + "depth" : 8, + "layer" : 8, + "computedX" : 92.0, + "computedY" : 232.0, + "placementX" : 92.0, + "placementY" : 232.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "entityName" : "marker", + "entityKind" : "CanvasLayerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 9, + "layer" : 9, + "computedX" : 92.0, + "computedY" : 232.0, + "placementX" : 92.0, + "placementY" : 232.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "childIndex" : 0, + "depth" : 10, + "layer" : 10, + "computedX" : 92.0, + "computedY" : 232.0, + "placementX" : 92.0, + "placementY" : 232.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/EllipseNode[0]", + "entityName" : null, + "entityKind" : "EllipseNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 11, + "layer" : 11, + "computedX" : 92.0, + "computedY" : 232.0, + "placementX" : 92.0, + "placementY" : 232.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "childIndex" : 2, + "depth" : 6, + "layer" : 6, + "computedX" : 121.818, + "computedY" : 229.825, + "placementX" : 121.818, + "placementY" : 229.825, + "placementWidth" : 84.359, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 84.359, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 121.818, + "computedY" : 229.825, + "placementX" : 121.818, + "placementY" : 229.825, + "placementWidth" : 84.359, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 84.359, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 214.262, + "placementX" : 28.0, + "placementY" : 214.262, + "placementWidth" : 124.621, + "placementHeight" : 8.787, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 124.621, + "contentHeight" : 8.787, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]", + "childIndex" : 1, + "depth" : 3, + "layer" : 3, + "computedX" : 20.0, + "computedY" : 134.625, + "placementX" : 20.0, + "placementY" : 134.625, + "placementWidth" : 320.0, + "placementHeight" : 65.638, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 65.638, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 20.0, + "computedY" : 134.625, + "placementX" : 20.0, + "placementY" : 134.625, + "placementWidth" : 320.0, + "placementHeight" : 65.638, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 65.638, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 14.0, + "left" : 8.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "entityName" : null, + "entityKind" : "RowNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 161.412, + "placementX" : 28.0, + "placementY" : 161.412, + "placementWidth" : 312.0, + "placementHeight" : 38.85, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 312.0, + "contentHeight" : 38.85, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.0, + "computedY" : 161.412, + "placementX" : 28.0, + "placementY" : 161.412, + "placementWidth" : 55.258, + "placementHeight" : 38.85, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 55.258, + "contentHeight" : 38.85, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 28.0, + "computedY" : 161.412, + "placementX" : 28.0, + "placementY" : 161.412, + "placementWidth" : 55.258, + "placementHeight" : 38.85, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 55.258, + "contentHeight" : 38.85, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 92.0, + "computedY" : 192.262, + "placementX" : 92.0, + "placementY" : 192.262, + "placementWidth" : 21.818, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 21.818, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]", + "entityName" : null, + "entityKind" : "Align", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 92.0, + "computedY" : 192.262, + "placementX" : 92.0, + "placementY" : 192.262, + "placementWidth" : 21.818, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 21.818, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]", + "childIndex" : 0, + "depth" : 8, + "layer" : 8, + "computedX" : 92.0, + "computedY" : 192.262, + "placementX" : 92.0, + "placementY" : 192.262, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "entityName" : "marker", + "entityKind" : "CanvasLayerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 9, + "layer" : 9, + "computedX" : 92.0, + "computedY" : 192.262, + "placementX" : 92.0, + "placementY" : 192.262, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "childIndex" : 0, + "depth" : 10, + "layer" : 10, + "computedX" : 92.0, + "computedY" : 192.262, + "placementX" : 92.0, + "placementY" : 192.262, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/EllipseNode[0]", + "entityName" : null, + "entityKind" : "EllipseNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 11, + "layer" : 11, + "computedX" : 92.0, + "computedY" : 192.262, + "placementX" : 92.0, + "placementY" : 192.262, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[2]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "childIndex" : 2, + "depth" : 6, + "layer" : 6, + "computedX" : 121.818, + "computedY" : 190.087, + "placementX" : 121.818, + "placementY" : 190.087, + "placementWidth" : 47.069, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 47.069, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[2]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[2]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 121.818, + "computedY" : 190.087, + "placementX" : 121.818, + "placementY" : 190.087, + "placementWidth" : 47.069, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 47.069, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 148.625, + "placementX" : 28.0, + "placementY" : 148.625, + "placementWidth" : 138.349, + "placementHeight" : 8.787, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 138.349, + "contentHeight" : 8.787, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]", + "childIndex" : 2, + "depth" : 3, + "layer" : 3, + "computedX" : 20.0, + "computedY" : 107.675, + "placementX" : 20.0, + "placementY" : 107.675, + "placementWidth" : 320.0, + "placementHeight" : 26.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 26.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 20.0, + "computedY" : 107.675, + "placementX" : 20.0, + "placementY" : 107.675, + "placementWidth" : 320.0, + "placementHeight" : 26.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 26.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 14.0, + "left" : 8.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]", + "entityName" : null, + "entityKind" : "RowNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 121.675, + "placementX" : 28.0, + "placementY" : 121.675, + "placementWidth" : 312.0, + "placementHeight" : 12.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 312.0, + "contentHeight" : 12.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.0, + "computedY" : 121.675, + "placementX" : 28.0, + "placementY" : 121.675, + "placementWidth" : 18.676, + "placementHeight" : 12.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 18.676, + "contentHeight" : 12.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 28.0, + "computedY" : 121.675, + "placementX" : 28.0, + "placementY" : 121.675, + "placementWidth" : 18.676, + "placementHeight" : 12.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 18.676, + "contentHeight" : 12.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 92.0, + "computedY" : 126.625, + "placementX" : 92.0, + "placementY" : 126.625, + "placementWidth" : 21.818, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 21.818, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]", + "entityName" : null, + "entityKind" : "Align", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 92.0, + "computedY" : 126.625, + "placementX" : 92.0, + "placementY" : 126.625, + "placementWidth" : 21.818, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 21.818, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]", + "childIndex" : 0, + "depth" : 8, + "layer" : 8, + "computedX" : 92.0, + "computedY" : 126.625, + "placementX" : 92.0, + "placementY" : 126.625, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "entityName" : "marker", + "entityKind" : "CanvasLayerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 9, + "layer" : 9, + "computedX" : 92.0, + "computedY" : 126.625, + "placementX" : 92.0, + "placementY" : 126.625, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "childIndex" : 0, + "depth" : 10, + "layer" : 10, + "computedX" : 92.0, + "computedY" : 126.625, + "placementX" : 92.0, + "placementY" : 126.625, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/TimelineMarkerSquare[0]", + "entityName" : "TimelineMarkerSquare", + "entityKind" : "ShapeNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 11, + "layer" : 11, + "computedX" : 92.0, + "computedY" : 126.625, + "placementX" : 92.0, + "placementY" : 126.625, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[2]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]", + "childIndex" : 2, + "depth" : 6, + "layer" : 6, + "computedX" : 121.818, + "computedY" : 124.45, + "placementX" : 121.818, + "placementY" : 124.45, + "placementWidth" : 83.743, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 83.743, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[2]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[2]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 121.818, + "computedY" : 124.45, + "placementX" : 121.818, + "placementY" : 124.45, + "placementWidth" : 83.743, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 83.743, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]", + "childIndex" : 3, + "depth" : 3, + "layer" : 3, + "computedX" : 20.0, + "computedY" : 97.5, + "placementX" : 20.0, + "placementY" : 97.5, + "placementWidth" : 320.0, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 20.0, + "computedY" : 97.5, + "placementX" : 20.0, + "placementY" : 97.5, + "placementWidth" : 320.0, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 320.0, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 8.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]", + "entityName" : null, + "entityKind" : "RowNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 97.5, + "placementX" : 28.0, + "placementY" : 97.5, + "placementWidth" : 312.0, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 312.0, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.0, + "computedY" : 107.675, + "placementX" : 28.0, + "placementY" : 107.675, + "placementWidth" : 0.0, + "placementHeight" : 0.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 0.0, + "contentHeight" : 0.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 92.0, + "computedY" : 99.675, + "placementX" : 92.0, + "placementY" : 99.675, + "placementWidth" : 21.818, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 21.818, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]", + "entityName" : null, + "entityKind" : "Align", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 92.0, + "computedY" : 99.675, + "placementX" : 92.0, + "placementY" : 99.675, + "placementWidth" : 21.818, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 21.818, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]", + "childIndex" : 0, + "depth" : 8, + "layer" : 8, + "computedX" : 92.0, + "computedY" : 99.675, + "placementX" : 92.0, + "placementY" : 99.675, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "entityName" : "marker", + "entityKind" : "CanvasLayerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 9, + "layer" : 9, + "computedX" : 92.0, + "computedY" : 99.675, + "placementX" : 92.0, + "placementY" : 99.675, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "childIndex" : 0, + "depth" : 10, + "layer" : 10, + "computedX" : 92.0, + "computedY" : 99.675, + "placementX" : 92.0, + "placementY" : 99.675, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/EllipseNode[0]", + "entityName" : null, + "entityKind" : "EllipseNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 11, + "layer" : 11, + "computedX" : 92.0, + "computedY" : 99.675, + "placementX" : 92.0, + "placementY" : 99.675, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[2]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]", + "childIndex" : 2, + "depth" : 6, + "layer" : 6, + "computedX" : 121.818, + "computedY" : 97.5, + "placementX" : 121.818, + "placementY" : 97.5, + "placementWidth" : 68.464, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 68.464, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[2]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[2]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 121.818, + "computedY" : 97.5, + "placementX" : 121.818, + "placementY" : 97.5, + "placementWidth" : 68.464, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 68.464, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + } ] +} diff --git a/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json b/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json new file mode 100644 index 000000000..a5d3b88f4 --- /dev/null +++ b/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json @@ -0,0 +1,857 @@ +{ + "formatVersion" : "2.0", + "canvas" : { + "pageWidth" : 320.0, + "pageHeight" : 170.0, + "innerWidth" : 280.0, + "innerHeight" : 130.0, + "margin" : { + "top" : 20.0, + "right" : 20.0, + "bottom" : 20.0, + "left" : 20.0 + } + }, + "totalPages" : 2, + "nodes" : [ { + "path" : "ContainerNode[0]", + "entityName" : null, + "entityKind" : "ContainerNode", + "parentPath" : null, + "childIndex" : 0, + "depth" : 1, + "layer" : 1, + "computedX" : 20.0, + "computedY" : -10.35, + "placementX" : 20.0, + "placementY" : -10.35, + "placementWidth" : 280.0, + "placementHeight" : 160.35, + "startPage" : 0, + "endPage" : 1, + "contentWidth" : 280.0, + "contentHeight" : 160.35, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]", + "childIndex" : 0, + "depth" : 2, + "layer" : 2, + "computedX" : 20.0, + "computedY" : -10.35, + "placementX" : 20.0, + "placementY" : -10.35, + "placementWidth" : 280.0, + "placementHeight" : 160.35, + "startPage" : 0, + "endPage" : 1, + "contentWidth" : 280.0, + "contentHeight" : 160.35, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 3, + "layer" : 3, + "computedX" : 20.0, + "computedY" : 22.475, + "placementX" : 20.0, + "placementY" : 22.475, + "placementWidth" : 280.0, + "placementHeight" : 127.525, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 127.525, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 20.0, + "computedY" : 22.475, + "placementX" : 20.0, + "placementY" : 22.475, + "placementWidth" : 280.0, + "placementHeight" : 127.525, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 280.0, + "contentHeight" : 127.525, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 14.0, + "left" : 8.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "entityName" : null, + "entityKind" : "RowNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 129.962, + "placementX" : 28.0, + "placementY" : 129.962, + "placementWidth" : 272.0, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 272.0, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 24.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 24.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "entityName" : null, + "entityKind" : "Align", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 24.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 24.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "childIndex" : 0, + "depth" : 8, + "layer" : 8, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "entityName" : "marker", + "entityKind" : "CanvasLayerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 9, + "layer" : 9, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "childIndex" : 0, + "depth" : 10, + "layer" : 10, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/EllipseNode[0]", + "entityName" : null, + "entityKind" : "EllipseNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 11, + "layer" : 11, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 60.0, + "computedY" : 129.962, + "placementX" : 60.0, + "placementY" : 129.962, + "placementWidth" : 23.837, + "placementHeight" : 20.038, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 23.837, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 139.825, + "placementX" : 60.0, + "placementY" : 139.825, + "placementWidth" : 23.837, + "placementHeight" : 10.175, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 23.837, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 1, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 129.962, + "placementX" : 60.0, + "placementY" : 129.962, + "placementWidth" : 18.904, + "placementHeight" : 7.863, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 18.904, + "contentHeight" : 7.863, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 36.475, + "placementX" : 28.0, + "placementY" : 36.475, + "placementWidth" : 270.94, + "placementHeight" : 89.487, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 270.94, + "contentHeight" : 89.487, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]", + "childIndex" : 1, + "depth" : 3, + "layer" : 3, + "computedX" : 20.0, + "computedY" : -10.35, + "placementX" : 20.0, + "placementY" : -10.35, + "placementWidth" : 280.0, + "placementHeight" : 32.825, + "startPage" : 0, + "endPage" : 1, + "contentWidth" : 280.0, + "contentHeight" : 32.825, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 20.0, + "computedY" : -10.35, + "placementX" : 20.0, + "placementY" : -10.35, + "placementWidth" : 280.0, + "placementHeight" : 32.825, + "startPage" : 0, + "endPage" : 1, + "contentWidth" : 280.0, + "contentHeight" : 32.825, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 8.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "entityName" : null, + "entityKind" : "RowNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 129.962, + "placementX" : 28.0, + "placementY" : 129.962, + "placementWidth" : 272.0, + "placementHeight" : 20.038, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 272.0, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 24.0, + "placementHeight" : 8.0, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 24.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "entityName" : null, + "entityKind" : "Align", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 24.0, + "placementHeight" : 8.0, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 24.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "entityName" : null, + "entityKind" : "LayoutAnchorNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]", + "childIndex" : 0, + "depth" : 8, + "layer" : 8, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "entityName" : "marker", + "entityKind" : "CanvasLayerNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]", + "childIndex" : 0, + "depth" : 9, + "layer" : 9, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]", + "childIndex" : 0, + "depth" : 10, + "layer" : 10, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]/EllipseNode[0]", + "entityName" : null, + "entityKind" : "EllipseNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/Align[0]/LayoutAnchorNode[0]/marker[0]/SectionNode[0]", + "childIndex" : 0, + "depth" : 11, + "layer" : 11, + "computedX" : 28.0, + "computedY" : 142.0, + "placementX" : 28.0, + "placementY" : 142.0, + "placementWidth" : 8.0, + "placementHeight" : 8.0, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 8.0, + "contentHeight" : 8.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "entityName" : null, + "entityKind" : "SectionNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 60.0, + "computedY" : 129.962, + "placementX" : 60.0, + "placementY" : 129.962, + "placementWidth" : 39.732, + "placementHeight" : 20.038, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 39.732, + "contentHeight" : 20.038, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 139.825, + "placementX" : 60.0, + "placementY" : 139.825, + "placementWidth" : 39.732, + "placementHeight" : 10.175, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 39.732, + "contentHeight" : 10.175, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]", + "childIndex" : 1, + "depth" : 7, + "layer" : 7, + "computedX" : 60.0, + "computedY" : 129.962, + "placementX" : 60.0, + "placementY" : 129.962, + "placementWidth" : 18.904, + "placementHeight" : 7.863, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 18.904, + "contentHeight" : 7.863, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/ParagraphNode[1]", + "entityName" : null, + "entityKind" : "ParagraphNode", + "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 28.0, + "computedY" : 117.175, + "placementX" : 28.0, + "placementY" : 117.175, + "placementWidth" : 25.346, + "placementHeight" : 8.787, + "startPage" : 1, + "endPage" : 1, + "contentWidth" : 25.346, + "contentHeight" : 8.787, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + } ] +} diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/classic-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/classic-page-0.png new file mode 100644 index 000000000..3a4a08e5b Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/classic-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/custom-marker-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/custom-marker-page-0.png new file mode 100644 index 000000000..a8cb385d9 Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/custom-marker-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/entry-bounds-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/entry-bounds-page-0.png new file mode 100644 index 000000000..8fc3cc65f Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/entry-bounds-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/leading-column-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/leading-column-page-0.png new file mode 100644 index 000000000..0653af35b Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/leading-column-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/marker-on-rail-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/marker-on-rail-page-0.png new file mode 100644 index 000000000..9e51a3bd3 Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/marker-on-rail-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/marker-on-rail-sizes-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/marker-on-rail-sizes-page-0.png new file mode 100644 index 000000000..19880da7f Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/marker-on-rail-sizes-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/marker-to-marker-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/marker-to-marker-page-0.png new file mode 100644 index 000000000..b3c48b278 Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/marker-to-marker-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/outlined-marker-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/outlined-marker-page-0.png new file mode 100644 index 000000000..9baac577f Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/outlined-marker-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-0.png new file mode 100644 index 000000000..42a30018f Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-1.png b/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-1.png new file mode 100644 index 000000000..fbdeacd9f Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-2.png b/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-2.png new file mode 100644 index 000000000..cd1911595 Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-2.png differ diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-3.png b/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-3.png new file mode 100644 index 000000000..1b403c11b Binary files /dev/null and b/qa/src/test/resources/visual-baselines/timeline-dsl/paginated-marker-to-marker-page-3.png differ diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index df92f47a7..5078cd547 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -303,7 +303,8 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio writeList(document, list); } else if (node instanceof ContainerNode || node instanceof SectionNode || node instanceof com.demcha.compose.document.node.LayerStackNode - || node instanceof com.demcha.compose.document.node.CanvasLayerNode) { + || node instanceof com.demcha.compose.document.node.CanvasLayerNode + || isSemanticallyTransparent(node)) { // Overlay/positioned wrappers have no DOCX analogue for their // geometry, but their children can be semantic (text, images) — // render them sequentially rather than dropping the subtree. @@ -972,6 +973,24 @@ private void writeCellBody(XWPFTableCell cell, DocumentNode child) throws Except *

A wrapper contributes nothing of its own to a Word cell, so its children are * written in its place rather than the wrapper being dropped with them inside.

*/ + /** + * Whether a node exists only to say something about geometry, and so has nothing of its + * own to write here. + * + *

A layout anchor reports where its child landed and an alignment says where in the + * available width to put it. Word lays text out itself, so neither has an analogue — + * but both have exactly one child, and dropping a wrapper takes the content with it. + * The two walkers below ask this rather than each keeping its own list, because a + * wrapper missing from one of them loses a subtree the other would have kept.

+ * + * @param node the node being written + * @return true when the node itself writes nothing and its children should be written + */ + private static boolean isSemanticallyTransparent(DocumentNode node) { + return node instanceof com.demcha.compose.document.layout.LayoutAnchorNode + || node instanceof com.demcha.compose.document.node.AlignNode; + } + private void writeCellNode(XWPFTableCell cell, DocumentNode child) throws Exception { if (child instanceof ParagraphNode paragraph) { // Same walk as writeParagraph, all of it: a cell paragraph keeps per-run @@ -989,6 +1008,10 @@ private void writeCellNode(XWPFTableCell cell, DocumentNode child) throws Except for (DocumentNode grandChild : section.children()) { writeCellNode(cell, grandChild); } + } else if (isSemanticallyTransparent(child)) { + for (DocumentNode grandChild : child.children()) { + writeCellNode(cell, grandChild); + } } else if (child instanceof SpacerNode) { cell.addParagraph(); } else {