postext 0.3.43 → 0.3.45

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