postext 1.49.0 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/__tests__/composed/emptyTabFrame.test.d.ts +2 -0
  2. package/dist/__tests__/composed/emptyTabFrame.test.d.ts.map +1 -0
  3. package/dist/__tests__/composed/emptyTabFrame.test.js +122 -0
  4. package/dist/__tests__/composed/emptyTabFrame.test.js.map +1 -0
  5. package/dist/__tests__/composed/fenceCondition.test.d.ts +2 -0
  6. package/dist/__tests__/composed/fenceCondition.test.d.ts.map +1 -0
  7. package/dist/__tests__/composed/fenceCondition.test.js +171 -0
  8. package/dist/__tests__/composed/fenceCondition.test.js.map +1 -0
  9. package/dist/__tests__/composed/fixtures/moduleRows.d.ts +40 -0
  10. package/dist/__tests__/composed/fixtures/moduleRows.d.ts.map +1 -0
  11. package/dist/__tests__/composed/fixtures/moduleRows.js +65 -0
  12. package/dist/__tests__/composed/fixtures/moduleRows.js.map +1 -0
  13. package/dist/__tests__/composed/frameLooks.test.d.ts +2 -0
  14. package/dist/__tests__/composed/frameLooks.test.d.ts.map +1 -0
  15. package/dist/__tests__/composed/frameLooks.test.js +151 -0
  16. package/dist/__tests__/composed/frameLooks.test.js.map +1 -0
  17. package/dist/__tests__/composed/moduleRows.test.d.ts +2 -0
  18. package/dist/__tests__/composed/moduleRows.test.d.ts.map +1 -0
  19. package/dist/__tests__/composed/moduleRows.test.js +233 -0
  20. package/dist/__tests__/composed/moduleRows.test.js.map +1 -0
  21. package/dist/__tests__/composed/modules.test.js +3 -2
  22. package/dist/__tests__/composed/modules.test.js.map +1 -1
  23. package/dist/__tests__/composed/pillWord.test.d.ts +2 -0
  24. package/dist/__tests__/composed/pillWord.test.d.ts.map +1 -0
  25. package/dist/__tests__/composed/pillWord.test.js +128 -0
  26. package/dist/__tests__/composed/pillWord.test.js.map +1 -0
  27. package/dist/__tests__/exports.test.js +8 -0
  28. package/dist/__tests__/exports.test.js.map +1 -1
  29. package/dist/composed/modules.d.ts.map +1 -1
  30. package/dist/composed/modules.js +9 -2
  31. package/dist/composed/modules.js.map +1 -1
  32. package/dist/composed/reflowLog.js +1 -1
  33. package/dist/composed/reflowLog.js.map +1 -1
  34. package/dist/composed/slotChecks.d.ts +10 -0
  35. package/dist/composed/slotChecks.d.ts.map +1 -1
  36. package/dist/composed/slotChecks.js +39 -0
  37. package/dist/composed/slotChecks.js.map +1 -1
  38. package/dist/composed/templates.d.ts +14 -1
  39. package/dist/composed/templates.d.ts.map +1 -1
  40. package/dist/composed/templates.js +73 -5
  41. package/dist/composed/templates.js.map +1 -1
  42. package/dist/composed/types.d.ts +55 -4
  43. package/dist/composed/types.d.ts.map +1 -1
  44. package/dist/composed/validate.d.ts.map +1 -1
  45. package/dist/composed/validate.js +63 -6
  46. package/dist/composed/validate.js.map +1 -1
  47. package/dist/composed/wrap.d.ts.map +1 -1
  48. package/dist/composed/wrap.js +22 -5
  49. package/dist/composed/wrap.js.map +1 -1
  50. package/dist/configWarnings.d.ts +3 -1
  51. package/dist/configWarnings.d.ts.map +1 -1
  52. package/dist/configWarnings.js +32 -3
  53. package/dist/configWarnings.js.map +1 -1
  54. package/dist/defaults/headerFooter.d.ts +10 -0
  55. package/dist/defaults/headerFooter.d.ts.map +1 -1
  56. package/dist/defaults/headerFooter.js +21 -1
  57. package/dist/defaults/headerFooter.js.map +1 -1
  58. package/dist/design/fenceCondition.d.ts +45 -0
  59. package/dist/design/fenceCondition.d.ts.map +1 -0
  60. package/dist/design/fenceCondition.js +66 -0
  61. package/dist/design/fenceCondition.js.map +1 -0
  62. package/dist/design/layout.d.ts.map +1 -1
  63. package/dist/design/layout.js +33 -1
  64. package/dist/design/layout.js.map +1 -1
  65. package/dist/index.d.ts +5 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +4 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/pipeline/composedLayout.d.ts +5 -1
  70. package/dist/pipeline/composedLayout.d.ts.map +1 -1
  71. package/dist/pipeline/composedLayout.js +309 -84
  72. package/dist/pipeline/composedLayout.js.map +1 -1
  73. package/dist/pipeline/config.d.ts.map +1 -1
  74. package/dist/pipeline/config.js +8 -1
  75. package/dist/pipeline/config.js.map +1 -1
  76. package/dist/pipeline/contentWarnings.d.ts.map +1 -1
  77. package/dist/pipeline/contentWarnings.js +8 -1
  78. package/dist/pipeline/contentWarnings.js.map +1 -1
  79. package/dist/types.d.ts +68 -2
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/vdt.d.ts +21 -2
  82. package/dist/vdt.d.ts.map +1 -1
  83. package/dist/vdt.js.map +1 -1
  84. package/package.json +1 -1
