postext 0.3.42 → 0.3.44

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 (120) hide show
  1. package/README.md +1 -1
  2. package/dist/__tests__/createLayout.test.js +5 -65
  3. package/dist/__tests__/createLayout.test.js.map +1 -1
  4. package/dist/__tests__/defaults/calloutStyles.test.js +3 -0
  5. package/dist/__tests__/defaults/calloutStyles.test.js.map +1 -1
  6. package/dist/__tests__/parse/containers.test.js +1 -1
  7. package/dist/__tests__/parse/containers.test.js.map +1 -1
  8. package/dist/__tests__/parse/scripts.test.d.ts +2 -0
  9. package/dist/__tests__/parse/scripts.test.d.ts.map +1 -0
  10. package/dist/__tests__/parse/scripts.test.js +68 -0
  11. package/dist/__tests__/parse/scripts.test.js.map +1 -0
  12. package/dist/__tests__/pipeline/calloutColumnsLabel.test.d.ts +2 -0
  13. package/dist/__tests__/pipeline/calloutColumnsLabel.test.d.ts.map +1 -0
  14. package/dist/__tests__/pipeline/calloutColumnsLabel.test.js +163 -0
  15. package/dist/__tests__/pipeline/calloutColumnsLabel.test.js.map +1 -0
  16. package/dist/__tests__/pipeline/calloutFloat.test.d.ts +2 -0
  17. package/dist/__tests__/pipeline/calloutFloat.test.d.ts.map +1 -0
  18. package/dist/__tests__/pipeline/calloutFloat.test.js +138 -0
  19. package/dist/__tests__/pipeline/calloutFloat.test.js.map +1 -0
  20. package/dist/__tests__/pipeline/calloutPlacement.test.js +10 -6
  21. package/dist/__tests__/pipeline/calloutPlacement.test.js.map +1 -1
  22. package/dist/__tests__/pipeline/floatPlacement.test.js +3 -3
  23. package/dist/__tests__/pipeline/floatPlacement.test.js.map +1 -1
  24. package/dist/__tests__/pipeline/sideColumn.test.d.ts +2 -0
  25. package/dist/__tests__/pipeline/sideColumn.test.d.ts.map +1 -0
  26. package/dist/__tests__/pipeline/sideColumn.test.js +130 -0
  27. package/dist/__tests__/pipeline/sideColumn.test.js.map +1 -0
  28. package/dist/__tests__/pipeline/spanBlocks.test.js +11 -3
  29. package/dist/__tests__/pipeline/spanBlocks.test.js.map +1 -1
  30. package/dist/canvas-backend/blockRender.js +2 -2
  31. package/dist/canvas-backend/blockRender.js.map +1 -1
  32. package/dist/canvas-backend/renderResourceBlock.js +2 -2
  33. package/dist/canvas-backend/renderResourceBlock.js.map +1 -1
  34. package/dist/columnRule.d.ts.map +1 -1
  35. package/dist/columnRule.js +3 -0
  36. package/dist/columnRule.js.map +1 -1
  37. package/dist/defaults/calloutStyles.d.ts +23 -0
  38. package/dist/defaults/calloutStyles.d.ts.map +1 -1
  39. package/dist/defaults/calloutStyles.js +68 -0
  40. package/dist/defaults/calloutStyles.js.map +1 -1
  41. package/dist/defaults/headerFooter.d.ts.map +1 -1
  42. package/dist/defaults/headerFooter.js +2 -0
  43. package/dist/defaults/headerFooter.js.map +1 -1
  44. package/dist/defaults/headings.d.ts.map +1 -1
  45. package/dist/defaults/headings.js +7 -1
  46. package/dist/defaults/headings.js.map +1 -1
  47. package/dist/defaults/layout.d.ts.map +1 -1
  48. package/dist/defaults/layout.js +12 -0
  49. package/dist/defaults/layout.js.map +1 -1
  50. package/dist/design/layout.d.ts +3 -0
  51. package/dist/design/layout.d.ts.map +1 -1
  52. package/dist/design/layout.js +149 -5
  53. package/dist/design/layout.js.map +1 -1
  54. package/dist/html-backend.d.ts.map +1 -1
  55. package/dist/html-backend.js +8 -4
  56. package/dist/html-backend.js.map +1 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js.map +1 -1
  60. package/dist/knuthPlass/richAdapter.d.ts +3 -0
  61. package/dist/knuthPlass/richAdapter.d.ts.map +1 -1
  62. package/dist/knuthPlass/richAdapter.js +1 -0
  63. package/dist/knuthPlass/richAdapter.js.map +1 -1
  64. package/dist/measure/font.d.ts +9 -0
  65. package/dist/measure/font.d.ts.map +1 -1
  66. package/dist/measure/font.js +17 -1
  67. package/dist/measure/font.js.map +1 -1
  68. package/dist/measure/rich.d.ts +17 -0
  69. package/dist/measure/rich.d.ts.map +1 -1
  70. package/dist/measure/rich.js +45 -4
  71. package/dist/measure/rich.js.map +1 -1
  72. package/dist/parse/blockParser.d.ts.map +1 -1
  73. package/dist/parse/blockParser.js +1 -1
  74. package/dist/parse/blockParser.js.map +1 -1
  75. package/dist/parse/inlineFormatting.d.ts +5 -0
  76. package/dist/parse/inlineFormatting.d.ts.map +1 -1
  77. package/dist/parse/inlineFormatting.js +29 -4
  78. package/dist/parse/inlineFormatting.js.map +1 -1
  79. package/dist/parse/types.d.ts +4 -1
  80. package/dist/parse/types.d.ts.map +1 -1
  81. package/dist/pipeline/build.d.ts.map +1 -1
  82. package/dist/pipeline/build.js +452 -85
  83. package/dist/pipeline/build.js.map +1 -1
  84. package/dist/pipeline/calloutLayout.d.ts +4 -0
  85. package/dist/pipeline/calloutLayout.d.ts.map +1 -1
  86. package/dist/pipeline/calloutLayout.js +390 -131
  87. package/dist/pipeline/calloutLayout.js.map +1 -1
  88. package/dist/pipeline/columnBalancing.js +1 -1
  89. package/dist/pipeline/columnBalancing.js.map +1 -1
  90. package/dist/pipeline/config.d.ts +16 -1
  91. package/dist/pipeline/config.d.ts.map +1 -1
  92. package/dist/pipeline/config.js +35 -2
  93. package/dist/pipeline/config.js.map +1 -1
  94. package/dist/pipeline/floatPlacement.d.ts +21 -0
  95. package/dist/pipeline/floatPlacement.d.ts.map +1 -1
  96. package/dist/pipeline/floatPlacement.js +12 -3
  97. package/dist/pipeline/floatPlacement.js.map +1 -1
  98. package/dist/pipeline/floatSlots.d.ts +17 -0
  99. package/dist/pipeline/floatSlots.d.ts.map +1 -1
  100. package/dist/pipeline/floatSlots.js +27 -3
  101. package/dist/pipeline/floatSlots.js.map +1 -1
  102. package/dist/pipeline/headerFooter.js +1 -1
  103. package/dist/pipeline/headerFooter.js.map +1 -1
  104. package/dist/pipeline/measureContentBlock.d.ts.map +1 -1
  105. package/dist/pipeline/measureContentBlock.js +13 -3
  106. package/dist/pipeline/measureContentBlock.js.map +1 -1
  107. package/dist/pipeline/placement.d.ts +12 -1
  108. package/dist/pipeline/placement.d.ts.map +1 -1
  109. package/dist/pipeline/placement.js +50 -7
  110. package/dist/pipeline/placement.js.map +1 -1
  111. package/dist/pipeline/resourceLayout.d.ts +17 -0
  112. package/dist/pipeline/resourceLayout.d.ts.map +1 -1
  113. package/dist/pipeline/resourceLayout.js +45 -11
  114. package/dist/pipeline/resourceLayout.js.map +1 -1
  115. package/dist/types.d.ts +174 -62
  116. package/dist/types.d.ts.map +1 -1
  117. package/dist/vdt.d.ts +11 -2
  118. package/dist/vdt.d.ts.map +1 -1
  119. package/dist/vdt.js.map +1 -1
  120. package/package.json +1 -1
