postext 1.35.0 → 1.36.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 (158) hide show
  1. package/dist/__bench__/composedPage.bench.test.d.ts +2 -0
  2. package/dist/__bench__/composedPage.bench.test.d.ts.map +1 -0
  3. package/dist/__bench__/composedPage.bench.test.js +95 -0
  4. package/dist/__bench__/composedPage.bench.test.js.map +1 -0
  5. package/dist/__tests__/composed/fit.test.d.ts +2 -0
  6. package/dist/__tests__/composed/fit.test.d.ts.map +1 -0
  7. package/dist/__tests__/composed/fit.test.js +405 -0
  8. package/dist/__tests__/composed/fit.test.js.map +1 -0
  9. package/dist/__tests__/composed/layout.test.js +2 -1
  10. package/dist/__tests__/composed/layout.test.js.map +1 -1
  11. package/dist/__tests__/composed/pageMemo.test.d.ts +2 -0
  12. package/dist/__tests__/composed/pageMemo.test.d.ts.map +1 -0
  13. package/dist/__tests__/composed/pageMemo.test.js +128 -0
  14. package/dist/__tests__/composed/pageMemo.test.js.map +1 -0
  15. package/dist/__tests__/composed/sourceMap.test.d.ts +2 -0
  16. package/dist/__tests__/composed/sourceMap.test.d.ts.map +1 -0
  17. package/dist/__tests__/composed/sourceMap.test.js +369 -0
  18. package/dist/__tests__/composed/sourceMap.test.js.map +1 -0
  19. package/dist/__tests__/composed/templateFrameRects.test.d.ts +2 -0
  20. package/dist/__tests__/composed/templateFrameRects.test.d.ts.map +1 -0
  21. package/dist/__tests__/composed/templateFrameRects.test.js +37 -0
  22. package/dist/__tests__/composed/templateFrameRects.test.js.map +1 -0
  23. package/dist/__tests__/design/imageAltText.test.js +2 -1
  24. package/dist/__tests__/design/imageAltText.test.js.map +1 -1
  25. package/dist/__tests__/exports.test.js +22 -0
  26. package/dist/__tests__/exports.test.js.map +1 -1
  27. package/dist/__tests__/pipeline/contourWrap.test.d.ts +2 -0
  28. package/dist/__tests__/pipeline/contourWrap.test.d.ts.map +1 -0
  29. package/dist/__tests__/pipeline/contourWrap.test.js +412 -0
  30. package/dist/__tests__/pipeline/contourWrap.test.js.map +1 -0
  31. package/dist/__tests__/toc.test.js +3 -2
  32. package/dist/__tests__/toc.test.js.map +1 -1
  33. package/dist/bundle/codec.d.ts +16 -0
  34. package/dist/bundle/codec.d.ts.map +1 -1
  35. package/dist/bundle/codec.js +44 -2
  36. package/dist/bundle/codec.js.map +1 -1
  37. package/dist/bundle/index.d.ts +1 -1
  38. package/dist/bundle/index.d.ts.map +1 -1
  39. package/dist/bundle/index.js +1 -1
  40. package/dist/bundle/index.js.map +1 -1
  41. package/dist/canvas-backend/composed.d.ts.map +1 -1
  42. package/dist/canvas-backend/composed.js +5 -2
  43. package/dist/canvas-backend/composed.js.map +1 -1
  44. package/dist/composed/contour.d.ts +21 -0
  45. package/dist/composed/contour.d.ts.map +1 -0
  46. package/dist/composed/contour.js +34 -0
  47. package/dist/composed/contour.js.map +1 -0
  48. package/dist/composed/geometry.d.ts +8 -0
  49. package/dist/composed/geometry.d.ts.map +1 -1
  50. package/dist/composed/geometry.js +26 -0
  51. package/dist/composed/geometry.js.map +1 -1
  52. package/dist/composed/ids.d.ts +22 -0
  53. package/dist/composed/ids.d.ts.map +1 -0
  54. package/dist/composed/ids.js +32 -0
  55. package/dist/composed/ids.js.map +1 -0
  56. package/dist/composed/pageMemo.d.ts +72 -0
  57. package/dist/composed/pageMemo.d.ts.map +1 -0
  58. package/dist/composed/pageMemo.js +287 -0
  59. package/dist/composed/pageMemo.js.map +1 -0
  60. package/dist/composed/paint.d.ts +21 -7
  61. package/dist/composed/paint.d.ts.map +1 -1
  62. package/dist/composed/paint.js +33 -5
  63. package/dist/composed/paint.js.map +1 -1
  64. package/dist/composed/reflowLog.d.ts +11 -1
  65. package/dist/composed/reflowLog.d.ts.map +1 -1
  66. package/dist/composed/reflowLog.js +45 -2
  67. package/dist/composed/reflowLog.js.map +1 -1
  68. package/dist/composed/slotChecks.d.ts.map +1 -1
  69. package/dist/composed/slotChecks.js +3 -1
  70. package/dist/composed/slotChecks.js.map +1 -1
  71. package/dist/composed/sourceMap.d.ts +118 -0
  72. package/dist/composed/sourceMap.d.ts.map +1 -0
  73. package/dist/composed/sourceMap.js +668 -0
  74. package/dist/composed/sourceMap.js.map +1 -0
  75. package/dist/composed/spread.d.ts.map +1 -1
  76. package/dist/composed/spread.js +10 -11
  77. package/dist/composed/spread.js.map +1 -1
  78. package/dist/composed/templates.d.ts +1 -0
  79. package/dist/composed/templates.d.ts.map +1 -1
  80. package/dist/composed/templates.js +4 -0
  81. package/dist/composed/templates.js.map +1 -1
  82. package/dist/composed/types.d.ts +15 -32
  83. package/dist/composed/types.d.ts.map +1 -1
  84. package/dist/composed/validate.d.ts.map +1 -1
  85. package/dist/composed/validate.js +3 -2
  86. package/dist/composed/validate.js.map +1 -1
  87. package/dist/composed/wrap.d.ts.map +1 -1
  88. package/dist/composed/wrap.js +7 -1
  89. package/dist/composed/wrap.js.map +1 -1
  90. package/dist/contour/index.d.ts +134 -0
  91. package/dist/contour/index.d.ts.map +1 -0
  92. package/dist/contour/index.js +354 -0
  93. package/dist/contour/index.js.map +1 -0
  94. package/dist/contour/png.d.ts +16 -0
  95. package/dist/contour/png.d.ts.map +1 -0
  96. package/dist/contour/png.js +173 -0
  97. package/dist/contour/png.js.map +1 -0
  98. package/dist/html-backend.js +2 -2
  99. package/dist/html-backend.js.map +1 -1
  100. package/dist/index.d.ts +12 -5
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +13 -3
  103. package/dist/index.js.map +1 -1
  104. package/dist/measure/rich.d.ts.map +1 -1
  105. package/dist/measure/rich.js +2 -0
  106. package/dist/measure/rich.js.map +1 -1
  107. package/dist/parse/blockParser.d.ts.map +1 -1
  108. package/dist/parse/blockParser.js +44 -3
  109. package/dist/parse/blockParser.js.map +1 -1
  110. package/dist/parse/types.d.ts +20 -0
  111. package/dist/parse/types.d.ts.map +1 -1
  112. package/dist/pipeline/build.d.ts.map +1 -1
  113. package/dist/pipeline/build.js +108 -8
  114. package/dist/pipeline/build.js.map +1 -1
  115. package/dist/pipeline/buildMeasurement.d.ts +7 -2
  116. package/dist/pipeline/buildMeasurement.d.ts.map +1 -1
  117. package/dist/pipeline/buildMeasurement.js +10 -5
  118. package/dist/pipeline/buildMeasurement.js.map +1 -1
  119. package/dist/pipeline/calloutLayout.d.ts +20 -14
  120. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  121. package/dist/pipeline/calloutLayout.js +20 -2
  122. package/dist/pipeline/calloutLayout.js.map +1 -1
  123. package/dist/pipeline/composedLayout.d.ts +36 -5
  124. package/dist/pipeline/composedLayout.d.ts.map +1 -1
  125. package/dist/pipeline/composedLayout.js +553 -68
  126. package/dist/pipeline/composedLayout.js.map +1 -1
  127. package/dist/pipeline/contentWarnings.d.ts.map +1 -1
  128. package/dist/pipeline/contentWarnings.js +13 -4
  129. package/dist/pipeline/contentWarnings.js.map +1 -1
  130. package/dist/pipeline/floatPlacement.d.ts +2 -0
  131. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  132. package/dist/pipeline/floatPlacement.js +1 -0
  133. package/dist/pipeline/floatPlacement.js.map +1 -1
  134. package/dist/pipeline/frameLayout.d.ts +3 -3
  135. package/dist/pipeline/frameLayout.d.ts.map +1 -1
  136. package/dist/pipeline/headerFooter.d.ts +9 -0
  137. package/dist/pipeline/headerFooter.d.ts.map +1 -1
  138. package/dist/pipeline/headerFooter.js +56 -4
  139. package/dist/pipeline/headerFooter.js.map +1 -1
  140. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  141. package/dist/pipeline/measureContentBlock.js +1 -0
  142. package/dist/pipeline/measureContentBlock.js.map +1 -1
  143. package/dist/pipeline/outline.d.ts.map +1 -1
  144. package/dist/pipeline/outline.js +2 -0
  145. package/dist/pipeline/outline.js.map +1 -1
  146. package/dist/pipeline/textWrap.d.ts +4 -1
  147. package/dist/pipeline/textWrap.d.ts.map +1 -1
  148. package/dist/pipeline/textWrap.js +17 -3
  149. package/dist/pipeline/textWrap.js.map +1 -1
  150. package/dist/pipeline/toc.d.ts.map +1 -1
  151. package/dist/pipeline/toc.js +1 -0
  152. package/dist/pipeline/toc.js.map +1 -1
  153. package/dist/types.d.ts +64 -2
  154. package/dist/types.d.ts.map +1 -1
  155. package/dist/vdt.d.ts +155 -11
  156. package/dist/vdt.d.ts.map +1 -1
  157. package/dist/vdt.js.map +1 -1
  158. package/package.json +1 -1