@@ -41,7 +41,7 @@ import { chainHead, FRAME_ATTR } from '../composed/wrap';
41
41
  import { framePlaceable, nextFrameOf, groupPlaceable, placeBox, unionRect } from '../composed/geometry';
42
42
  import { composedOrder, groupOfFrames, layoutGroup } from '../composed/groups';
43
43
  import { reflowEvents } from '../composed/reflowLog';
44
- import { anchorTarget, repeatInstance, repeatedFrames, resolveContinueIn } from '../composed/templates';
44
+ import { anchorTarget, lookOver, repeatInstance, repeatedFrames, resolveContinueIn } from '../composed/templates';
45
45
  import { collapsedOnAddedPage, collapseFrames, continuationTemplate, continuesCollapsed, continuesOffPage, trailingFrames } from '../composed/continuation';
46
46
  import { isModuleTemplate, resolvePageTemplate } from '../composed/templates';
47
47
  import { frameElementId } from '../composed/ids';
@@ -224,6 +224,20 @@ export function layoutComposedPage(asked) {
224
224
  };
225
225
  /** The frame's content does not fit, or it squeezes the frames after it. */
226
226
  const short = (c, name) => overflows(c, name) || squeezes(c, name);
227
+ /** A line of the frame holds nothing but the head of a word divided
228
+ * because it was wider than the measure (#1084): a hyphenated line with
229
+ * no space in it. */
230
+ const dividesWord = (c, name) => {
231
+ const ids = new Set(frameIn(c, name)?.blockIds ?? []);
232
+ for (const b of c.blocks) {
233
+ if (!ids.has(b.id))
234
+ continue;
235
+ for (const l of b.lines ?? [])
236
+ if (l.hyphenated && !/\s/.test(l.text.trim()))
237
+ return true;
238
+ }
239
+ return false;
240
+ };
227
241
  /** The ladder's last step (#859): the frame is cut at its foot and the
228
242
  * rest goes on on another page, its text and pictures back at full
229
243
  * size (they go on anyway). */
@@ -339,10 +353,51 @@ export function layoutComposedPage(asked) {
339
353
  const min = frame.minHeight && dimensionToPx(frame.minHeight, dpi, input.emPx) > dimensionToPx(h, dpi, input.emPx) ? frame.minHeight : h;
340
354
  return { height: 'auto', minHeight: min };
341
355
  };