@@ -97,6 +97,7 @@ export function deriveCalloutResolvedConfig(resolved, style) {
97
97
  fontSize: body.fontSize,
98
98
  lineHeight: body.lineHeight,
99
99
  color: body.color,
100
+ boldColor: body.boldColor ?? body.color,
100
101
  textAlign: body.textAlign,
101
102
  hyphenation: { ...resolved.bodyText.hyphenation, enabled: body.hyphenation },
102
103
  paragraphSpacing: body.paragraphSpacing,
@@ -113,6 +114,10 @@ export function deriveCalloutResolvedConfig(resolved, style) {
113
114
  ...l,
114
115
  bulletChar: bulletOverride ? lists.bulletChar : l.bulletChar,
115
116
  color: colorOverride ? lists.color : l.color,
117
+ // A bullet size / weight of its own sets the glyph in the box's body
118
+ // face at that size (the document's list face otherwise).
119
+ ...(lists.bulletFontSize ? { fontSize: lists.bulletFontSize, fontFamily: body.fontFamily } : {}),
120
+ ...(lists.bulletFontWeight !== undefined ? { fontWeight: lists.bulletFontWeight } : {}),
116
121
  })),
117
122
  },
118
123
  orderedLists: {
@@ -141,7 +146,7 @@ export function deriveCalloutMeasureContext(ctx, style) {
141
146
  orderedLevelIndentsPx: computeOrderedLevelIndentsPx(resolved, bodyStyle.fontSizePx, ctx.orderedMetrics.maxWidthByDepth),
142
147
  };
143
148
  }
144
- const VALID_SPANS = new Set(['column', 'page']);
149
+ const VALID_SPANS = new Set(['column', 'page', 'side']);
145
150
  const VALID_PLACEMENTS = new Set(['here', 'top', 'bottom', 'fixed']);
146
151
  /** Per-instance span / placement: fence attribute when valid, else the style. */
