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 {