356
+ /** The frame's text may be set smaller (`fit.shrinkText`, a
357
+ * `minScale` under 1) and it holds text. */
358
+ const textShrinks = (frame) => frame.fit.shrinkText && frame.fit.minScale < 1 && holdsText(frame);
359
+ /** The frame divides a word wider than its measure (#1084): a smaller
360
+ * scale than the one its text has, down to `minScale`, that sets the
361
+ * word whole and still fits wins; the largest such. When none does the
362
+ * word stays divided: dividing is the last resort. */
363
+ const wordWhole = (frame) => {
364
+ const name = frame.name;
365
+ if (runs >= COMPOSE_BUDGET || !dividesWord(cur, name))
366
+ return;
367
+ const head = headOf(name);
368
+ const now = fit.text.get(head) ?? 1;
369
+ const scales = scaleSteps(frame.fit.minScale, now);
370
+ if (scales.length === 0)
371
+ return;
372
+ const make = (s) => withText(fit, head, s);
373
+ const whole = (c) => !short(c, name) && !dividesWord(c, name);
374
+ if (!whole(compose(make(scales[scales.length - 1]))))
375
+ return;
376
+ let lo = 0;
377
+ let hi = scales.length - 1; // whole at hi
378
+ while (lo < hi && runs < COMPOSE_BUDGET) {
379
+ const mid = Math.floor((lo + hi) / 2);
380
+ if (whole(compose(make(scales[mid]))))
381
+ hi = mid;
382
+ else
383
+ lo = mid + 1;
384
+ }
385
+ fit = make(scales[hi]);
386
+ cur = compose(fit);
387
+ };
342
388
  for (const frame of template.frames) {
343
389
  const name = frame.name;
344
- if (!short(cur, name) || runs >= COMPOSE_BUDGET)
390
+ if (runs >= COMPOSE_BUDGET)
345
391
  continue;
392
+ if (!short(cur, name)) {
393
+ // A frame that fits, held to its height (a fixed one, or a
394
+ // `maxHeight`), may still divide a word wider than its measure
395
+ // (#1084): a one-line pill whose word went over two lines that its
396
+ // height holds. Its text is set smaller first.
397
+ if ((fixedHeight(frame) || frame.maxHeight) && textShrinks(frame))
398
+ wordWhole(frame);
399
+ continue;
400
+ }
346
401
  const steps = [];
347
402
  tried.set(name, steps);
348
403
  // 1. Free space: a fixed height grows to its maximum ('auto' heights
@@ -375,10 +430,15 @@ export function layoutComposedPage(asked) {
375
430
  };
376
431
  // 5. The frame's text (its whole story), down to fit.minScale.
377
432
  const shrinkText = () => {
378
- if (!frame.fit.shrinkText || frame.fit.minScale >= 1 || !holdsText(frame))
433
+ if (!textShrinks(frame))
379
434
  return false;
380
435
  steps.push('shrinkText');
381
- return searchScale(scaleSteps(frame.fit.minScale), (s) => withText(fit, headOf(name), s), name);
436
+ const scales = scaleSteps(frame.fit.minScale);
437
+ const make = (s) => withText(fit, headOf(name), s);
438
+ if (!searchScale(scales, make, name))
439
+ return false;
440
+ wordWhole(frame);
441
+ return true;
382
442
  };
383
443
  // 6. Continuation on another page (`overflow.continueIn: 'next-page'`,
384
444
  // `'template:<id>'` or `'page:<id>'`, #859). On a page added by
@@ -460,6 +520,7 @@ export function layoutComposedPage(asked) {
460
520
  templateSource: templateAttr ? { sourceStart: templateAttr.sourceStart + input.bodyOffset, sourceEnd: templateAttr.sourceEnd + input.bodyOffset } : undefined,
461
521
  budgetLeft: () => runs < COMPOSE_BUDGET,
462
522
  holdsText: holdsText(frame),
523
+ dividesWord,
463
524
  });