147
152
  export function resolveCalloutAttrs(style, attrs) {
@@ -152,7 +157,8 @@ export function resolveCalloutAttrs(style, attrs) {
152
157
  ? attrs.placement
153
158
  : style.placement;
154
159
  const title = attrs.title !== undefined ? attrs.title : style.title;
155
- return { span, placement, title };
160
+ const label = attrs.label ?? '';
161
+ return { span, placement, title, label };
156
162
  }
157
163
  /** Shift a resolved resource block's caption/table geometry by `(ox, oy)`. */
158
164
  function offsetResourceBlock(rb, ox, oy) {
@@ -242,7 +248,7 @@ export function layoutCallout(input) {
242
248
  const bodyStyle = derivedCtx.bodyStyle;
243
249
  const em = bodyStyle.fontSizePx;
244
250
  const px = (d) => dimensionToPx(d, dpi, em);
245
- const { span, placement, title: rawTitle } = resolveCalloutAttrs(style, attrs);
251
+ const { span, placement, title: rawTitle, label: labelText } = resolveCalloutAttrs(style, attrs);
246
252
  const isAuto = style.width === 'auto';
247
253
  // --- Marker column (outside the box, on its left) --------------------------
248
254
  const hasMarker = iconPresent(style.marker);
@@ -266,6 +272,7 @@ export function layoutCallout(input) {
266
272
  const topStripe = stripeOn && style.stripe.side === 'top';
267
273
  const hasIcon = !input.continuation && iconPresent(style.icon);
268
274
  const iconSize = hasIcon ? px(style.icon.size) : 0;
275
+ const iconBoxW = hasIcon && style.icon.width ? Math.max(iconSize, px(style.icon.width)) : iconSize;
269
276
  const gapPx = px(style.titleStyle.gap);
270
277
  // The icon takes its own column only when there is no side stripe to sit
271
278
  // over. A continuation that opens inside a paragraph (`lineFrom`) keeps
@@ -273,8 +280,9 @@ export function layoutCallout(input) {
273
280
  // so the text must wrap the same way — line `lineFrom` on, nothing lost
274
281
  // or doubled.
275
282
  const openingMidRun = !!input.continuation && (input.lineFrom ?? 0) > 0;
276
- const iconColumnKept = (hasIcon || (openingMidRun && iconPresent(style.icon))) && !sideStripe;
277
- const iconColumn = iconColumnKept ? px(style.icon.size) + gapPx : 0;
283
+ const cornerIcon = style.icon.position === 'corner';
284
+ const iconColumnKept = (hasIcon || (openingMidRun && iconPresent(style.icon))) && !sideStripe && !cornerIcon;
285
+ const iconColumn = iconColumnKept ? iconBoxW + gapPx : 0;
278
286
  const innerX = (stripeLeft ? stripeW : 0) + padL + iconColumn;
279
287
  const innerTop = (topStripe ? stripeW : 0) + padT;
280
288
  // --- Title -----------------------------------------------------------------
@@ -284,13 +292,16 @@ export function layoutCallout(input) {
284
292
  const titleFontPx = dimensionToPx(style.titleStyle.fontSize, dpi, em);
285
293
  const titleFont = buildFontString(style.titleStyle.fontFamily, titleFontPx, style.titleStyle.fontWeight.toString(), style.titleStyle.italic ? 'italic' : 'normal');
286
294
  const titleLineHeight = titleFontPx * 1.2;
295
+ const titleTrackingPx = Math.max(0, dimensionToPx(style.titleStyle.letterSpacing, dpi, titleFontPx));
296
+ const titleIndentPx = Math.max(0, dimensionToPx(style.titleStyle.indent, dpi, titleFontPx));
287
297
  const hasTitle = !input.continuation && titleText.trim().length > 0;
298
+ const titleWidthOf = (t) => measureTextWidth(t, titleFont) + titleTrackingPx * t.length;
288
299
  // Box width: `fill` uses the given width less the marker column; `auto`
289
300
  // shrink-wraps the title.
290
301
  let boxWidth = input.width - markerColumn;
291
302
  let innerWidth;
292
303
  if (isAuto) {
293
- const titleW = hasTitle ? measureTextWidth(titleText, titleFont) : 0;
304
+ const titleW = hasTitle ? titleWidthOf(titleText) + titleIndentPx : 0;
294
305
  innerWidth = Math.max(1, titleW);
295
306
  boxWidth = innerX + innerWidth + padR + (stripeRight ? stripeW : 0);
296
307
  }
@@ -301,14 +312,18 @@ export function layoutCallout(input) {
301
312
  const overlayBlocks = [];
302
313
  let titleBlock;
303
314
  if (hasTitle) {
304
- const measured = measureBlock(titleText, titleFont, innerWidth, titleLineHeight, { textAlign: 'left' });
315
+ const titleW = Math.max(1, innerWidth - titleIndentPx);
316
+ const measured = measureBlock(titleText, titleFont, titleW, titleLineHeight, {
317
+ textAlign: 'left',
318
+ ...(titleTrackingPx > 0 ? { letterSpacingPx: titleTrackingPx } : {}),
319
+ });
305
320
  const lines = measured.lines.length > 0
306
321
  ? measured.lines
307
- : [{ text: titleText, bbox: createBoundingBox(0, 0, innerWidth, titleLineHeight), baseline: titleLineHeight * 0.8, hyphenated: false }];
322
+ : [{ text: titleText, bbox: createBoundingBox(0, 0, titleW, titleLineHeight), baseline: titleLineHeight * 0.8, hyphenated: false }];
308
323
  const titleHeight = lines.length * titleLineHeight;
309
324
  titleBlock = {
310
325
  kind: 'text',
311
- bbox: createBoundingBox(innerX, cursorY, innerWidth, titleHeight),
326
+ bbox: createBoundingBox(innerX + titleIndentPx, cursorY, titleW, titleHeight),
312
327
  fontString: titleFont,
313
328
  color: style.titleStyle.color.hex,
314
329
  lines: lines.map((ln, i) => ({
@@ -318,137 +333,286 @@ export function layoutCallout(input) {
318
333
  width: ln.bbox.width,
319
334
  })),
320
335
  clip: false,
336
+ ...(titleTrackingPx > 0 ? { letterSpacingPx: titleTrackingPx } : {}),
321
337
  };
322
338
  cursorY += titleHeight;
323
339
  }
324
340
  // --- Children --------------------------------------------------------------
341
+ // The children stack under the title; a `:::columns{count=N}` group among
342
+ // them lays its own children out in N columns of equal width, cut at
343
+ // the line boundaries that level the columns best, and takes the height
344
+ // of the tallest column.
325
345
  const childBlocks = [];
326
- if (!isAuto) {
327
- let prevMarginBottom = hasTitle ? gapPx : 0;
328
- let prevWasListItem = false;
329
- let first = true;
330
- for (let k = 0; k < children.length; k++) {
346
+ const columnGapPx = px(style.columnGap);
347
+ const isFirstRealOf = (k) => children.slice(0, k).every((c) => c.type === 'directive' || isMarkerBlock(c));
348
+ const isLastRealOf = (k) => children.slice(k + 1).every((c) => c.type === 'directive' || isMarkerBlock(c));
349
+ /** Lay out one child at `width` from `x`, appending it to `into` and
350
+ * advancing the stack. */
351
+ const placeChild = (raw, k, width, x, st, into) => {
352
+ const blockIdx = childStartIdx + k;
353
+ const measuredBlock = measureContentBlock(raw, blockIdx, width, derivedCtx, {
354
+ styleOverride: input.paragraphStyleFor?.(blockIdx),
355
+ });
356
+ if (!measuredBlock)
357
+ return undefined;
358
+ const { kind, measured, prefixLen, absoluteSourceMap, mathDisplayRender, resourceBlock } = measuredBlock;
359
+ const { style: bs, vdtType, headingLevel, numberPrefix, listBullet, listDepth, listKind, bulletXOffsetInColumn, strikethroughText } = kind;
360
+ // Spacing above: margin collapsing, list-item spacing inside a run.
361
+ let spacing;
362
+ if (st.first) {
363
+ spacing = st.prevMarginBottom;
364
+ }
365
+ else if (vdtType === 'listItem' && st.prevWasListItem) {
366
+ spacing = listBullet ? listBullet.itemSpacingPx : 0;
367
+ }
368
+ else {
369
+ spacing = Math.max(st.prevMarginBottom, bs.marginTopPx);
370
+ }
371
+ st.cursorY += spacing;
372
+ const blk = createVDTBlock(input.nextChildId(), vdtType, bs.fontString, bs.color, bs.textAlign);
373
+ applyStyleAttrs(blk, bs);
374
+ blk.contentIndex = blockIdx;
375
+ blk.containerId = containerId;
376
+ blk.dirty = false;
377
+ blk.snappedToGrid = false;
378
+ blk.headingLevel = headingLevel;
379
+ if (numberPrefix)
380
+ blk.numberPrefix = numberPrefix;
381
+ blk.sourceMap = absoluteSourceMap;
382
+ blk.plainPrefixLen = prefixLen;
383
+ // Line-level cut of the first / last child (split boxes): the kept
384
+ // run of lines, and whether this child opens after its first line
385
+ // (no bullet then — the head carries it).
386
+ const isTextRun = !(vdtType === 'resource' && resourceBlock) && !(vdtType === 'mathDisplay' && mathDisplayRender);
387
+ const isFirstReal = isFirstRealOf(k);
388
+ const isLastReal = isLastRealOf(k);
389
+ const lineFrom = isTextRun && isFirstReal ? Math.max(0, input.lineFrom ?? 0) : 0;
390
+ const lineTo = isTextRun && isLastReal && input.lineTo !== undefined
391
+ ? Math.max(lineFrom + 1, Math.min(input.lineTo, measured.lines.length))
392
+ : measured.lines.length;
393
+ const keptLines = isTextRun ? measured.lines.slice(Math.min(lineFrom, measured.lines.length - 1), lineTo) : measured.lines;
394
+ const openedMidRun = lineFrom > 0;
395
+ let height;
396
+ if (vdtType === 'resource' && resourceBlock) {
397
+ height = measured.totalHeight;
398
+ blk.resourceBlock = resourceBlock;
399
+ blk.lines = [{
400
+ text: '',
401
+ bbox: createBoundingBox(0, 0, resourceBlock.bodyRect.width, height),
402
+ baseline: 0,
403
+ hyphenated: false,
404
+ segments: [],
405
+ isLastLine: true,
406
+ }];
407
+ blk.sourceStart = raw.sourceStart + (input.bodyOffset ?? ctx.bodyOffset);
408
+ blk.sourceEnd = raw.sourceEnd + (input.bodyOffset ?? ctx.bodyOffset);
409
+ }
410
+ else if (vdtType === 'mathDisplay' && mathDisplayRender) {
411
+ blk.mathRender = mathDisplayRender;
412
+ blk.tex = raw.tex;
413
+ const mathLine = { ...measured.lines[0], bbox: { ...measured.lines[0].bbox, y: 0 } };
414
+ blk.lines = [mathLine];
415
+ height = mathLine.bbox.height;
416
+ blk.sourceStart = raw.sourceStart + ctx.bodyOffset;
417
+ blk.sourceEnd = raw.sourceEnd + ctx.bodyOffset;
418
+ }
419
+ else {
420
+ blk.lines = resetLinePositions(keptLines, bs.lineHeightPx);
421
+ height = blk.lines.length * bs.lineHeightPx;
422
+ blk.sourceStart = keptLines[0].sourceStart ?? raw.sourceStart + ctx.bodyOffset;
423
+ blk.sourceEnd = keptLines[keptLines.length - 1].sourceEnd ?? raw.sourceEnd + ctx.bodyOffset;
424
+ }
425
+ // Relocate to the inner rect (box-relative).
426
+ blk.bbox = createBoundingBox(x, st.cursorY, width, height);
427
+ for (const line of blk.lines) {
428
+ line.bbox.x += x;
429
+ line.bbox.y += st.cursorY;
430
+ line.baseline += st.cursorY;
431
+ }
432
+ if (blk.resourceBlock)
433
+ offsetResourceBlock(blk.resourceBlock, x, st.cursorY);
434
+ if (listBullet && !openedMidRun) {
435
+ blk.listDepth = listDepth;
436
+ blk.listKind = listKind;
437
+ blk.bulletText = listBullet.bulletText;
438
+ blk.bulletFontString = listBullet.bulletFontString;
439
+ blk.bulletColor = listBullet.bulletColor;
440
+ blk.bulletOffsetX = x + bulletXOffsetInColumn;
441
+ if (listBullet.separatorText !== undefined) {
442
+ blk.separatorText = listBullet.separatorText;
443
+ blk.separatorFontString = listBullet.separatorFontString;
444
+ blk.separatorColor = listBullet.separatorColor;
445
+ blk.separatorX = blk.bulletOffsetX + (listBullet.separatorOffsetPx ?? 0);
446
+ }
447
+ if (strikethroughText)
448
+ blk.strikethroughText = true;
449
+ const firstLine = blk.lines[0];
450
+ if (firstLine) {
451
+ blk.bulletY = firstLine.baseline - listBullet.textFontSizePx * 0.3 + listBullet.verticalOffsetPx;
452
+ }
453
+ }
454
+ else if (listBullet) {
455
+ blk.listDepth = listDepth;
456
+ blk.listKind = listKind;
457
+ }
458
+ into.push(blk);
459
+ st.cursorY += height;
460
+ st.prevMarginBottom = bs.marginBottomPx;
461
+ st.prevWasListItem = vdtType === 'listItem';
462
+ st.first = false;
463
+ return blk;
464
+ };
465
+ /** The tail of a text block cut at line `l`: a block of its own with the
466
+ * lines from `l` on (no bullet — the head keeps it), at y = 0. */
467
+ const tailOf = (blk, l) => {
468
+ const lh = blk.lines[0]?.bbox.height ?? 0;
469
+ const tail = { ...blk, id: input.nextChildId(), lines: resetLinePositions(blk.lines.slice(l), lh) };
470
+ delete tail.bulletText;
471
+ delete tail.bulletFontString;
472
+ delete tail.bulletColor;
473
+ delete tail.bulletOffsetX;
474
+ delete tail.bulletY;
475
+ delete tail.separatorText;
476
+ delete tail.separatorFontString;
477
+ delete tail.separatorColor;
478
+ delete tail.separatorX;
479
+ tail.bbox = createBoundingBox(blk.bbox.x, 0, blk.bbox.width, tail.lines.length * lh);
480
+ tail.sourceStart = tail.lines[0]?.sourceStart ?? blk.sourceStart;
481
+ return tail;
482
+ };
483
+ /** A `:::columns{count=N}` group: children `k0 … k1 - 1` in N columns. */
484
+ const placeColumnsGroup = (k0, k1, count, st, breaks) => {
485
+ const cols = Math.max(1, Math.min(6, count));
486
+ const colW = Math.max(1, (innerWidth - columnGapPx * (cols - 1)) / cols);
487
+ // Spacing above the group: as a block with no margin of its own.
488
+ const spacing = st.first ? st.prevMarginBottom : st.prevMarginBottom;
489
+ const groupTop = st.cursorY + spacing;
490
+ const gst = { cursorY: 0, prevMarginBottom: 0, prevWasListItem: false, first: true };
491
+ const stack = [];
492
+ for (let k = k0; k < k1; k++) {
331
493
  const raw = children[k];
332
- const blockIdx = childStartIdx + k;
333
494
  if (raw.type === 'directive' || isMarkerBlock(raw))
334
495
  continue;
335
- const measuredBlock = measureContentBlock(raw, blockIdx, innerWidth, derivedCtx, {
336
- styleOverride: input.paragraphStyleFor?.(blockIdx),
337
- });
338
- if (!measuredBlock)
339
- continue;
340
- const { kind, measured, prefixLen, absoluteSourceMap, mathDisplayRender, resourceBlock } = measuredBlock;
341
- const { style: bs, vdtType, headingLevel, numberPrefix, listBullet, listDepth, listKind, bulletXOffsetInColumn, strikethroughText } = kind;
342
- // Spacing above: margin collapsing, list-item spacing inside a run.
343
- let spacing;
344
- if (first) {
345
- spacing = prevMarginBottom;
346
- }
347
- else if (vdtType === 'listItem' && prevWasListItem) {
348
- spacing = listBullet ? listBullet.itemSpacingPx : 0;
349
- }
350
- else {
351
- spacing = Math.max(prevMarginBottom, bs.marginTopPx);
496
+ placeChild(raw, k, colW, innerX, gst, stack);
497
+ }
498
+ if (stack.length === 0)
499
+ return;
500
+ const total = gst.cursorY;
501
+ const candidates = [];
502
+ stack.forEach((blk, i) => {
503
+ if (i > 0)
504
+ candidates.push({ y: blk.bbox.y, block: i, line: 0 });
505
+ const cuttable = blk.type !== 'resource' && blk.type !== 'mathDisplay' && blk.lines.length > 1 && !blk.mathRender;
506
+ if (cuttable) {
507
+ for (let l = 1; l < blk.lines.length; l++)
508
+ candidates.push({ y: blk.lines[l].bbox.y, block: i, line: l });
352
509
  }
353
- cursorY += spacing;
354
- const blk = createVDTBlock(input.nextChildId(), vdtType, bs.fontString, bs.color, bs.textAlign);
355
- applyStyleAttrs(blk, bs);
356
- blk.contentIndex = blockIdx;
357
- blk.containerId = containerId;
358
- blk.dirty = false;
359
- blk.snappedToGrid = false;
360
- blk.headingLevel = headingLevel;
361
- if (numberPrefix)
362
- blk.numberPrefix = numberPrefix;
363
- blk.sourceMap = absoluteSourceMap;
364
- blk.plainPrefixLen = prefixLen;
365
- // Line-level cut of the first / last child (split boxes): the kept
366
- // run of lines, and whether this child opens after its first line
367
- // (no bullet then — the head carries it).
368
- const isTextRun = !(vdtType === 'resource' && resourceBlock) && !(vdtType === 'mathDisplay' && mathDisplayRender);
369
- const isFirstReal = childBlocks.length === 0;
370
- const isLastReal = children.slice(k + 1).every((c) => c.type === 'directive' || isMarkerBlock(c));
371
- const lineFrom = isTextRun && isFirstReal ? Math.max(0, input.lineFrom ?? 0) : 0;
372
- const lineTo = isTextRun && isLastReal && input.lineTo !== undefined
373
- ? Math.max(lineFrom + 1, Math.min(input.lineTo, measured.lines.length))
374
- : measured.lines.length;
375
- const keptLines = isTextRun ? measured.lines.slice(Math.min(lineFrom, measured.lines.length - 1), lineTo) : measured.lines;
376
- const openedMidRun = lineFrom > 0;
377
- let height;
378
- if (vdtType === 'resource' && resourceBlock) {
379
- height = measured.totalHeight;
380
- blk.resourceBlock = resourceBlock;
381
- blk.lines = [{
382
- text: '',
383
- bbox: createBoundingBox(0, 0, resourceBlock.bodyRect.width, height),
384
- baseline: 0,
385
- hyphenated: false,
386
- segments: [],
387
- isLastLine: true,
388
- }];
389
- blk.sourceStart = raw.sourceStart + (input.bodyOffset ?? ctx.bodyOffset);
390
- blk.sourceEnd = raw.sourceEnd + (input.bodyOffset ?? ctx.bodyOffset);
510
+ });
511
+ const cuts = [];
512
+ let prevY = 0;
513
+ if (breaks && breaks.length > 0) {
514
+ // Explicit column starts (`breaks="3"`: the third child opens the
515
+ // second column): block-level cuts, no balancing.
516
+ for (const b of breaks.slice(0, cols - 1)) {
517
+ const i = b - 1;
518
+ if (i <= 0 || i >= stack.length)
519
+ continue;
520
+ const y = stack[i].bbox.y;
521
+ if (y <= prevY + 0.01)
522
+ continue;
523
+ cuts.push({ y, block: i, line: 0 });
524
+ prevY = y;
391
525
  }
392
- else if (vdtType === 'mathDisplay' && mathDisplayRender) {
393
- blk.mathRender = mathDisplayRender;
394
- blk.tex = raw.tex;
395
- const mathLine = { ...measured.lines[0], bbox: { ...measured.lines[0].bbox, y: 0 } };
396
- blk.lines = [mathLine];
397
- height = mathLine.bbox.height;
398
- blk.sourceStart = raw.sourceStart + ctx.bodyOffset;
399
- blk.sourceEnd = raw.sourceEnd + ctx.bodyOffset;
526
+ }
527
+ else {
528
+ for (let c = 1; c < cols; c++) {
529
+ const target = (total * c) / cols;
530
+ let best;
531
+ for (const cand of candidates) {
532
+ if (cand.y <= prevY + 0.01)
533
+ continue;
534
+ if (!best || Math.abs(cand.y - target) < Math.abs(best.y - target))
535
+ best = cand;
536
+ }
537
+ if (!best)
538
+ break;
539
+ cuts.push(best);
540
+ prevY = best.y;
400
541
  }
401
- else {
402
- blk.lines = resetLinePositions(keptLines, bs.lineHeightPx);
403
- height = blk.lines.length * bs.lineHeightPx;
404
- blk.sourceStart = keptLines[0].sourceStart ?? raw.sourceStart + ctx.bodyOffset;
405
- blk.sourceEnd = keptLines[keptLines.length - 1].sourceEnd ?? raw.sourceEnd + ctx.bodyOffset;
542
+ }
543
+ // Split the stack at the cuts into columns of blocks; a cut inside a
544
+ // text block leaves its head behind and opens the next column with the
545
+ // tail (a block of its own, bullet-less).
546
+ const columns = [[]];
547
+ const columnStarts = [0];
548
+ let cutIdx = 0;
549
+ for (let i = 0; i < stack.length; i++) {
550
+ let blk = stack[i];
551
+ while (cutIdx < cuts.length && cuts[cutIdx].block === i && cuts[cutIdx].line === 0) {
552
+ columns.push([]);
553
+ columnStarts.push(cuts[cutIdx].y);
554
+ cutIdx++;
406
555
  }
407
- // Relocate to the inner rect (box-relative).
408
- blk.bbox = createBoundingBox(innerX, cursorY, innerWidth, height);
409
- for (const line of blk.lines) {
410
- line.bbox.x += innerX;
411
- line.bbox.y += cursorY;
412
- line.baseline += cursorY;
556
+ let consumed = 0;
557
+ while (cutIdx < cuts.length && cuts[cutIdx].block === i && cuts[cutIdx].line > 0) {
558
+ const cut = cuts[cutIdx];
559
+ const l = cut.line - consumed;
560
+ cutIdx++;
561
+ if (l <= 0 || l >= blk.lines.length)
562
+ continue;
563
+ const lh = blk.lines[0]?.bbox.height ?? 0;
564
+ const tail = tailOf(blk, l);
565
+ blk.lines = blk.lines.slice(0, l);
566
+ blk.bbox = createBoundingBox(blk.bbox.x, blk.bbox.y, blk.bbox.width, l * lh);
567
+ columns[columns.length - 1].push(blk);
568
+ offsetBlock(tail, 0, cut.y);
569
+ columns.push([]);
570
+ columnStarts.push(cut.y);
571
+ consumed += l;
572
+ blk = tail;
413
573
  }
414
- if (blk.resourceBlock)
415
- offsetResourceBlock(blk.resourceBlock, innerX, cursorY);
416
- if (listBullet && !openedMidRun) {
417
- blk.listDepth = listDepth;
418
- blk.listKind = listKind;
419
- blk.bulletText = listBullet.bulletText;
420
- blk.bulletFontString = listBullet.bulletFontString;
421
- blk.bulletColor = listBullet.bulletColor;
422
- blk.bulletOffsetX = innerX + bulletXOffsetInColumn;
423
- if (listBullet.separatorText !== undefined) {
424
- blk.separatorText = listBullet.separatorText;
425
- blk.separatorFontString = listBullet.separatorFontString;
426
- blk.separatorColor = listBullet.separatorColor;
427
- blk.separatorX = blk.bulletOffsetX + (listBullet.separatorOffsetPx ?? 0);
428
- }
429
- if (strikethroughText)
430
- blk.strikethroughText = true;
431
- const firstLine = blk.lines[0];
432
- if (firstLine) {
433
- blk.bulletY = firstLine.baseline - listBullet.textFontSizePx * 0.3 + listBullet.verticalOffsetPx;
434
- }
574
+ columns[columns.length - 1].push(blk);
575
+ }
576
+ let groupHeight = 0;
577
+ columns.forEach((blocks, c) => {
578
+ const shiftX = c * (colW + columnGapPx);
579
+ const shiftY = groupTop - (columnStarts[c] ?? 0);
580
+ for (const blk of blocks) {
581
+ offsetBlock(blk, shiftX, shiftY);
582
+ childBlocks.push(blk);
583
+ groupHeight = Math.max(groupHeight, blk.bbox.y + blk.bbox.height - groupTop);
435
584
  }
436
- else if (listBullet) {
437
- blk.listDepth = listDepth;
438
- blk.listKind = listKind;
585
+ });
586
+ st.cursorY = groupTop + groupHeight;
587
+ st.prevMarginBottom = 0;
588
+ st.prevWasListItem = false;
589
+ st.first = false;
590
+ };
591
+ const st = { cursorY, prevMarginBottom: hasTitle ? gapPx : 0, prevWasListItem: false, first: true };
592
+ if (!isAuto) {
593
+ for (let k = 0; k < children.length; k++) {
594
+ const raw = children[k];
595
+ if (raw.type === 'containerStart' && raw.containerName === 'columns') {
596
+ let e = k + 1;
597
+ while (e < children.length && !(children[e].type === 'containerEnd' && children[e].containerName === 'columns' && children[e].containerId === raw.containerId))
598
+ e++;
599
+ const count = Number.parseInt(raw.containerAttrs?.count ?? '2', 10);
600
+ const breaks = (raw.containerAttrs?.breaks ?? '').split(',').map((v) => Number.parseInt(v.trim(), 10)).filter((v) => Number.isFinite(v) && v > 1);
601
+ placeColumnsGroup(k + 1, e, Number.isFinite(count) ? count : 2, st, breaks.length > 0 ? breaks : undefined);
602
+ k = e;
603
+ continue;
439
604
  }
440
- childBlocks.push(blk);
441
- cursorY += height;
442
- prevMarginBottom = bs.marginBottomPx;
443
- prevWasListItem = vdtType === 'listItem';
444
- first = false;
605
+ if (raw.type === 'directive' || isMarkerBlock(raw))
606
+ continue;
607
+ placeChild(raw, k, innerWidth, innerX, st, childBlocks);
445
608
  }
446
609
  }
610
+ cursorY = st.cursorY;
447
611
  // An icon taller than the content grows the inner area to fit it; with
448
612
  // `align: 'center'` the title and children are centred on the icon.
449
613
  let contentBottom = cursorY;
450
614
  const contentH = contentBottom - innerTop;
451
- if (hasIcon && iconSize > contentH) {
615
+ if (hasIcon && !cornerIcon && iconSize > contentH) {
452
616
  const extra = iconSize - contentH;
453
617
  if (style.icon.align === 'center') {
454
618
  if (titleBlock)
@@ -491,13 +655,22 @@ export function layoutCallout(input) {
491
655
  if (hasIcon) {
492
656
  // Over a side stripe the icon is centred on the stripe; otherwise it
493
657
  // sits in its own column left of the content.
494
- const iconX = sideStripe
495
- ? (stripeLeft ? (stripeW - iconSize) / 2 : boxWidth - stripeW + (stripeW - iconSize) / 2)
496
- : innerX - iconColumn;
497
- const iconY = style.icon.align === 'center'
498
- ? innerTop + (contentBottom - innerTop - iconSize) / 2
499
- : innerTop;
500
- const built = buildIconBlock(style.icon, iconX, iconY, iconSize, ctx);
658
+ // A corner badge hangs on the top-right corner, half of it past the
659
+ // border, flush with the top edge.
660
+ const cornerRight = style.icon.cornerSide === 'right'
661
+ || (style.icon.cornerSide === 'outer' && !input.mirrored)
662
+ || (style.icon.cornerSide === 'inner' && !!input.mirrored);
663
+ const iconX = cornerIcon
664
+ ? (cornerRight ? boxWidth - iconSize / 2 : -iconSize / 2)
665
+ : sideStripe
666
+ ? (stripeLeft ? (stripeW - iconSize) / 2 : boxWidth - stripeW + (stripeW - iconSize) / 2)
667
+ : innerX - iconColumn;
668
+ const iconY = cornerIcon
669
+ ? 0
670
+ : style.icon.align === 'center'
671
+ ? innerTop + (contentBottom - innerTop - iconSize) / 2
672
+ : innerTop;
673
+ const built = buildIconBlock(style.icon, iconX, iconY, iconSize, ctx, iconBoxW);
501
674
  if (built) {
502
675
  overlayBlocks.push(built.block);
503
676
  iconFileId = built.fileId;
@@ -506,6 +679,72 @@ export function layoutCallout(input) {
506
679
  }
507
680
  if (titleBlock)
508
681
  overlayBlocks.push(titleBlock);
682
+ // --- Label tab ---------------------------------------------------------------
683
+ // The fence's `label` on a tab hugging a top corner, rising `offset`
684
+ // above the box; an icon beside it and a rule along the top edge.
685
+ let labelRisePx = 0;
686
+ if (style.label && !input.continuation && labelText.trim().length > 0) {
687
+ const lb = style.label;
688
+ const lbFontPx = dimensionToPx(lb.fontSize, dpi, em);
689
+ const lbFont = buildFontString(lb.fontFamily, lbFontPx, lb.fontWeight.toString());
690
+ const padX = dimensionToPx(lb.paddingX, dpi, lbFontPx);
691
+ const tabH = dimensionToPx(lb.height, dpi, lbFontPx);
692
+ const rise = px(lb.offset);
693
+ const inset = px(lb.inset);
694
+ const textW = measureTextWidth(labelText, lbFont);
695
+ const tabW = textW + 2 * padX;
696
+ const onRight = lb.position === 'top-right';
697
+ const tabX = onRight ? boxWidth - inset - tabW : inset;
698
+ const tabY = -rise;
699
+ labelRisePx = Math.max(0, rise);
700
+ overlayBlocks.push({
701
+ kind: 'box',
702
+ bbox: createBoundingBox(tabX, tabY, tabW, tabH),
703
+ box: { backgroundColor: lb.background.hex, borderWidthPx: 0, borderRadiusPx: 0 },
704
+ });
705
+ overlayBlocks.push({
706
+ kind: 'text',
707
+ bbox: createBoundingBox(tabX, tabY, tabW, tabH),
708
+ fontString: lbFont,
709
+ color: lb.color.hex,
710
+ lines: [{ text: labelText, xOffset: padX, baselineY: tabY + tabH / 2 + lbFontPx * 0.36, width: textW }],
711
+ clip: false,
712
+ });
713
+ let edgeX = onRight ? tabX : tabX + tabW;
714
+ if (lb.icon.resourceId) {
715
+ const iconW = px(lb.icon.width);
716
+ const gap = px(lb.icon.gap);
717
+ const iconX = onRight ? tabX - gap - iconW : tabX + tabW + gap;
718
+ const resource = ctx.resourceById.get(lb.icon.resourceId);
719
+ const fileId = resource?.bitmap?.fileId ?? resource?.svg?.fileId;
720
+ if (fileId) {
721
+ const dims = resource?.bitmap ?? resource?.svg;
722
+ let w = iconW;
723
+ let h = tabH;
724
+ if (dims?.width && dims?.height) {
725
+ const k = Math.min(iconW / dims.width, tabH / dims.height);
726
+ w = dims.width * k;
727
+ h = dims.height * k;
728
+ }
729
+ overlayBlocks.push({ kind: 'image', bbox: createBoundingBox(onRight ? iconX + iconW - w : iconX, tabY + (tabH - h) / 2, w, h), fileId });
730
+ edgeX = onRight ? iconX + iconW - w : iconX + w;
731
+ }
732
+ }
733
+ if (lb.rule.enabled) {
734
+ const ruleW = px(lb.rule.width);
735
+ const x0 = onRight ? 0 : edgeX;
736
+ const len = onRight ? edgeX : boxWidth - edgeX;
737
+ if (len > 0) {
738
+ overlayBlocks.push({
739
+ kind: 'rule',
740
+ bbox: createBoundingBox(x0, -ruleW / 2, len, ruleW),
741
+ color: lb.rule.color.hex,
742
+ thicknessPx: ruleW,
743
+ direction: 'horizontal',
744
+ });
745
+ }
746
+ }
747
+ }
509
748
  // --- Marker column + frame -----------------------------------------------------
510
749
  // The frame is as tall as the tallest of box, marker and rule; the three
511
750
  // are centred on each other (`marker.align: 'center'`) or top-aligned.
@@ -546,13 +785,26 @@ export function layoutCallout(input) {
546
785
  });
547
786
  }
548
787
  }
788
+ // The label tab rises above the box inside the block: the box starts
789
+ // `labelRisePx` down, so the tab keeps its room at a column head too,
790
+ // where the top margin is dropped.
791
+ if (labelRisePx > 0) {
792
+ for (const b of overlayBlocks)
793
+ offsetDesignBlock(b, 0, labelRisePx);
794
+ for (const b of markerBlocks)
795
+ offsetDesignBlock(b, 0, labelRisePx);
796
+ for (const blk of childBlocks)
797
+ offsetBlock(blk, 0, labelRisePx);
798
+ innerRect.y += labelRisePx;
799
+ }
800
+ const frameHeight = totalHeight + labelRisePx;
549
801
  const overlay = {
550
- bbox: createBoundingBox(0, 0, width, totalHeight),
802
+ bbox: createBoundingBox(0, 0, width, frameHeight),
551
803
  blocks: [...markerBlocks, ...overlayBlocks],
552
804
  };
553
805
  // --- Frame block -------------------------------------------------------------
554
806
  const frame = createVDTBlock(frameId, 'callout', bodyStyle.fontString, bodyStyle.color, bodyStyle.textAlign);
555
- frame.bbox = createBoundingBox(0, 0, width, totalHeight);
807
+ frame.bbox = createBoundingBox(0, 0, width, frameHeight);
556
808
  frame.lines = [];
557
809
  frame.dirty = false;
558
810
  frame.snappedToGrid = false;
@@ -573,7 +825,7 @@ export function layoutCallout(input) {
573
825
  frame,
574
826
  children: childBlocks,
575
827
  width,
576
- totalHeight,
828
+ totalHeight: frameHeight,
577
829
  marginTopPx: px(style.marginTop),
578
830
  marginBottomPx: px(style.marginBottom),
579
831
  };
@@ -599,15 +851,22 @@ function fitInSquare(x, y, size, w, h) {
599
851
  /** The design block for an icon spec drawn in the square `(x, y, size)`:
600
852
  * an image block for a resource (aspect-fitted; `undefined` when the
601
853
  * resource is missing), a centred text block for a glyph. */
602
- function buildIconBlock(spec, x, y, size, ctx) {
854
+ function buildIconBlock(spec, x, y, size, ctx, boxWidth = size) {
603
855
  if (spec.kind === 'resource') {
604
856
  const resource = ctx.resourceById.get(spec.resourceId);
605
857
  const fileId = resource?.bitmap?.fileId ?? resource?.svg?.fileId;
606
858
  if (!fileId)
607
859
  return undefined;
608
860
  const dims = resource?.bitmap ?? resource?.svg;
861
+ let bbox = fitInSquare(x, y, size, dims?.width, dims?.height);
862
+ if (boxWidth > size && dims?.width && dims?.height) {
863
+ const k = Math.min(boxWidth / dims.width, size / dims.height);
864
+ const fw = dims.width * k;
865
+ const fh = dims.height * k;
866
+ bbox = createBoundingBox(x, y + (size - fh) / 2, fw, fh);
867
+ }
609
868
  return {
610
- block: { kind: 'image', bbox: fitInSquare(x, y, size, dims?.width, dims?.height), fileId },
869
+ block: { kind: 'image', bbox, fileId },
611
870
  fileId,
612
871
  format: resource?.bitmap?.format,
613
872
  };