@@ -1,18 +1,412 @@
1
+ import { pageIsMirrored } from '../vdt';
2
+ import { contourBounds, scaleContourAbout, shiftContour } from '../contour';
3
+ import { frameContour } from '../composed/contour';
1
4
  import { dimensionToPx } from '../units';
2
5
  import { frameBounds } from '../composed/paint';
3
6
  import { FRAME_ATTR } from '../composed/wrap';
4
7
  import { framePlaceable, nextFrameOf, groupPlaceable, placeBox, unionRect } from '../composed/geometry';
5
8
  import { composedOrder, groupOfFrames, layoutGroup } from '../composed/groups';
6
9
  import { reflowEvents } from '../composed/reflowLog';
7
- import { anchorTarget } from '../composed/templates';
10
+ import { anchorTarget, continueInTemplate } from '../composed/templates';
8
11
  import { resolvePageTemplate } from '../composed/templates';
12
+ import { frameElementId } from '../composed/ids';
9
13
  import { offsetCalloutToAbsolute } from './calloutLayout';
14
+ import { isMarkerBlock } from './buildHelpers';
10
15
  import { CUT_START, fragmentRange, makeCalloutLayouter, splitCalloutFragment } from './frameLayout';
11
16
  const MM_PER_IN = 25.4;
17
+ const NO_FIT = { patches: new Map(), text: new Map(), pictures: new Map(), yieldAll: new Set() };
18
+ /** Step of the text and picture scales the ladder tries (DESIGN §5.4). */
19
+ const FIT_STEP = 0.025;
20
+ /** Lowest text scale an `allowShrink` proposal goes to. */
21
+ const PROPOSAL_MIN_SCALE = 0.5;
22
+ /** Most compositions of one page the ladder and its proposals may run. */
23
+ const COMPOSE_BUDGET = 96;
24
+ /** The scales from just under 1 (or under `from`) down to `min`, in
25
+ * {@link FIT_STEP} steps, `min` last. */
26
+ function scaleSteps(min, from = 1) {
27
+ const out = [];
28
+ for (let k = 1;; k++) {
29
+ const s = Math.round((from - k * FIT_STEP) * 1e4) / 1e4;
30
+ if (s <= min + 1e-6)
31
+ break;
32
+ out.push(s);
33
+ }
34
+ if (min < from - 1e-6)
35
+ out.push(Math.round(min * 1e4) / 1e4);
36
+ return out;
37
+ }
38
+ /** The template with the ladder's patches on its frames. */
39
+ function patchTemplate(template, patches) {
40
+ if (patches.size === 0)
41
+ return template;
42
+ return { ...template, frames: template.frames.map((f) => (patches.has(f.name) ? { ...f, ...patches.get(f.name) } : f)) };
43
+ }
44
+ /** A fixed height (a length, not a percentage). */
45
+ const fixedHeight = (f) => {
46
+ const h = f.height;
47
+ return h !== null && typeof h === 'object' && 'value' in h && 'unit' in h ? f.height : undefined;
48
+ };
12
49
  export function layoutComposedPage(input) {
13
- const { pass, page, template, fence, dpi } = input;
50
+ const { template, fence, dpi } = input;
14
51
  const fi = fence.fenceIndex;
15
- const frameIdOf = (name) => `cp${fi}:${name}`;
52
+ const mmPx = dpi / MM_PER_IN;
53
+ const byName = new Map(template.frames.map((f) => [f.name, f]));
54
+ const groupOf = groupOfFrames(template);
55
+ const prev = new Map();
56
+ for (const f of template.frames) {
57
+ const n = nextFrameOf(f);
58
+ if (n && byName.has(n) && !prev.has(n))
59
+ prev.set(n, f.name);
60
+ }
61
+ const headOf = (name) => {
62
+ let at = name;
63
+ const seen = new Set([at]);
64
+ for (let p = prev.get(at); p !== undefined && !seen.has(p); p = prev.get(at)) {
65
+ seen.add(p);
66
+ at = p;
67
+ }
68
+ return at;
69
+ };
70
+ // Compositions by fit state: the ladder and its proposals ask for some
71
+ // twice.
72
+ const memo = new Map();
73
+ let runs = 0;
74
+ const keyOf = (tpl, fit) => JSON.stringify([tpl.id, [...fit.patches], [...fit.text], [...fit.pictures], [...fit.yieldAll]]);
75
+ const compose = (fit, tpl = template) => {
76
+ const key = keyOf(tpl, fit);
77
+ const known = memo.get(key);
78
+ if (known)
79
+ return known;
80
+ runs++;
81
+ const c = composePage(input, patchTemplate(tpl, fit.patches), fit);
82
+ memo.set(key, c);
83
+ return c;
84
+ };
85
+ const frameIn = (c, name) => c.frames.find((f) => f.name === name);
86
+ const overflows = (c, name) => (frameIn(c, name)?.overflowPx ?? 0) > 0.5;
87
+ // The ladder, frame by frame in template order (ties break there).
88
+ let fit = NO_FIT;
89
+ let cur = compose(fit);
90
+ const tried = new Map();
91
+ const patched = (f, name, patch) => ({ ...f, patches: new Map([...f.patches, [name, { ...f.patches.get(name), ...patch }]]) });
92
+ const withText = (f, head, s) => ({ ...f, text: new Map([...f.text, [head, s]]) });
93
+ const withPictures = (f, heads, s, by) => {
94
+ const pictures = new Map(f.pictures);
95
+ for (const h of heads)
96
+ pictures.set(h, { scale: s, by });
97
+ return { ...f, pictures };
98
+ };
99
+ /** Take `next` when it makes `name` fit; else keep it anyway (each step
100
+ * builds on the ones before it at their limit) and say so. */
101
+ const attempt = (next, name) => {
102
+ fit = next;
103
+ cur = compose(fit);
104
+ return !overflows(cur, name);
105
+ };
106
+ /** The largest scale among `steps` (descending) at which `name` fits,
107
+ * checked at the lowest first, then by bisection; adopted. */
108
+ const searchScale = (steps, make, name) => {
109
+ if (steps.length === 0)
110
+ return false;
111
+ if (!attempt(make(steps[steps.length - 1]), name))
112
+ return false;
113
+ let lo = 0;
114
+ let hi = steps.length - 1; // fits at hi
115
+ while (lo < hi && runs < COMPOSE_BUDGET) {
116
+ const mid = Math.floor((lo + hi) / 2);
117
+ if (!overflows(compose(make(steps[mid])), name))
118
+ hi = mid;
119
+ else
120
+ lo = mid + 1;
121
+ }
122
+ fit = make(steps[hi]);
123
+ cur = compose(fit);
124
+ return true;
125
+ };
126
+ /** Story heads of the frame and of its group's frames whose content holds
127
+ * a picture. */
128
+ const pictureHeads = (frame) => {
129
+ const g = groupOf.get(frame.name);
130
+ const names = g ? [frame.name, ...g.frames.filter((m) => m !== frame.name)] : [frame.name];
131
+ const heads = [];
132
+ for (const n of names) {
133
+ const head = headOf(n);
134
+ const story = input.stories.get(head);
135
+ if (!story || heads.includes(head))
136
+ continue;
137
+ for (let i = story.startIdx + 1; i < story.plan.endIdx; i++) {
138
+ if (input.pass.contentBlocks[i]?.type === 'resourceBlock') {
139
+ heads.push(head);
140
+ break;
141
+ }
142
+ }
143
+ }
144
+ return heads;
145
+ };
146
+ /** The frame's story sets text (not only pictures): its text can
147
+ * shrink. */
148
+ const holdsText = (frame) => {
149
+ const story = input.stories.get(headOf(frame.name));
150
+ if (!story)
151
+ return false;
152
+ for (let i = story.startIdx + 1; i < story.plan.endIdx; i++) {
153
+ const b = input.pass.contentBlocks[i];
154
+ if (b && b.type !== 'resourceBlock' && b.type !== 'directive' && !isMarkerBlock(b))
155
+ return true;
156
+ }
157
+ return false;
158
+ };
159
+ const movesOthers = (frame) => {
160
+ const g = groupOf.get(frame.name);
161
+ if (g && g.frames.filter((m) => byName.has(m)).length > 1)
162
+ return true;
163
+ return template.frames.some((f) => f.name !== frame.name && !f.grid && anchorTarget(f.placement) === frame.name);
164
+ };
165
+ /** `free` applies: a fixed height with room up to a higher `maxHeight`. */
166
+ const growPatch = (frame) => {
167
+ const h = fixedHeight(frame);
168
+ if (!h || !frame.maxHeight)
169
+ return undefined;
170
+ if (dimensionToPx(frame.maxHeight, dpi, input.emPx) <= dimensionToPx(h, dpi, input.emPx) + 0.5)
171
+ return undefined;
172
+ const min = frame.minHeight && dimensionToPx(frame.minHeight, dpi, input.emPx) > dimensionToPx(h, dpi, input.emPx) ? frame.minHeight : h;
173
+ return { height: 'auto', minHeight: min };
174
+ };
175
+ for (const frame of template.frames) {
176
+ const name = frame.name;
177
+ if (!overflows(cur, name) || runs >= COMPOSE_BUDGET)
178
+ continue;
179
+ const steps = [];
180
+ tried.set(name, steps);
181
+ // 1. Free space: a fixed height grows to its maximum ('auto' heights
182
+ // already take the room their area and group leave).
183
+ steps.push('free');
184
+ const grow = growPatch(frame);
185
+ if (grow && attempt(patched(fit, name, grow), name))
186
+ continue;
187
+ // 2. Push siblings: the group moved the frames after it and the frames
188
+ // that shrink gave space back when it was laid.
189
+ if (movesOthers(frame))
190
+ steps.push('push');
191
+ // 3. Pictures of the frame and its group, down to imageMinScale.
192
+ const heads = frame.fit.scaleImages ? pictureHeads(frame) : [];
193
+ if (heads.length > 0 && frame.fit.imageMinScale < 1) {
194
+ steps.push('scaleImages');
195
+ if (searchScale(scaleSteps(frame.fit.imageMinScale), (s) => withPictures(fit, heads, s, name), name))
196
+ continue;
197
+ }
198
+ // 4. Redistribute: every other frame of the group gives space (or
199
+ // width) back, down to its minimum or its content.
200
+ const g = groupOf.get(name);
201
+ if (g && g.frames.filter((m) => byName.has(m)).length > 1) {
202
+ steps.push('redistribute');
203
+ if (attempt({ ...fit, yieldAll: new Set([...fit.yieldAll, g.name]) }, name))
204
+ continue;
205
+ }
206
+ // 5. The frame's text (its whole story), down to fit.minScale.
207
+ if (frame.fit.shrinkText && frame.fit.minScale < 1 && holdsText(frame)) {
208
+ steps.push('shrinkText');
209
+ const head = headOf(name);
210
+ if (searchScale(scaleSteps(frame.fit.minScale), (s) => withText(fit, head, s), name))
211
+ continue;
212
+ }
213
+ // 6. Continuation on another page (`overflow.continueIn: 'next-page'`
214
+ // or `'template:<id>'`) is #859's: the step is reported, the content
215
+ // still stops here.
216
+ if (continueOnOtherPage(frame))
217
+ steps.push('continue');
218
+ }
219
+ // 7. Warn, with what would make each frame that still overflows fit.
220
+ const warnings = [...(cur.notes ?? [])];
221
+ const templateAttr = fence.attrSources.template;
222
+ for (const frame of template.frames) {
223
+ const vdt = frameIn(cur, frame.name);
224
+ if (!vdt || vdt.overflowPx <= 0.5)
225
+ continue;
226
+ const overflowMm = Math.round((vdt.overflowPx / mmPx) * 10) / 10;
227
+ const proposals = overflowProposals({
228
+ frame,
229
+ vdt,
230
+ cur,
231
+ fit,
232
+ compose,
233
+ overflows,
234
+ headOf,
235
+ withText,
236
+ patched,
237
+ groupOf,
238
+ byName,
239
+ mmPx,
240
+ dpi,
241
+ emPx: input.emPx,
242
+ alternatives: input.alternatives,
243
+ templateSource: templateAttr ? { sourceStart: templateAttr.sourceStart + input.bodyOffset, sourceEnd: templateAttr.sourceEnd + input.bodyOffset } : undefined,
244
+ budgetLeft: () => runs < COMPOSE_BUDGET,
245
+ holdsText: holdsText(frame),
246
+ });
247
+ warnings.push({
248
+ kind: 'frameOverflow',
249
+ template: template.id,
250
+ frame: frame.name,
251
+ fenceIndex: fi,
252
+ overflowMm,
253
+ configPath: frame.configPath,
254
+ ...(input.locale ? { locale: input.locale } : {}),
255
+ stepsTried: tried.get(frame.name) ?? ['free'],
256
+ scale: vdt.scale,
257
+ ...(vdt.imageScale !== undefined ? { imageScale: vdt.imageScale } : {}),
258
+ proposals,
259
+ ...(vdt.sourceStart !== undefined ? { sourceStart: vdt.sourceStart, sourceEnd: vdt.sourceEnd } : {}),
260
+ pageIndex: input.page.index,
261
+ });
262
+ }
263
+ const { frames, blocks, log, laid, fitted } = cur;
264
+ // Template order on the page record.
265
+ const order = new Map(template.frames.map((f, i) => [f.name, i]));
266
+ frames.sort((a, b) => order.get(a.name) - order.get(b.name));
267
+ const contentBlocks = input.pass.contentBlocks;
268
+ const composed = {
269
+ template: template.id,
270
+ ...(template.variant ? { variant: template.variant } : {}),
271
+ fenceIndex: fi,
272
+ role: template.role,
273
+ attrs: { ...fence.attrs },
274
+ attrSources: Object.fromEntries(Object.entries(fence.attrSources).map(([k, v]) => [k, { sourceStart: v.sourceStart + input.bodyOffset, sourceEnd: v.sourceEnd + input.bodyOffset }])),
275
+ frames,
276
+ log,
277
+ configPath: template.configPath,
278
+ sourceStart: contentBlocks[input.startIdx].sourceStart + input.bodyOffset,
279
+ sourceEnd: contentBlocks[input.endIdx].sourceEnd + input.bodyOffset,
280
+ };
281
+ return { composed, blocks, warnings, laid, ...(fitted.size > 0 ? { fitted } : {}) };
282
+ }
283
+ /** The frame sends what does not fit to another page (#859). */
284
+ function continueOnOtherPage(frame) {
285
+ const to = frame.overflow.continueIn;
286
+ return to === 'next-page' || continueInTemplate(to) !== undefined;
287
+ }
288
+ const mmDim = (value) => ({ value, unit: 'mm' });
289
+ /** What would make `frame`'s content fit, each value worked out from the
290
+ * shortfall and, where the page can tell, tried on it (DESIGN §5.7). */
291
+ function overflowProposals(c) {
292
+ const { frame, vdt, cur, fit, mmPx } = c;
293
+ const name = frame.name;
294
+ const out = [];
295
+ const path = (key) => frame.fieldPaths[key] ?? `${frame.configPath}/${key}`;
296
+ const fits = (next, tpl) => c.budgetLeft() && !c.overflows(c.compose(next, tpl), name);
297
+ // More shrink: the largest text scale, in 2.5 % steps, at which it fits.
298
+ const head = c.headOf(name);
299
+ const from = frame.fit.shrinkText ? Math.min(frame.fit.minScale, vdt.scale) : 1;
300
+ const steps = scaleSteps(PROPOSAL_MIN_SCALE, from);
301
+ if (c.holdsText && steps.length > 0 && fits(c.withText(fit, head, steps[steps.length - 1]))) {
302
+ let lo = 0;
303
+ let hi = steps.length - 1;
304
+ while (lo < hi && c.budgetLeft()) {
305
+ const mid = Math.floor((lo + hi) / 2);
306
+ if (fits(c.withText(fit, head, steps[mid])))
307
+ hi = mid;
308
+ else
309
+ lo = mid + 1;
310
+ }
311
+ out.push({
312
+ kind: 'allowShrink',
313
+ path: path('fit/minScale'),
314
+ value: steps[hi],
315
+ ...(frame.fit.shrinkText ? {} : { also: [{ path: path('fit/shrinkText'), value: true }] }),
316
+ });
317
+ }
318
+ // A higher maximum: the frame's height plus the shortfall, in whole mm.
319
+ const fixed = fixedHeight(frame);
320
+ if (frame.maxHeight || fixed) {
321
+ const want = Math.ceil((vdt.rect.height + vdt.overflowPx) / mmPx - 1e-6);
322
+ const patch = { maxHeight: mmDim(want), ...(fixed ? { height: 'auto', minHeight: fixed } : {}) };
323
+ if (fits(c.patched(fit, name, patch)))
324
+ out.push({ kind: 'raiseMax', path: path('maxHeight'), value: mmDim(want) });
325
+ }
326
+ // A lower minimum on a frame of its group that stands at its minimum
327
+ // with less content than that: lowered by the shortfall, not below its
328
+ // content.
329
+ const g = c.groupOf.get(name);
330
+ if (g) {
331
+ for (const other of g.frames) {
332
+ const f = c.byName.get(other);
333
+ if (!f || other === name || f.pin || !f.minHeight)
334
+ continue;
335
+ const box = cur.frames.find((x) => x.name === other);
336
+ if (!box)
337
+ continue;
338
+ const minPx = dimensionToPx(f.minHeight, c.dpi, c.emPx);
339
+ const need = cur.needs.get(other)?.() ?? 0;
340
+ if (Math.abs(box.rect.height - minPx) > 0.5 || need >= minPx - mmPx)
341
+ continue;
342
+ const lowered = Math.max(Math.ceil(need / mmPx - 1e-6), Math.floor((minPx - vdt.overflowPx) / mmPx + 1e-6));
343
+ if (lowered * mmPx >= minPx - 0.5)
344
+ continue;
345
+ if (fits(c.patched(fit, other, { minHeight: mmDim(lowered) }))) {
346
+ out.push({ kind: 'lowerMin', path: f.fieldPaths.minHeight ?? `${f.configPath}/minHeight`, value: mmDim(lowered), frame: other });
347
+ }
348
+ }
349
+ }
350
+ // Continue on the next page (#859 lays it out).
351
+ if (!frame.overflow.continueIn)
352
+ out.push({ kind: 'continueNextPage', path: path('overflow/continueIn'), value: 'next-page' });
353
+ // Another template of the book with a frame of this name, where the
354
+ // content fits as it is.
355
+ for (const alt of c.alternatives?.() ?? []) {
356
+ if (!c.budgetLeft())
357
+ break;
358
+ if (!alt.frames.some((f) => f.name === name))
359
+ continue;
360
+ const trial = c.compose(NO_FIT, alt);
361
+ const laidThere = trial.frames.find((f) => f.name === name);
362
+ if (laidThere && laidThere.blockIds.length > 0 && laidThere.overflowPx <= 0.5) {
363
+ out.push({ kind: 'useTemplate', value: alt.id, ...(c.templateSource ?? {}) });
364
+ break;
365
+ }
366
+ }
367
+ // Shorter text: the lines that do not fit, at the frame's characters a
368
+ // line.
369
+ const lines = cur.blocks.filter((b) => vdt.blockIds.includes(b.id) && b.type !== 'resource').flatMap((b) => b.lines.filter((l) => l.text.length > 0));
370
+ if (lines.length > 0) {
371
+ // Characters of a full line: the lines that do not end their paragraph.
372
+ const full = lines.filter((l) => !l.isLastLine);
373
+ const sample = full.length > 0 ? full : lines;
374
+ const perLine = sample.reduce((n, l) => n + l.text.length, 0) / sample.length;
375
+ const lineH = (lines.reduce((h, l) => h + l.bbox.height, 0) / lines.length) * vdt.scale;
376
+ if (lineH > 0) {
377
+ const over = Math.ceil(vdt.overflowPx / lineH - 1e-6);
378
+ out.push({ kind: 'shortenText', value: Math.ceil(over * perLine), ...(vdt.sourceStart !== undefined ? { sourceStart: vdt.sourceStart, sourceEnd: vdt.sourceEnd } : {}) });
379
+ }
380
+ }
381
+ return out;
382
+ }
383
+ /** A frame style whose box keeps its drawn size when the frame's text is
384
+ * set at `scale` (#858): the frame is laid out `1 / scale` as large and
385
+ * painted at `scale`, so its padding, border, corner radius, stripe and
386
+ * tab are laid out `1 / scale` as large to come out as drawn. Only the
387
+ * text and what it holds shrink: padding that keeps room for the photos
388
+ * set over a box stays that room. */
389
+ export function keepChromeAt(style, scale) {
390
+ if (scale === 1)
391
+ return style;
392
+ const d = (v) => ({ ...v, value: v.value / scale });
393
+ const p = style.padding;
394
+ return {
395
+ ...style,
396
+ padding: { top: d(p.top), right: d(p.right), bottom: d(p.bottom), left: d(p.left) },
397
+ border: { ...style.border, width: d(style.border.width) },
398
+ borderRadius: d(style.borderRadius),
399
+ stripe: { ...style.stripe, width: d(style.stripe.width) },
400
+ ...(style.label
401
+ ? { label: { ...style.label, height: d(style.label.height), paddingX: d(style.label.paddingX), offset: d(style.label.offset), inset: d(style.label.inset), rule: { ...style.label.rule, width: d(style.label.rule.width) } } }
402
+ : {}),
403
+ };
404
+ }
405
+ /** Lay out every frame of the page once, with the reflow ladder's `fit`. */
406
+ function composePage(input, template, fit) {
407
+ const { pass, fence, dpi } = input;
408
+ const fi = fence.fenceIndex;
409
+ const frameIdOf = (name) => frameElementId(fi, name);
16
410
  const byName = new Map(template.frames.map((f) => [f.name, f]));
17
411
  const prev = new Map();
18
412
  for (const f of template.frames) {
@@ -30,11 +424,26 @@ export function layoutComposedPage(input) {
30
424
  }
31
425
  return at;
32
426
  };
427
+ /** The text scale of a frame's story (#858). */
428
+ const textScale = (frame) => fit.text.get(headOf(frame.name)) ?? 1;
429
+ /** The style a frame is laid out in: its box as drawn at any text scale. */
430
+ const styleOf = (frame) => keepChromeAt(input.styleOf(frame), textScale(frame));
431
+ /** The scale of a frame's pictures (#858), below 1, or undefined. */
432
+ const pictureScale = (frame) => fit.pictures.get(headOf(frame.name))?.scale;
433
+ /** Rectangles in a frame's box coordinates, in its laid-out (scaled)
434
+ * coordinates. */
435
+ const unscale = (rs, s) => rs && s !== 1
436
+ ? rs.map((r) => ({ x: r.x / s, y: r.y / s, width: r.width / s, height: r.height / s, ...(r.contour ? { contour: scaleContourAbout(r.contour, 0, 0, 1 / s) } : {}) }))
437
+ : rs;
33
438
  /** Place and fill every frame. `seed`: the boxes of the frames that push
34
439
  * text, from a first run, so a frame placed before one that pushes it
35
440
  * (the photo anchored to its box) still runs round it. */
36
- const run = (seed) => {
441
+ const run = (seed, seedShapes) => {
37
442
  const pushRects = new Map();
443
+ /** The shapes of the frames that push with their pictures' contour (#883). */
444
+ const pushShapes = new Map();
445
+ /** Pictures of a contour push whose shape is not known (#883). */
446
+ const notes = [];
38
447
  const states = new Map();
39
448
  const stateOf = (frame) => {
40
449
  const head = headOf(frame.name);
@@ -47,7 +456,7 @@ export function layoutComposedPage(input) {
47
456
  return undefined;
48
457
  const st = {
49
458
  story,
50
- L: makeCalloutLayouter(pass, story.startIdx, story.plan, input.styleOf(frame)),
459
+ L: makeCalloutLayouter(pass, story.startIdx, story.plan, styleOf(frame)),
51
460
  cut: CUT_START,
52
461
  pieces: 0,
53
462
  headBlockId: frameIdOf(head),
@@ -82,10 +491,15 @@ export function layoutComposedPage(input) {
82
491
  const frames = [];
83
492
  const vdtByName = new Map();
84
493
  const blocks = [];
85
- const warnings = [];
86
494
  const overflowLog = [];
495
+ const fitLog = [];
496
+ const needs = new Map();
497
+ /** What `fitBox` did to each frame (#861), last placement wins. */
498
+ const fitted = new Map();
87
499
  const laid = new Set();
88
- const pushed = seed ? [...seed].map(([name, box]) => ({ name, box })) : [];
500
+ const pushed = seed
501
+ ? [...seed].map(([name, box]) => ({ name, box, ...(seedShapes?.get(name) ? { contour: seedShapes.get(name) } : {}) }))
502
+ : [];
89
503
  /** The rectangles of the pushing frames placed so far that `rect`
90
504
  * meets, in the frame block's coordinates. */
91
505
  const exclusionsFor = (frame, rect, open) => {
@@ -94,11 +508,17 @@ export function layoutComposedPage(input) {
94
508
  for (const p of pushed) {
95
509
  if (p.name === frame.name)
96
510
  continue;
97
- const b = p.box;
511
+ // A contour (#883) pushes with its shape, its margin round it.
512
+ const cb = p.contour ? contourBounds(p.contour) : undefined;
513
+ const b = cb ?? p.box;
514
+ const e = cb ? 0 : g;
98
515
  const below = open ? true : b.y < rect.y + rect.height;
99
- if (b.x - g >= rect.x + rect.width || b.x + b.width + g <= rect.x || !below || b.y + b.height + g <= rect.y)
516
+ if (b.x - e >= rect.x + rect.width || b.x + b.width + e <= rect.x || !below || b.y + b.height + e <= rect.y)
100
517
  continue;
101
- out.push({ x: b.x - g - rect.x, y: b.y - g - rect.y, width: b.width + 2 * g, height: b.height + 2 * g });
518
+ out.push({
519
+ x: b.x - e - rect.x, y: b.y - e - rect.y, width: b.width + 2 * e, height: b.height + 2 * e,
520
+ ...(cb && p.contour ? { contour: shiftContour(p.contour, -rect.x, -rect.y) } : {}),
521
+ });
102
522
  }
103
523
  return out.length > 0 ? out : undefined;
104
524
  };
@@ -107,7 +527,7 @@ export function layoutComposedPage(input) {
107
527
  // Each frame of a chain sets the story in its own style: the cuts are
108
528
  // positions in the story's blocks, the same in any.
109
529
  if (st)
110
- st.L = makeCalloutLayouter(pass, st.story.startIdx, st.story.plan, input.styleOf(frame));
530
+ st.L = makeCalloutLayouter(pass, st.story.startIdx, st.story.plan, styleOf(frame));
111
531
  // A frame continues its story only once the frame before it in the
112
532
  // chain was filled (a frame placed before it, through an anchor, starts
113
533
  // empty).
@@ -119,9 +539,13 @@ export function layoutComposedPage(input) {
119
539
  return { frame, st, before, holds, continuation, blockId };
120
540
  };
121
541
  /** The content's height at `width`, running round the pushing frames
122
- * below the top of `at` (the frame's box as first placed). */
542
+ * below the top of `at` (the frame's box as first placed). A frame
543
+ * whose text the ladder scales (#858) is laid out `1 / scale` as
544
+ * wide and is `scale` as tall as that. */
123
545
  const naturalOf = (p, at) => {
124
546
  const memo = new Map();
547
+ const s = textScale(p.frame);
548
+ const pic = pictureScale(p.frame);
125
549
  return (width) => {
126
550
  if (!p.holds)
127
551
  return 0;
@@ -129,10 +553,12 @@ export function layoutComposedPage(input) {
129
553
  const known = memo.get(key);
130
554
  if (known !== undefined)
131
555
  return known;
132
- const exclusions = at ? exclusionsFor(p.frame, { ...at, width }, true) : undefined;
133
- const r = p.st.L.layoutRange(p.st.cut, p.st.L.end, width, p.blockId, p.continuation, input.mirrored, exclusions ? { exclusions } : undefined);
134
- memo.set(key, r.totalHeight);
135
- return r.totalHeight;
556
+ const exclusions = at ? unscale(exclusionsFor(p.frame, { ...at, width }, true), s) : undefined;
557
+ const opts = { ...(exclusions ? { exclusions } : {}), ...(pic !== undefined ? { pictureScale: pic } : {}) };
558
+ const r = p.st.L.layoutRange(p.st.cut, p.st.L.end, width / s, p.blockId, p.continuation, input.mirrored, opts.exclusions || opts.pictureScale !== undefined ? opts : undefined);
559
+ const h = r.totalHeight * s;
560
+ memo.set(key, h);
561
+ return h;
136
562
  };
137
563
  };
138
564
  /** `placeBox`, then the box the page lets the frame take (`fitBox`:
@@ -143,10 +569,18 @@ export function layoutComposedPage(input) {
143
569
  const r = placeBox(placeable, template, ctx, refs, nat);
144
570
  if (!input.fitBox)
145
571
  return r;
146
- const fitted = input.fitBox(frame, r);
147
- if (Math.abs(fitted.width - r.width) < 0.01)
148
- return fitted;
149
- return input.fitBox(frame, placeBox(placeable, template, ctx, refs, () => nat(fitted.width)));
572
+ const keep = (placed) => {
573
+ const kept = input.fitBox(frame, placed);
574
+ if (sameRect(kept, placed))
575
+ fitted.delete(frame.name);
576
+ else
577
+ fitted.set(frame.name, { placed, kept });
578
+ return kept;
579
+ };
580
+ const first = keep(r);
581
+ if (Math.abs(first.width - r.width) < 0.01)
582
+ return first;
583
+ return keep(placeBox(placeable, template, ctx, refs, () => nat(first.width)));
150
584
  };
151
585
  /** A frame's height as its template declares it: its `minHeight` where
152
586
  * the height follows the content, else the content's (`natural`). */
@@ -177,10 +611,13 @@ export function layoutComposedPage(input) {
177
611
  return rect;
178
612
  };
179
613
  /** Lay a group: its frames' boxes as the template declares them and as
180
- * their content needs (`composed/groups.ts`). */
614
+ * their content needs (`composed/groups.ts`). With the ladder's
615
+ * `redistribute` (#858) every frame gives space back, down to its
616
+ * minimum or its content, whether it `shrink`s or not. */
181
617
  const placeGroup = (group, members) => {
182
618
  const own = groupPlaceable(group);
183
619
  const mmPx = dpi / MM_PER_IN;
620
+ const yieldAll = fit.yieldAll.has(group.name);
184
621
  const preps = members.map(prepare);
185
622
  const solve = (mode, naturals) => {
186
623
  const refs = mode === 'final' ? refOf : declaredRefOf;
@@ -200,12 +637,13 @@ export function layoutComposedPage(input) {
200
637
  const fill = f.height === 'fill';
201
638
  const minH = f.minHeight ? dimensionToPx(f.minHeight, dpi, input.emPx) : 0;
202
639
  const want = fill ? minH : auto ? (mode === 'final' ? natural(box.width) : declaredNatural(f, natural)(box.width)) : box.height;
203
- return {
640
+ const max = f.maxHeight ? dimensionToPx(f.maxHeight, dpi, input.emPx) : Infinity;
641
+ const item = {
204
642
  name: f.name,
205
643
  own: box,
206
644
  want,
207
645
  min: minH,
208
- max: f.maxHeight ? dimensionToPx(f.maxHeight, dpi, input.emPx) : Infinity,
646
+ max,
209
647
  minWidth: f.minWidth ? dimensionToPx(f.minWidth, dpi, input.emPx) : 0,
210
648
  maxWidth: f.maxWidth ? dimensionToPx(f.maxWidth, dpi, input.emPx) : Infinity,
211
649
  grow: fill ? Math.max(1, f.grow) : f.grow,
@@ -215,6 +653,17 @@ export function layoutComposedPage(input) {
215
653
  fixedHeight: !auto && !fill,
216
654
  need: natural,
217
655
  };
656
+ if (yieldAll && mode === 'final' && !f.pin) {
657
+ // Down to its minimum, never under what its content needs, never
658
+ // over the height it has.
659
+ const height = Math.min(Math.max(want, minH), max);
660
+ item.shrink = f.shrink > 0 ? f.shrink : 1;
661
+ // (A row's frames give width, each down to the narrowest width
662
+ // its content still fits: their heights stay the row's.)
663
+ if (group.kind === 'stack')
664
+ item.min = Math.min(height, Math.max(minH, natural(box.width)));
665
+ }
666
+ return item;
218
667
  });
219
668
  const firstFree = items.find((it) => !it.pin) ?? items[0];
220
669
  const start = firstFree?.own ?? input.areas.area;
@@ -257,41 +706,51 @@ export function layoutComposedPage(input) {
257
706
  groupDeclared.set(group.name, decl.box);
258
707
  return fin.rects;
259
708
  };
260
- /** Pour a frame's content into its box and record the frame. */
709
+ /** Pour a frame's content into its box and record the frame. A frame
710
+ * whose text the ladder scales (#858) is laid out in a box `1 / scale`
711
+ * as large, painted at `scale` (`frameMatrix`). */
261
712
  const fill = (p, rect) => {
262
713
  const { frame, st, holds, continuation, blockId, before } = p;
263
- const style = input.styleOf(frame);
714
+ const style = styleOf(frame);
715
+ const s = textScale(frame);
716
+ const pic = pictureScale(frame);
717
+ const laidW = rect.width / s;
718
+ const laidH = rect.height / s;
264
719
  let result;
265
720
  let overflowPx = 0;
266
721
  const from = st?.cut ?? CUT_START;
267
722
  if (holds) {
268
- const opts = {
269
- boxHeight: rect.height,
270
- verticalAlign: frame.verticalAlign,
271
- ...(exclusionsFor(frame, rect, false) ? { exclusions: exclusionsFor(frame, rect, false) } : {}),
272
- };
273
- const whole = st.L.layoutRange(from, st.L.end, rect.width, blockId, continuation, input.mirrored, opts);
723
+ // What its content needs at its width, measured only when a
724
+ // proposal asks (`lowerMin`), from where the frame starts.
725
+ const width = rect.width;
726
+ const startAt = { ...p, st: { ...st, cut: from } };
727
+ let known;
728
+ needs.set(frame.name, () => (known ??= naturalOf(startAt)(width)));
729
+ const exclusions = unscale(exclusionsFor(frame, rect, false), s);
730
+ const extra = { ...(exclusions ? { exclusions } : {}), ...(pic !== undefined ? { pictureScale: pic } : {}) };
731
+ const opts = { boxHeight: laidH, verticalAlign: frame.verticalAlign, ...extra };
732
+ const whole = st.L.layoutRange(from, st.L.end, laidW, blockId, continuation, input.mirrored, opts);
274
733
  const nextName = nextFrameOf(frame);
275
734
  const nextFrame = nextName !== undefined ? byName.get(nextName) : undefined;
276
735
  if ((whole.overflowPx ?? 0) > 0.5 && nextFrame && !vdtByName.has(nextFrame.name)) {
277
736
  // The rest goes on in the next frame of the chain.
278
737
  const minLines = Math.max(1, style.splitMinLines);
279
- const frag = splitCalloutFragment(pass, st.L, from, rect.width, rect.height, blockId, continuation, minLines, input.mirrored, opts.exclusions ? { exclusions: opts.exclusions } : undefined);
738
+ const frag = splitCalloutFragment(pass, st.L, from, laidW, laidH, blockId, continuation, minLines, input.mirrored, extra.exclusions || extra.pictureScale !== undefined ? extra : undefined);
280
739
  const to = frag?.to ?? from;
281
740
  result = frag
282
- ? st.L.layoutRange(from, to, rect.width, blockId, continuation, input.mirrored, opts)
741
+ ? st.L.layoutRange(from, to, laidW, blockId, continuation, input.mirrored, opts)
283
742
  : undefined;
284
743
  st.cut = to;
285
744
  }
286
745
  else {
287
746
  result = whole;
288
- overflowPx = whole.overflowPx ?? 0;
747
+ overflowPx = (whole.overflowPx ?? 0) * s;
289
748
  st.cut = st.L.end;
290
749
  }
291
750
  }
292
751
  else if (st && frame.style) {
293
752
  // A styled frame with nothing (left) to hold still draws its box.
294
- result = st.L.layoutRange(st.L.end, st.L.end, rect.width, blockId, true, input.mirrored, { boxHeight: rect.height });
753
+ result = st.L.layoutRange(st.L.end, st.L.end, laidW, blockId, true, input.mirrored, { boxHeight: laidH });
295
754
  }
296
755
  const vdt = {
297
756
  id: frameIdOf(frame.name),
@@ -300,7 +759,8 @@ export function layoutComposedPage(input) {
300
759
  rotate: frame.rotate,
301
760
  layer: frame.layer,
302
761
  ...(frame.style ? { styleId: frame.style } : {}),
303
- scale: 1,
762
+ scale: s,
763
+ ...(pic !== undefined && holds ? { imageScale: pic } : {}),
304
764
  overflowPx,
305
765
  blockIds: [],
306
766
  configPath: frame.configPath,
@@ -321,26 +781,36 @@ export function layoutComposedPage(input) {
321
781
  st.pieces++;
322
782
  st.lastFrameId = vdt.id;
323
783
  laid.add(headOf(frame.name));
784
+ // What the ladder changed in the frame's content (#858).
785
+ const pictures = fit.pictures.get(headOf(frame.name));
786
+ if (pictures && result.children.some((b) => b.type === 'resource')) {
787
+ fitLog.push({ frame: frame.name, kind: 'imageScaled', scale: pictures.scale, ...(pictures.by !== frame.name ? { cause: pictures.by } : {}) });
788
+ }
789
+ if (s !== 1 && holds)
790
+ fitLog.push({ frame: frame.name, kind: 'textScaled', scale: s });
324
791
  }
325
792
  if (overflowPx > 0.5) {
326
793
  const overflowMm = Math.round((overflowPx / dpi) * MM_PER_IN * 10) / 10;
327
794
  overflowLog.push({ frame: frame.name, kind: 'overflow', toMm: overflowMm });
328
- warnings.push({
329
- kind: 'frameOverflow',
330
- template: template.id,
331
- frame: frame.name,
332
- fenceIndex: fi,
333
- overflowMm,
334
- configPath: frame.configPath,
335
- ...(vdt.sourceStart !== undefined ? { sourceStart: vdt.sourceStart, sourceEnd: vdt.sourceEnd } : {}),
336
- pageIndex: page.index,
337
- });
338
795
  }
339
796
  if (frame.exclude === 'push') {
340
797
  const box = frameBounds({ rect, rotate: frame.rotate });
341
798
  pushRects.set(frame.name, box);
799
+ // `wrap: 'contour'` (#883): the shape of the pictures it holds; a
800
+ // turned frame pushes with its box.
801
+ const shape = frame.wrap === 'contour' && frame.rotate === 0 && result
802
+ ? frameContour(result.children, { gapPx: input.pushGapPx, dpi, emPx: input.emPx, mirrored: pageIsMirrored(input.page), pageIndex: input.page.index })
803
+ : undefined;
804
+ if (shape)
805
+ notes.push(...shape.notes);
806
+ // A frame whose text the ladder scaled (#858) holds its pictures in
807
+ // its laid-out coordinates: on the page they are `s` as large, from
808
+ // its box's corner (`frameMatrix`).
809
+ const contour = shape?.contour && s !== 1 ? scaleContourAbout(shape.contour, rect.x, rect.y, s, false) : shape?.contour;
810
+ if (contour)
811
+ pushShapes.set(frame.name, contour);
342
812
  if (!seed)
343
- pushed.push({ name: frame.name, box });
813
+ pushed.push({ name: frame.name, box, ...(contour ? { contour } : {}) });
344
814
  }
345
815
  frames.push(vdt);
346
816
  vdtByName.set(frame.name, vdt);
@@ -357,29 +827,12 @@ export function layoutComposedPage(input) {
357
827
  for (const f of step.frames)
358
828
  fill(prepare(f), solved.get(f.name));
359
829
  }
360
- const log = mergeLog(reflowEvents(motionsOf(template, rects, declared), dpi / MM_PER_IN), overflowLog, template);
361
- return { frames, blocks, warnings, log, laid, pushRects };
830
+ const log = mergeLog(reflowEvents(motionsOf(template, rects, declared), dpi / MM_PER_IN), [...fitLog, ...overflowLog], template);
831
+ return { frames, blocks, log, laid, needs, pushRects, pushShapes, fitted, notes };
362
832
  };
363
833
  const first = run();
364
- const { frames, blocks, warnings, log, laid } = first.pushRects.size > 0 ? run(first.pushRects) : first;
365
- // Template order on the page record.
366
- const order = new Map(template.frames.map((f, i) => [f.name, i]));
367
- frames.sort((a, b) => order.get(a.name) - order.get(b.name));
368
- const contentBlocks = pass.contentBlocks;
369
- const composed = {
370
- template: template.id,
371
- ...(template.variant ? { variant: template.variant } : {}),
372
- fenceIndex: fi,
373
- role: template.role,
374
- attrs: { ...fence.attrs },
375
- attrSources: Object.fromEntries(Object.entries(fence.attrSources).map(([k, v]) => [k, { sourceStart: v.sourceStart + input.bodyOffset, sourceEnd: v.sourceEnd + input.bodyOffset }])),
376
- frames,
377
- log,
378
- configPath: template.configPath,
379
- sourceStart: contentBlocks[input.startIdx].sourceStart + input.bodyOffset,
380
- sourceEnd: contentBlocks[input.endIdx].sourceEnd + input.bodyOffset,
381
- };
382
- return { composed, blocks, warnings, laid };
834
+ const { frames, blocks, log, laid, needs, fitted, notes } = first.pushRects.size > 0 ? run(first.pushRects, first.pushShapes) : first;
835
+ return { frames, blocks, log, laid, needs, template, fitted, ...(notes.length > 0 ? { notes } : {}) };
383
836
  }
384
837
  const sameRect = (a, b) => Math.abs(a.x - b.x) < 0.01 && Math.abs(a.y - b.y) < 0.01 && Math.abs(a.width - b.width) < 0.01 && Math.abs(a.height - b.height) < 0.01;
385
838
  /** Each laid frame's declared and final boxes, in template order, with
@@ -439,6 +892,7 @@ function commitFrame(result, rect, story, range, input, vdt, out) {
439
892
  }
440
893
  box.pageIndex = page.index;
441
894
  box.columnIndex = 0;
895
+ stampFrameAttrText(box, blocks[story.startIdx]?.containerAttrSources, story.plan.attrs, bodyOffset);
442
896
  offsetCalloutToAbsolute(result, rect.x, rect.y);
443
897
  out.push(box);
444
898
  vdt.blockIds.push(box.id);
@@ -449,6 +903,37 @@ function commitFrame(result, rect, story, range, input, vdt, out) {
449
903
  vdt.blockIds.push(child.id);
450
904
  }
451
905
  }
906
+ /** The frame's tab (`label`) and title (`title`) as the box draws them,
907
+ * stamped with where the `:::frame` fence writes them (#857): the
908
+ * attribute's value range, and a per-character map when the text is
909
+ * printed as written. */
910
+ function stampFrameAttrText(box, sources, attrs, bodyOffset) {
911
+ if (!sources || !box.designOverlay)
912
+ return;
913
+ for (const name of ['label', 'title']) {
914
+ const src = sources[name];
915
+ const value = attrs[name];
916
+ if (!src || value === undefined || value.trim() === '')
917
+ continue;
918
+ const start = src.sourceStart + bodyOffset;
919
+ const end = src.sourceEnd + bodyOffset;
920
+ for (const b of box.designOverlay.blocks) {
921
+ if (b.kind !== 'text' || b.attr !== undefined)
922
+ continue;
923
+ const shown = b.lines.map((l) => l.text).join(' ');
924
+ if (shown.trim() !== value.trim() && shown.trim().toLocaleLowerCase() !== value.trim().toLocaleLowerCase())
925
+ continue;
926
+ b.sourceStart = start;
927
+ b.sourceEnd = end;
928
+ b.attr = name;
929
+ if (shown === value && end - start === value.length) {
930
+ b.sourceText = value;
931
+ b.sourceMap = Array.from({ length: value.length }, (_, i) => start + i);
932
+ }
933
+ break;
934
+ }
935
+ }
936
+ }
452
937
  /** The callout style a frame is drawn with: `base` (its `style`, or the
453
938
  * frameless frame style) with what a frame sets of its own: its padding
454
939
  * (side by side) and a measure that is the frame's. A style's placement,