464
525
  warnings.push({
465
526
  kind: 'frameOverflow',
@@ -770,24 +831,51 @@ function overflowProposals(c) {
770
831
  const path = (key) => frame.fieldPaths[key] ?? `${frame.configPath}/${key}`;
771
832
  const fits = (next, tpl) => c.budgetLeft() && !c.overflows(c.compose(next, tpl), name);
772
833
  // More shrink: the largest text scale, in 2.5 % steps, at which it fits.
834
+ // When that scale divides a word wider than the frame's measure, the
835
+ // largest smaller one that fits with every word whole, as the ladder
836
+ // prefers (#1084, #1090); none, and the largest that fits, saying the
837
+ // word will be divided.
773
838
  const head = c.headOf(name);
774
839
  const from = frame.fit.shrinkText ? Math.min(frame.fit.minScale, vdt.scale) : 1;
775
840
  const steps = scaleSteps(PROPOSAL_MIN_SCALE, from);
776
- if (c.holdsText && steps.length > 0 && fits(c.withText(fit, head, steps[steps.length - 1]))) {
777
- let lo = 0;
778
- let hi = steps.length - 1;
779
- while (lo < hi && c.budgetLeft()) {
780
- const mid = Math.floor((lo + hi) / 2);
781
- if (fits(c.withText(fit, head, steps[mid])))
782
- hi = mid;
841
+ const shrunk = (i) => c.withText(fit, head, steps[i]);
842
+ if (c.holdsText && steps.length > 0 && fits(shrunk(steps.length - 1))) {
843
+ const last = steps.length - 1;
844
+ /** The largest step from `lo` down at which `ok` holds, given it holds
845
+ * at the last one: by bisection. */
846
+ const largest = (lo, ok) => {
847
+ let hi = last;
848
+ while (lo < hi && c.budgetLeft()) {
849
+ const mid = Math.floor((lo + hi) / 2);
850
+ if (ok(mid))
851
+ hi = mid;
852
+ else
853
+ lo = mid + 1;
854
+ }
855
+ return hi;
856
+ };
857
+ let at = largest(0, (i) => fits(shrunk(i)));
858
+ let word;
859
+ if (c.budgetLeft() && c.dividesWord(c.compose(shrunk(at)), name)) {
860
+ const whole = (i) => {
861
+ if (!c.budgetLeft())
862
+ return false;
863
+ const comp = c.compose(shrunk(i));
864
+ return !c.overflows(comp, name) && !c.dividesWord(comp, name);
865
+ };
866
+ if (at < last && whole(last)) {
867
+ at = largest(at + 1, whole);
868
+ word = 'whole';
869
+ }
783
870
  else
784
- lo = mid + 1;
871
+ word = 'divided';
785
872
  }
786
873
  out.push({
787
874
  kind: 'allowShrink',
788
875
  path: path('fit/minScale'),
789
- value: steps[hi],
876
+ value: steps[at],
790
877
  ...(frame.fit.shrinkText ? {} : { also: [{ path: path('fit/shrinkText'), value: true }] }),
878
+ ...(word ? { word } : {}),
791
879
  });
792
880
  }
793
881
  // A frame that holds content (an empty one is short of its own box, #911:
@@ -931,9 +1019,19 @@ function composePage(pageInput, template, fit, collapsed) {
931
1019
  * numbered (`VDTDesignTextBlock.pageMark`). */
932
1020
  const toGuess = String(input.page.index + 2);
933
1021
  const fromGuess = String(Math.max(1, input.page.index));
1022
+ /** The named look (#1089) the `:::frame` fence of a frame's story names
1023
+ * (`look="<id>"`), when `frameLooks` has it: the story's, so every
1024
+ * frame of a chain and the frames it goes on in on later pages wear it. */
1025
+ const frameLooks = pass.resolved.frameLooks;
1026
+ const namedLookOf = (frame) => {
1027
+ if (!frameLooks)
1028
+ return undefined;
1029
+ const id = input.stories.get(headOf(frame.name))?.plan.attrs.look?.trim();
1030
+ return id ? frameLooks.find((l) => l.id === id) : undefined;
1031
+ };
934
1032
  /** The style a frame is laid out in: its box as drawn at any text scale. */
935
1033
  const styleOf = (frame) => {
936
- const style = keepChromeAt(input.styleOf(frame), textScale(frame));
1034
+ const style = keepChromeAt(input.styleOf(withNamedLook(frame, namedLookOf(frame))), textScale(frame));
937
1035
  if (!fit.goOn.has(frame.name))
938
1036
  return style;
939
1037
  // A frame cut for another page (#859) ends with its mark: its own
@@ -1625,8 +1723,9 @@ function composePage(pageInput, template, fit, collapsed) {
1625
1723
  }
1626
1724
  }
1627
1725
  else if (st && frame.style) {
1628
- // A styled frame with nothing (left) to hold still draws its box.
1629
- result = inMode(frame, () => st.L.layoutRange(st.L.end, st.L.end, laidW, blockId, true, mirroredIn(frame), { boxHeight: laidH }));
1726
+ // A styled frame with nothing (left) to hold still draws its box;
1727
+ // an empty story's own frame (#1084) its tab and title too.
1728
+ result = inMode(frame, () => st.L.layoutRange(st.L.end, st.L.end, laidW, blockId, continuation, mirroredIn(frame), { boxHeight: laidH }));
1630
1729
  }
1631
1730
  // A frame cut below its least height overflows by the cut, holding
1632
1731
  // content or not (#911).
@@ -1807,26 +1906,102 @@ function composePage(pageInput, template, fit, collapsed) {
1807
1906
  fill(prepare(f), r);
1808
1907
  }
1809
1908
  };
1810
- /** Lay a modules group (#1062): each module, in order, in a box as wide
1811
- * as the group, from where the module before it ends (`gap` between
1812
- * them), as tall as its frames. A module's frames are placed as its
1813
- * template draws them in that box (`'container'` is the box) and its
1814
- * groups laid in it: first in the room left, to find how tall it is
1815
- * (the frames held by the box's foot or middle, or sized by its
1816
- * height, left out), then in a box that tall. The group runs down its
1817
- * own box, else the area. A module that does not fit is cut at the
1818
- * group's foot (its frames clipped there), or, when the group goes on
1819
- * to another page (`FitState.gridsOn`), it and every module after it
1820
- * go on, whole. The modules' declared boxes follow each other at the
1821
- * heights their frames declare, so the log tells a module moved by
1822
- * the one before it. */
1909
+ /** Lay a modules group (#1062): its modules in rows down the group's
1910
+ * box (its own, else the area). A stack gives each module a row of
1911
+ * its own, as wide as the group (or its template's `width`, #1088), a
1912
+ * `'row'` puts them all in one, start to end (each its `width`, else
1913
+ * an equal share of what is left), a `'grid'` `columns` to a row in
1914
+ * equal columns; a run of modules a group inside the stack takes
1915
+ * (`within`) is laid as that group says where its first module comes.
1916
+ * Rows run from the start side of the page's lines (the flow frame is
1917
+ * mirrored on a right-to-left page, so its left is the start). A
1918
+ * module is placed as its template draws its frames in its box
1919
+ * (`'container'` is the box) and its groups laid in it: first in the
1920
+ * room left, to find how tall it is (the frames held by the box's foot
1921
+ * or middle, or sized by its height, left out), then in a box that
1922
+ * tall, or (`rowAlign` `'stretch'`, a row of several) as tall as its
1923
+ * row. A row that does not fit is cut at the group's foot (its frames
1924
+ * clipped there), or, when the group goes on to another page
1925
+ * (`FitState.gridsOn`), it and every row after it go on, whole. The
1926
+ * modules' declared boxes follow each other at the heights their
1927
+ * frames declare, so the log tells a module moved by the one before
1928
+ * it. */
1823
1929
  const placeModules = (group, members) => {
1824
1930
  const byMember = new Map(members.map((f) => [f.name, f]));
1825
1931
  const own = groupPlaceable(group);
1826
1932
  const ownBox = own ? placeBox(own, template, pageInput, refOf, () => 0) : undefined;
1827
1933
  const box = ownBox ?? pageInput.areas.area;
1828
1934
  const foot = box.y + box.height;
1829
- const gap = dimensionToPx(group.gap, dpi, pageInput.emPx);
1935
+ const toPx = (d) => dimensionToPx(d, dpi, pageInput.emPx);
1936
+ const mods = [];
1937
+ for (const item of group.items ?? []) {
1938
+ const mod = template.modules?.find((m) => m.name === item.module);
1939
+ const names = item.frames.filter((n) => byMember.has(n));
1940
+ if (mod && names.length > 0)
1941
+ mods.push({ mod, names });
1942
+ }
1943
+ /** A module's width in the group by its template (`'fill'`: none). */
1944
+ const fixedWidth = (l) => {
1945
+ const w = l.mod.width;
1946
+ if (w === undefined || w === 'fill')
1947
+ return undefined;
1948
+ return 'percent' in w ? (box.width * w.percent) / 100 : toPx(w);
1949
+ };
1950
+ /** `list` side by side across the group, `gap` between them: each as
1951
+ * wide as its template says, the others sharing what is left. */
1952
+ const across = (list, gap) => {
1953
+ const fixed = list.map(fixedWidth);
1954
+ const taken = fixed.reduce((a, w) => a + (w ?? 0), 0) + gap * Math.max(0, list.length - 1);
1955
+ const shares = fixed.filter((w) => w === undefined).length;
1956
+ const share = shares > 0 ? Math.max(0, box.width - taken) / shares : 0;
1957
+ const widths = fixed.map((w) => Math.max(0, w ?? share));
1958
+ const xs = [];
1959
+ let x = box.x;
1960
+ for (const w of widths) {
1961
+ xs.push(x);
1962
+ x += w + gap;
1963
+ }
1964
+ return { xs, widths };
1965
+ };
1966
+ /** The rows `list` makes in a group of `g`'s kind, the first `first`
1967
+ * after the row before it. */
1968
+ const rowsOf = (list, g, first, nested) => {
1969
+ const gap = toPx(g.gap);
1970
+ const align = g.rowAlign ?? 'stretch';
1971
+ const tag = nested !== undefined ? { nested } : {};
1972
+ if (g.kind === 'row')
1973
+ return [{ mods: [...list], ...across(list, gap), align, gapBefore: first, ...tag }];
1974
+ if (g.kind === 'grid') {
1975
+ const columns = Math.max(1, g.columns ?? 1);
1976
+ const colGap = toPx(g.columnGap ?? g.gap);
1977
+ const rowGap = toPx(g.rowGap ?? g.gap);
1978
+ const colW = Math.max(1, (box.width - colGap * (columns - 1)) / columns);
1979
+ const out = [];
1980
+ for (let k = 0; k < list.length; k += columns) {
1981
+ const chunk = list.slice(k, k + columns);
1982
+ out.push({ mods: chunk, xs: chunk.map((_, c) => box.x + c * (colW + colGap)), widths: chunk.map(() => colW), align, gapBefore: k === 0 ? first : rowGap, ...tag });
1983
+ }
1984
+ return out;
1985
+ }
1986
+ // A stack: a row of its own per module, at its width from the start.
1987
+ return list.map((l, k) => ({ mods: [l], xs: [box.x], widths: [fixedWidth(l) ?? box.width], align: 'start', gapBefore: k === 0 ? first : gap, ...tag }));
1988
+ };
1989
+ const rows = [];
1990
+ if (group.kind !== 'stack')
1991
+ rows.push(...rowsOf(mods, group, 0));
1992
+ else {
1993
+ const stackGap = toPx(group.gap);
1994
+ for (let k = 0; k < mods.length;) {
1995
+ const nested = mods[k].mod.nested;
1996
+ const inner = nested !== undefined ? template.groups.find((g) => g.name === nested) : undefined;
1997
+ let end = k + 1;
1998
+ if (inner)
1999
+ while (end < mods.length && mods[end].mod.nested === nested)
2000
+ end++;
2001
+ rows.push(...rowsOf(mods.slice(k, end), inner ?? group, k === 0 ? 0 : stackGap, inner ? nested : undefined));
2002
+ k = end;
2003
+ }
2004
+ }
1830
2005
  let cursor = box.y;
1831
2006
  let declCursor = box.y;
1832
2007
  let out = false;
@@ -1834,40 +2009,31 @@ function composePage(pageInput, template, fit, collapsed) {
1834
2009
  let lastLaid;
1835
2010
  const boxes = [];
1836
2011
  const declBoxes = [];
2012
+ const nestedBoxes = new Map();
1837
2013
  const restore = (to, from) => {
1838
2014
  to.clear();
1839
2015
  for (const [k, v] of from)
1840
2016
  to.set(k, v);
1841
2017
  };
1842
- for (const item of group.items ?? []) {
1843
- const mod = template.modules?.find((m) => m.name === item.module);
1844
- const names = item.frames.filter((n) => byMember.has(n));
1845
- if (!mod || names.length === 0)
1846
- continue;
1847
- if (out) {
1848
- spillWhole(names, laidAny, lastLaid);
1849
- continue;
2018
+ const lay = (l, x, y, width, height, filling) => {
2019
+ const steps = composedOrder({ frames: l.names.map((n) => byMember.get(n)), groups: template.groups.filter((g) => l.mod.groups.includes(g.name)) });
2020
+ input = { ...pageInput, areas: { ...pageInput.areas, area: { x, y, width, height: Math.max(0, height) } } };
2021
+ try {
2022
+ for (const step of steps)
2023
+ runStep(step, filling);
1850
2024
  }
1851
- const sub = { frames: names.map((n) => byMember.get(n)), groups: template.groups.filter((g) => mod.groups.includes(g.name)) };
1852
- const steps = composedOrder(sub);
1853
- const lay = (height, filling) => {
1854
- input = { ...pageInput, areas: { ...pageInput.areas, area: { x: box.x, y: cursor, width: box.width, height: Math.max(0, height) } } };
1855
- try {
1856
- for (const step of steps)
1857
- runStep(step, filling);
1858
- }
1859
- finally {
1860
- input = pageInput;
1861
- }
1862
- };
1863
- // 1. How tall the module is: its frames placed in the room left,
1864
- // then everything they set put back.
2025
+ finally {
2026
+ input = pageInput;
2027
+ }
2028
+ };
2029
+ /** How tall a module is at `(x, y)`, `width` wide: its frames placed
2030
+ * in the room left, then everything they set put back. */
2031
+ const measure = (l, x, y, width) => {
1865
2032
  const kept = { rects: new Map(rects), declared: new Map(declared), groupRects: new Map(groupRects), groupDeclared: new Map(groupDeclared), pinned: pinned.length, boxShort: new Map(boxShort), fitted: new Map(fitted) };
1866
- lay(foot - cursor, false);
1867
- const placed = names.filter((n) => rects.has(n));
2033
+ lay(l, x, y, width, foot - y, false);
2034
+ const placed = l.names.filter((n) => rects.has(n));
1868
2035
  const measured = placed.filter((n) => !heldByFoot(byMember.get(n)));
1869
2036
  const reach = unionRect((measured.length > 0 ? measured : placed).map((n) => rects.get(n)));
1870
- const height = reach ? Math.max(0, reach.y + reach.height - cursor) : 0;
1871
2037
  restore(rects, kept.rects);
1872
2038
  restore(declared, kept.declared);
1873
2039
  restore(groupRects, kept.groupRects);
@@ -1875,41 +2041,81 @@ function composePage(pageInput, template, fit, collapsed) {
1875
2041
  restore(boxShort, kept.boxShort);
1876
2042
  restore(fitted, kept.fitted);
1877
2043
  pinned.length = kept.pinned;
1878
- const fits = cursor + height <= foot + 0.5;
1879
- if (!fits && fit.gridsOn.has(group.name)) {
1880
- // This module and the ones after it go on, whole.
1881
- out = true;
2044
+ return reach ? Math.max(0, reach.y + reach.height - y) : 0;
2045
+ };
2046
+ rows.forEach((row, r) => {
2047
+ const names = row.mods.flatMap((l) => l.names);
2048
+ if (out) {
1882
2049
  spillWhole(names, laidAny, lastLaid);
1883
- continue;
1884
- }
1885
- // 2. The module laid in a box that tall (cut at the foot when it
1886
- // does not fit).
1887
- clipFoot = fits ? undefined : foot;
1888
- try {
1889
- lay(fits ? height : foot - cursor, true);
2050
+ return;
1890
2051
  }
1891
- finally {
1892
- clipFoot = undefined;
2052
+ if (r > 0) {
2053
+ cursor += row.gapBefore;
2054
+ declCursor += row.gapBefore;
1893
2055
  }
1894
- const u = unionRect(names.flatMap((n) => (rects.has(n) ? [rects.get(n)] : [])));
1895
- const bottom = u ? Math.max(cursor, u.y + u.height) : cursor;
1896
- const moduleBox = { x: box.x, y: cursor, width: box.width, height: bottom - cursor };
1897
- moduleRects.set(mod.name, moduleBox);
1898
- boxes.push(moduleBox);
1899
- // Declared: after the modules before it as they declare themselves.
1900
- const shift = declCursor - cursor;
1901
- for (const n of names) {
1902
- const d = declared.get(n);
1903
- if (d && Math.abs(shift) > 0.001)
1904
- declared.set(n, { ...d, y: d.y + shift });
2056
+ const heights = row.mods.map((l, k) => measure(l, row.xs[k], cursor, row.widths[k]));
2057
+ const rowH = Math.max(0, ...heights);
2058
+ const fits = cursor + rowH <= foot + 0.5;
2059
+ if (!fits && fit.gridsOn.has(group.name)) {
2060
+ // This row and the ones after it go on, whole.
2061
+ out = true;
2062
+ spillWhole(names, laidAny, lastLaid);
2063
+ return;
1905
2064
  }
1906
- const du = unionRect(names.flatMap((n) => (declared.has(n) ? [declared.get(n)] : [])));
1907
- const declBottom = du ? Math.max(declCursor, du.y + du.height) : declCursor;
1908
- declBoxes.push({ x: box.x, y: declCursor, width: box.width, height: declBottom - declCursor });
2065
+ // Each module laid in a box that tall (cut at the foot when the row
2066
+ // does not fit); a shorter one placed by `rowAlign`.
2067
+ const several = row.mods.length > 1;
2068
+ let rowBottom = cursor;
2069
+ let rowDeclBottom = declCursor;
2070
+ row.mods.forEach((l, k) => {
2071
+ const x = row.xs[k];
2072
+ const width = row.widths[k];
2073
+ const spare = rowH - heights[k];
2074
+ const stretched = fits && several && row.align === 'stretch';
2075
+ const dy = fits && several && spare > 0.01 ? (row.align === 'end' ? spare : row.align === 'center' ? spare / 2 : 0) : 0;
2076
+ const y = cursor + dy;
2077
+ clipFoot = fits ? undefined : foot;
2078
+ try {
2079
+ lay(l, x, y, width, fits ? (stretched ? rowH : heights[k]) : foot - cursor, true);
2080
+ }
2081
+ finally {
2082
+ clipFoot = undefined;
2083
+ }
2084
+ const u = unionRect(l.names.flatMap((n) => (rects.has(n) ? [rects.get(n)] : [])));
2085
+ const bottom = Math.max(stretched ? y + rowH : y, u ? u.y + u.height : y);
2086
+ const moduleBox = { x, y, width, height: bottom - y };
2087
+ moduleRects.set(l.mod.name, moduleBox);
2088
+ boxes.push(moduleBox);
2089
+ // Declared: after the rows before it as they declare themselves.
2090
+ const shift = declCursor - cursor;
2091
+ for (const n of l.names) {
2092
+ const d = declared.get(n);
2093
+ if (d && Math.abs(shift) > 0.001)
2094
+ declared.set(n, { ...d, y: d.y + shift });
2095
+ }
2096
+ const declY = declCursor + dy;
2097
+ const du = unionRect(l.names.flatMap((n) => (declared.has(n) ? [declared.get(n)] : [])));
2098
+ const declBottom = du ? Math.max(declY, du.y + du.height) : declY;
2099
+ const declBox = { x, y: declY, width, height: declBottom - declY };
2100
+ declBoxes.push(declBox);
2101
+ if (row.nested !== undefined) {
2102
+ const n = nestedBoxes.get(row.nested) ?? { laid: [], declared: [] };
2103
+ n.laid.push(moduleBox);
2104
+ n.declared.push(declBox);
2105
+ nestedBoxes.set(row.nested, n);
2106
+ }
2107
+ rowBottom = Math.max(rowBottom, bottom);
2108
+ rowDeclBottom = Math.max(rowDeclBottom, declBottom);
2109
+ });
1909
2110
  laidAny = true;
1910
2111
  lastLaid = frameIdOf(names[names.length - 1]);
1911
- cursor = bottom + gap;
1912
- declCursor = declBottom + gap;
2112
+ cursor = rowBottom;
2113
+ declCursor = rowDeclBottom;
2114
+ });
2115
+ // A group inside the stack (#1088) is the box of its modules.
2116
+ for (const [name, b] of nestedBoxes) {
2117
+ groupRects.set(name, unionRect(b.laid));
2118
+ groupDeclared.set(name, unionRect(b.declared));
1913
2119
  }
1914
2120
  groupRects.set(group.name, ownBox ?? unionRect(boxes) ?? { ...box, height: 0 });
1915
2121
  groupDeclared.set(group.name, ownBox ?? unionRect(declBoxes) ?? { ...box, height: 0 });
@@ -2062,6 +2268,25 @@ export function frameStyleOf(base, frame) {
2062
2268
  };
2063
2269
  }
2064
2270
  const lookedStyles = new WeakMap();
2271
+ /** Frames with a named look laid over theirs (#1089), per look and frame:
2272
+ * the same look object each time, so `styleWithLook` keeps its styles. */
2273
+ const namedLookFrames = new WeakMap();
2274
+ /** `frame` with the named look `named` (#1089) laid over its own `look`
2275
+ * (`lookOver`): the order is the style, the template frame's look, then
2276
+ * the named look. `frame` itself when there is none. */
2277
+ export function withNamedLook(frame, named) {
2278
+ if (!named)
2279
+ return frame;
2280
+ let byFrame = namedLookFrames.get(named);
2281
+ if (!byFrame)
2282
+ namedLookFrames.set(named, (byFrame = new WeakMap()));
2283
+ const known = byFrame.get(frame);
2284
+ if (known)
2285
+ return known;
2286
+ const out = { ...frame, look: lookOver(frame.look, named) };
2287
+ byFrame.set(frame, out);
2288
+ return out;
2289
+ }
2065
2290
  /** `base` with what a frame's `look` sets (#976) written over it: colours
2066
2291
  * of the fill, border, stripe, icon, marker, title, text and label tab,
2067
2292
  * and the tracking of the body. A fill colour turns the fill on. A part