@stll/folio-core 0.37.1 → 0.37.3

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 (63) hide show
  1. package/dist/ai-edits/headless.d.ts +24 -1
  2. package/dist/ai-edits/headless.js +108 -71
  3. package/dist/ai-edits/read.d.ts +9 -1
  4. package/dist/ai-edits/read.js +34 -16
  5. package/dist/ai-edits/snapshot.d.ts +7 -5
  6. package/dist/ai-edits/snapshot.js +39 -12
  7. package/dist/ai-edits/table-cell-mutations.js +10 -5
  8. package/dist/ai-edits/table-template.js +19 -5
  9. package/dist/compare/compare.d.ts +2 -3
  10. package/dist/compare/compare.js +41 -64
  11. package/dist/compare/content-alignment.js +0 -1
  12. package/dist/compare/scenario.d.ts +11 -2
  13. package/dist/compare/scenario.js +6 -2
  14. package/dist/controller/hiddenEditorManager.js +11 -0
  15. package/dist/controller/layoutPipeline.js +1 -1
  16. package/dist/display-list/build/textBoxPrimitives.js +34 -1
  17. package/dist/display-list/dom/renderDisplayListToDom.js +5 -2
  18. package/dist/display-list/primitives.d.ts +1 -1
  19. package/dist/display-list/types.d.ts +8 -4
  20. package/dist/docx/paragraphPropertySource.d.ts +82 -2
  21. package/dist/docx/paragraphPropertySource.js +569 -3
  22. package/dist/docx/parser.js +9 -0
  23. package/dist/docx/server/materializeYjsDocx.d.ts +1 -1
  24. package/dist/docx/server/materializeYjsDocx.js +21 -2
  25. package/dist/headless-layout.js +1 -1
  26. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +7 -1
  27. package/dist/layout-bridge/convert/headerFooterLayout.js +20 -3
  28. package/dist/layout-bridge/convert/toFlowBlocks.js +43 -12
  29. package/dist/layout-engine/index.js +20 -10
  30. package/dist/layout-engine/measure/measureBlocks.d.ts +7 -3
  31. package/dist/layout-engine/measure/measureBlocks.js +10 -7
  32. package/dist/layout-engine/measure/measureParagraph.d.ts +2 -0
  33. package/dist/layout-engine/measure/measureParagraph.js +1 -1
  34. package/dist/layout-engine/paginator.d.ts +1 -1
  35. package/dist/layout-engine/paginator.js +41 -4
  36. package/dist/layout-engine/textBoxFlow.d.ts +2 -0
  37. package/dist/layout-engine/textBoxFlow.js +9 -5
  38. package/dist/layout-engine/types.d.ts +6 -0
  39. package/dist/layout-painter/documentColors.d.ts +10 -1
  40. package/dist/layout-painter/documentColors.js +16 -1
  41. package/dist/layout-painter/renderParagraph.js +9 -24
  42. package/dist/layout-painter/renderTable.js +3 -3
  43. package/dist/layout-painter/renderTextBox.js +2 -1
  44. package/dist/pdf/pageSpace.d.ts +12 -1
  45. package/dist/pdf/pageSpace.js +24 -1
  46. package/dist/pdf/paint.js +2 -2
  47. package/dist/prosemirror/attrs/index.js +19 -8
  48. package/dist/prosemirror/commands/comments.js +12 -3
  49. package/dist/prosemirror/commands/tableCellMergeResolution.js +18 -11
  50. package/dist/prosemirror/conversion/fromProseDoc.js +94 -21
  51. package/dist/prosemirror/conversion/toProseDoc.js +100 -8
  52. package/dist/prosemirror/extensions/core/DocExtension.js +2 -0
  53. package/dist/prosemirror/extensions/core/ParagraphExtension.js +2 -0
  54. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts +5 -1
  55. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +132 -41
  56. package/dist/prosemirror/extensions/nodes/TextBoxExtension.d.ts +2 -0
  57. package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +7 -0
  58. package/dist/prosemirror/schema/nodes.d.ts +3 -1
  59. package/dist/prosemirror/yjsParagraphSourceContract.d.ts +9 -0
  60. package/dist/prosemirror/yjsParagraphSourceContract.js +26 -0
  61. package/dist/utils/rotationBoundingBox.d.ts +5 -1
  62. package/dist/utils/rotationBoundingBox.js +9 -1
  63. package/package.json +2 -2
@@ -4,7 +4,7 @@ declare const FOLIO_YJS_PROSEMIRROR_FRAGMENT_NAME = "prosemirror";
4
4
  /** Maximum accepted size of a complete Yjs collaboration state update. */
5
5
  declare const FOLIO_YJS_UPDATE_MAX_BYTES: number;
6
6
  /** Stable failure codes returned by server-side Yjs-to-DOCX materialization. */
7
- declare const FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES: readonly ["empty_update", "invalid_update", "missing_document", "update_too_large"];
7
+ declare const FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES: readonly ["empty_update", "invalid_update", "missing_document", "source_mismatch", "update_too_large"];
8
8
  /** Failure code for a rejected Yjs-to-DOCX materialization request. */
9
9
  type FolioYjsDocxMaterializationErrorCode = (typeof FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES)[number];
10
10
  declare const FolioYjsDocxMaterializationError_base: import("better-result").TaggedErrorClass<"FolioYjsDocxMaterializationError">;
@@ -1,5 +1,7 @@
1
1
  import { fromProseDoc } from "../../prosemirror/conversion/fromProseDoc.js";
2
2
  import { schema } from "../../prosemirror/schema/index.js";
3
+ import { readYjsParagraphSourceContract, withParagraphSourceContract } from "../../prosemirror/yjsParagraphSourceContract.js";
4
+ import { ParagraphPropertySourceValidationError } from "../paragraphPropertySource.js";
3
5
  import { parseDocx } from "../parser.js";
4
6
  import { repackDocx } from "../rezip.js";
5
7
  import { Result, TaggedError } from "better-result";
@@ -15,6 +17,7 @@ const FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES = [
15
17
  "empty_update",
16
18
  "invalid_update",
17
19
  "missing_document",
20
+ "source_mismatch",
18
21
  "update_too_large"
19
22
  ];
20
23
  /** Typed failure raised when a collaboration snapshot cannot be materialized. */
@@ -37,7 +40,12 @@ const readProseMirrorDocument = (yjsUpdate) => {
37
40
  code: "missing_document",
38
41
  message: "Yjs update does not contain a Folio document."
39
42
  });
40
- return initProseMirrorDoc(fragment, schema).doc;
43
+ const contract = readYjsParagraphSourceContract(ydoc);
44
+ if (!contract) throw new FolioYjsDocxMaterializationError({
45
+ code: "source_mismatch",
46
+ message: "Yjs update does not identify its paragraph-property source document."
47
+ });
48
+ return withParagraphSourceContract(initProseMirrorDoc(fragment, schema).doc, contract);
41
49
  },
42
50
  catch: (cause) => cause instanceof FolioYjsDocxMaterializationError ? cause : new FolioYjsDocxMaterializationError({
43
51
  code: "invalid_update",
@@ -55,7 +63,18 @@ const readProseMirrorDocument = (yjsUpdate) => {
55
63
  * of the browser editor's full save path for the main document story.
56
64
  */
57
65
  const materializeYjsDocx = async ({ sourceDocx, yjsUpdate }) => {
58
- return await repackDocx(fromProseDoc(readProseMirrorDocument(yjsUpdate), await parseDocx(sourceDocx, { preloadFonts: false })));
66
+ const proseMirrorDocument = readProseMirrorDocument(yjsUpdate);
67
+ const baseDocument = await parseDocx(sourceDocx, { preloadFonts: false });
68
+ const converted = Result.try({
69
+ try: () => fromProseDoc(proseMirrorDocument, baseDocument),
70
+ catch: (cause) => cause instanceof ParagraphPropertySourceValidationError ? new FolioYjsDocxMaterializationError({
71
+ code: "source_mismatch",
72
+ message: "The collaboration snapshot belongs to a different source document.",
73
+ cause
74
+ }) : cause
75
+ });
76
+ if (converted.isErr()) throw converted.error;
77
+ return await repackDocx(converted.value);
59
78
  };
60
79
  //#endregion
61
80
  export { FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES, FOLIO_YJS_PROSEMIRROR_FRAGMENT_NAME, FOLIO_YJS_UPDATE_MAX_BYTES, FolioYjsDocxMaterializationError, materializeYjsDocx };
@@ -132,7 +132,7 @@ const convertStories = ({ parts, contentWidth, metrics, storyOptions, pageCount,
132
132
  for (const [rId, part] of parts ?? []) {
133
133
  const content = convertHeaderFooterToContent(part, contentWidth, metrics, {
134
134
  ...storyOptions,
135
- measureBlocks: (blocks, width) => measureBlocks(blocks, width, void 0, void 0, buildHeaderFooterFieldValues(blocks, pageCount, now)),
135
+ measureBlocks: (blocks, width) => measureBlocks(blocks, width, void 0, void 0, buildHeaderFooterFieldValues(blocks, pageCount, now), { allowEndTabOverflow: true }),
136
136
  rId
137
137
  });
138
138
  if (content) contentByRId.set(rId, content);
@@ -13,6 +13,12 @@ type HeaderFooterMetrics = {
13
13
  margins: PageMargins;
14
14
  };
15
15
  declare function normalizeHeaderFooterMeasureBlocks(blocks: FlowBlock[], section?: HeaderFooterMetrics["section"]): FlowBlock[];
16
+ /**
17
+ * Header/footer auto-fit tables use `w:tblGrid` as a provisional ratio, not a
18
+ * license to paint beyond the page furniture frame. Scale an oversized grid
19
+ * for measurement while leaving the authored block untouched for round trips.
20
+ */
21
+ declare function fitHeaderFooterTablesToContentWidth(blocks: FlowBlock[], contentWidth: number): FlowBlock[];
16
22
  declare function resolveHeaderFooterPositionedVisualTop(position: ImageRunPosition | undefined, elementHeight: number, sourceY: number, flowHeight: number, metrics: HeaderFooterMetrics): number;
17
23
  declare function resolveHeaderFooterVisualTop(run: ImageRun, paragraphY: number, flowHeight: number, metrics: HeaderFooterMetrics): number;
18
24
  declare function calculateHeaderFooterVisualBounds(blocks: FlowBlock[], measures: Measure[], flowHeight: number, metrics: HeaderFooterMetrics): {
@@ -96,4 +102,4 @@ type ReserveHeaderFooterFullWidthWrapBandsOptions = {
96
102
  };
97
103
  declare function reserveHeaderFooterFullWidthWrapBands({ blocks, measures, contentWidth, metrics }: ReserveHeaderFooterFullWidthWrapBandsOptions): Measure[];
98
104
  //#endregion
99
- export { ConvertHeaderFooterOptions, HeaderFooterMetrics, calculateHeaderFooterBodyTopClearance, calculateHeaderFooterMarginPushBounds, calculateHeaderFooterVisualBounds, convertHeaderFooterPmDocToContent, convertHeaderFooterToContent, normalizeHeaderFooterMeasureBlocks, reserveHeaderFooterFullWidthWrapBands, resolveHeaderFooterPositionedVisualTop, resolveHeaderFooterVisualTop };
105
+ export { ConvertHeaderFooterOptions, HeaderFooterMetrics, calculateHeaderFooterBodyTopClearance, calculateHeaderFooterMarginPushBounds, calculateHeaderFooterVisualBounds, convertHeaderFooterPmDocToContent, convertHeaderFooterToContent, fitHeaderFooterTablesToContentWidth, normalizeHeaderFooterMeasureBlocks, reserveHeaderFooterFullWidthWrapBands, resolveHeaderFooterPositionedVisualTop, resolveHeaderFooterVisualTop };
@@ -1,5 +1,5 @@
1
1
  import { cloneParagraphWithPropertySource } from "../../docx/paragraphPropertySource.js";
2
- import { isFloatingImageRun, isFloatingTextBoxBlock, isTextWrappingFloatingImageRun } from "../../layout-engine/types.js";
2
+ import { isFloatingImageRun, isFloatingTextBoxBlock, isTextWrappingFloatingImageRun, tableColumnsArePinned } from "../../layout-engine/types.js";
3
3
  import { headerFooterToProseDoc } from "../../prosemirror/conversion/toProseDoc.js";
4
4
  import { emuToPixels } from "../../utils/units.js";
5
5
  import { toFlowBlocks } from "./toFlowBlocks.js";
@@ -33,6 +33,23 @@ function preservesInheritedSpacing(block) {
33
33
  function normalizeHeaderFooterMeasureBlocks(blocks, section = "header") {
34
34
  return normalizeFlowBlockArray(blocks, { suppressTrailingEmptyAfterTable: section === "header" });
35
35
  }
36
+ /**
37
+ * Header/footer auto-fit tables use `w:tblGrid` as a provisional ratio, not a
38
+ * license to paint beyond the page furniture frame. Scale an oversized grid
39
+ * for measurement while leaving the authored block untouched for round trips.
40
+ */
41
+ function fitHeaderFooterTablesToContentWidth(blocks, contentWidth) {
42
+ return blocks.map((block) => {
43
+ if (block.kind !== "table" || block.floating !== void 0 || tableColumnsArePinned(block) || block.columnWidths === void 0) return block;
44
+ const totalWidth = block.columnWidths.reduce((sum, width) => sum + width, 0);
45
+ if (totalWidth <= contentWidth || totalWidth <= 0) return block;
46
+ const scale = contentWidth / totalWidth;
47
+ return {
48
+ ...block,
49
+ columnWidths: block.columnWidths.map((width) => width * scale)
50
+ };
51
+ });
52
+ }
36
53
  function normalizeFlowBlockArray(blocks, normalization) {
37
54
  const trailingEmptyAfterTable = /* @__PURE__ */ new Set();
38
55
  const lastIndex = blocks.length - 1;
@@ -414,7 +431,7 @@ function reserveHeaderFooterFullWidthWrapBands({ blocks, measures, contentWidth,
414
431
  }
415
432
  function finalizeHeaderFooterContent(blocks, contentWidth, metrics, options) {
416
433
  if (blocks.length === 0) return;
417
- const blocksForMeasure = normalizeHeaderFooterMeasureBlocks(blocks, metrics.section);
434
+ const blocksForMeasure = fitHeaderFooterTablesToContentWidth(normalizeHeaderFooterMeasureBlocks(blocks, metrics.section), contentWidth);
418
435
  const measures = reserveHeaderFooterFullWidthWrapBands({
419
436
  blocks,
420
437
  measures: options.measureBlocks(blocksForMeasure, contentWidth),
@@ -575,4 +592,4 @@ function serializeRunFmt(run) {
575
592
  return JSON.stringify(out);
576
593
  }
577
594
  //#endregion
578
- export { calculateHeaderFooterBodyTopClearance, calculateHeaderFooterMarginPushBounds, calculateHeaderFooterVisualBounds, convertHeaderFooterPmDocToContent, convertHeaderFooterToContent, normalizeHeaderFooterMeasureBlocks, reserveHeaderFooterFullWidthWrapBands, resolveHeaderFooterPositionedVisualTop, resolveHeaderFooterVisualTop };
595
+ export { calculateHeaderFooterBodyTopClearance, calculateHeaderFooterMarginPushBounds, calculateHeaderFooterVisualBounds, convertHeaderFooterPmDocToContent, convertHeaderFooterToContent, fitHeaderFooterTablesToContentWidth, normalizeHeaderFooterMeasureBlocks, reserveHeaderFooterFullWidthWrapBands, resolveHeaderFooterPositionedVisualTop, resolveHeaderFooterVisualTop };
@@ -1162,6 +1162,25 @@ function paragraphFragmentAttrs(source, index, count) {
1162
1162
  }
1163
1163
  return attrs;
1164
1164
  }
1165
+ const runIsZeroWidthBoundaryMarker = (run) => {
1166
+ switch (run.kind) {
1167
+ case "text": return run.text.length === 0;
1168
+ case "field": return (run.fallback ?? "").length === 0;
1169
+ case "renderedPageBreak": return true;
1170
+ case "image":
1171
+ case "lineBreak":
1172
+ case "math":
1173
+ case "tab": return false;
1174
+ default: return run;
1175
+ }
1176
+ };
1177
+ /** Decide leading-break eligibility from the exact projected runs consumed by layout. */
1178
+ const hasSingleLeadingProjectedPageBreak = (runs, pageBreaks) => {
1179
+ if (pageBreaks.length !== 1) return false;
1180
+ const partitioned = partitionRunsAtPageBreaks(runs, pageBreaks);
1181
+ if (partitioned.type === "overlap") return false;
1182
+ return partitioned.partitions[0]?.before.every(runIsZeroWidthBoundaryMarker) === true;
1183
+ };
1165
1184
  function splitParagraphAtPageBreaks({ pageBreaks, paragraph, splitPageBreakAndParagraphMark }) {
1166
1185
  if (pageBreaks.length === 0) return [paragraph];
1167
1186
  const result = [];
@@ -1324,17 +1343,16 @@ function extractCellBorders(borders, theme) {
1324
1343
  }
1325
1344
  return Object.keys(result).length > 0 ? result : void 0;
1326
1345
  }
1327
- /**
1328
- * Convert a table cell node.
1329
- */
1330
1346
  function convertTableCell(node, startPos, options, tableCellMargins) {
1331
- const pageBreakPosition = options.firstPageBreakRunPosition(node);
1332
- if (pageBreakPosition !== void 0) panic(`An explicit page-break run at ${String(pageBreakPosition)} cannot be projected inside a table cell`);
1333
1347
  const blocks = [];
1334
1348
  let offset = startPos + 1;
1349
+ const authoredPageBreakPosition = options.firstPageBreakRunPosition(node);
1350
+ const singleParagraph = node.childCount === 1 && node.firstChild?.type.name === "paragraph" ? node.firstChild : void 0;
1351
+ if (authoredPageBreakPosition !== void 0 && singleParagraph === void 0) panic(`An explicit page-break run at ${String(authoredPageBreakPosition)} cannot be projected inside a table cell`);
1352
+ const pageBreaks = [];
1335
1353
  node.forEach((child) => {
1336
1354
  if (child.type.name === "paragraph") {
1337
- const block = convertParagraph(child, offset, options);
1355
+ const block = convertParagraph(child, offset, options, child === singleParagraph ? pageBreaks : void 0);
1338
1356
  blocks.push(block);
1339
1357
  } else if (child.type.name === "table") blocks.push(convertTable(child, offset, options));
1340
1358
  else if (child.type.name === "textBox") blocks.push(convertTextBoxNode(child, offset, options));
@@ -1376,7 +1394,13 @@ function convertTableCell(node, startPos, options, tableCellMargins) {
1376
1394
  const cellBorders = extractCellBorders(attrs.borders, options.theme);
1377
1395
  if (cellBorders) cell.borders = cellBorders;
1378
1396
  if (attrs.noWrap) cell.noWrap = true;
1379
- return cell;
1397
+ if (authoredPageBreakPosition === void 0 || pageBreaks.length === 0) return { cell };
1398
+ const paragraph = blocks.at(0);
1399
+ if (paragraph?.kind !== "paragraph" || !hasSingleLeadingProjectedPageBreak(paragraph.runs, pageBreaks)) panic(`An explicit page-break run at ${String(authoredPageBreakPosition)} cannot be projected inside a table cell`);
1400
+ return {
1401
+ cell,
1402
+ breakBefore: "page"
1403
+ };
1380
1404
  }
1381
1405
  /**
1382
1406
  * Convert a table row node.
@@ -1384,8 +1408,13 @@ function convertTableCell(node, startPos, options, tableCellMargins) {
1384
1408
  function convertTableRow(node, startPos, options, tableCellMargins) {
1385
1409
  const cells = [];
1386
1410
  let offset = startPos + 1;
1411
+ let breakBefore;
1387
1412
  node.forEach((child) => {
1388
- if (child.type.name === "tableCell" || child.type.name === "tableHeader") cells.push(convertTableCell(child, offset, options, tableCellMargins));
1413
+ if (child.type.name === "tableCell" || child.type.name === "tableHeader") {
1414
+ const converted = convertTableCell(child, offset, options, tableCellMargins);
1415
+ if (converted.breakBefore !== void 0) breakBefore = converted.breakBefore;
1416
+ cells.push(converted.cell);
1417
+ }
1389
1418
  offset += child.nodeSize;
1390
1419
  });
1391
1420
  const attrs = expectTableRowAttrs(node);
@@ -1399,6 +1428,7 @@ function convertTableRow(node, startPos, options, tableCellMargins) {
1399
1428
  if (attrs.heightRule) row.heightRule = attrs.heightRule;
1400
1429
  if (attrs.isHeader) row.isHeader = attrs.isHeader;
1401
1430
  if (attrs._originalFormatting?.cantSplit) row.cantSplit = true;
1431
+ if (breakBefore !== void 0) row.breakBefore = breakBefore;
1402
1432
  if (attrs.hidden) row.hidden = attrs.hidden;
1403
1433
  const effectiveJustification = attrs._originalFormatting?.justification ?? attrs._resolvedJustification;
1404
1434
  if (effectiveJustification) row.justification = effectiveJustification;
@@ -1539,6 +1569,7 @@ function convertTextBoxNode(node, startPos, opts) {
1539
1569
  if (attrs.outlineWidth !== void 0) textBox.outlineWidth = attrs.outlineWidth;
1540
1570
  if (attrs.outlineColor !== void 0) textBox.outlineColor = attrs.outlineColor;
1541
1571
  if (attrs.outlineStyle !== void 0) textBox.outlineStyle = attrs.outlineStyle;
1572
+ if (attrs.transform !== void 0) textBox.transform = attrs.transform;
1542
1573
  if (attrs.displayMode !== void 0) textBox.displayMode = attrs.displayMode;
1543
1574
  if (attrs.cssFloat !== void 0) textBox.cssFloat = attrs.cssFloat;
1544
1575
  if (attrs.wrapType !== void 0) textBox.wrapType = attrs.wrapType;
@@ -1703,12 +1734,12 @@ function toFlowBlocks(doc, options = {}) {
1703
1734
  blocks.push(block);
1704
1735
  };
1705
1736
  const pushParagraphProjection = (node, pos, stripLeadingLineBreak = false) => {
1706
- if (opts.firstPageBreakRunPosition(node) !== void 0) {
1707
- const disposition = pageBreakRunParagraphProjectionDisposition(node);
1708
- if (disposition.status === "unsupported") panic(disposition.message);
1709
- }
1710
1737
  const pageBreaks = [];
1711
1738
  const paragraph = convertParagraph(node, pos, opts, pageBreaks);
1739
+ if (pageBreaks.length > 0) {
1740
+ const disposition = pageBreakRunParagraphProjectionDisposition(node);
1741
+ if (disposition.status === "unsupported" && (disposition.reason === "textBoxAnchor" || !hasSingleLeadingProjectedPageBreak(paragraph.runs, pageBreaks))) panic(disposition.message);
1742
+ }
1712
1743
  if (stripLeadingLineBreak && paragraph.runs.at(0)?.kind === "lineBreak") paragraph.runs.shift();
1713
1744
  for (const block of splitParagraphAtPageBreaks({
1714
1745
  pageBreaks,
@@ -303,10 +303,10 @@ function layoutDocumentPass(blocks, measures, options) {
303
303
  block,
304
304
  pageNumberBefore: pageBeforeBlockLayout,
305
305
  pageNumberAfter: paginator.getCurrentState().page.number,
306
- previousPage: paginator.pages[pageBeforeBlockLayout - 1]
306
+ previousPage: paginator.states[pageBeforeBlockLayout - 1]?.page
307
307
  });
308
308
  }
309
- if (paginator.pages.length === 0) paginator.getCurrentState();
309
+ if (paginator.states.length === 0) paginator.getCurrentState();
310
310
  return {
311
311
  pageSize,
312
312
  pages: paginator.pages,
@@ -359,6 +359,7 @@ function layoutParagraph({ block, measure, paginator, contentWidth, footnoteHeig
359
359
  const state = paginator.getCurrentState();
360
360
  const fragment = {
361
361
  kind: "paragraph",
362
+ ...block.attrs?.suppressEmptyParagraphHeight === true ? { paginationRole: "empty-carrier" } : {},
362
363
  blockId: block.id,
363
364
  x: paginator.getColumnX(state.columnIndex),
364
365
  y: state.cursorY + spaceBefore,
@@ -454,6 +455,7 @@ function layoutParagraph({ block, measure, paginator, contentWidth, footnoteHeig
454
455
  const pmRange = getParagraphFragmentPmRange(block, measure, currentLineIndex, currentLineIndex + fittingLines);
455
456
  const fragment = {
456
457
  kind: "paragraph",
458
+ ...block.attrs?.suppressEmptyParagraphHeight === true ? { paginationRole: "empty-carrier" } : {},
457
459
  blockId: block.id,
458
460
  x: paginator.getColumnX(state.columnIndex),
459
461
  y: 0,
@@ -575,7 +577,12 @@ function layoutTable(block, measure, paginator, footnoteHeightById) {
575
577
  const rowState = paginator.getCurrentState();
576
578
  const rowHeaderOverhead = shouldRepeatHeaderRows(currentRowIndex, 0, rowState) ? headerRowsHeight : 0;
577
579
  const rowAvailableHeight = paginator.getAvailableHeight() - rowHeaderOverhead - rowState.trailingSpacing;
578
- if (!(rowState.cursorY === rowState.topMargin && rowState.page.fragments.length === 0) && tableRowStartsWithRenderedPageBreak(block, currentRowIndex) && !tableRowHasTrackedChanges(block, currentRowIndex) && rows[currentRowIndex].height > rowAvailableHeight) {
580
+ const rowStartsFreshPage = rowState.cursorY === rowState.topMargin && rowState.page.fragments.length === 0;
581
+ if (block.rows[currentRowIndex]?.breakBefore === "page" && !rowStartsFreshPage) {
582
+ paginator.forcePageBreak();
583
+ continue;
584
+ }
585
+ if (!rowStartsFreshPage && tableRowStartsWithRenderedPageBreak(block, currentRowIndex) && !tableRowHasTrackedChanges(block, currentRowIndex) && rows[currentRowIndex].height > rowAvailableHeight) {
579
586
  paginator.forcePageBreak();
580
587
  continue;
581
588
  }
@@ -676,6 +683,7 @@ function layoutTable(block, measure, paginator, footnoteHeightById) {
676
683
  rowIndex: currentRowIndex
677
684
  });
678
685
  for (let j = currentRowIndex; j < rows.length; j++) {
686
+ if (j > currentRowIndex && block.rows[j]?.breakBefore === "page") break;
679
687
  if (j > currentRowIndex && computeTableX({
680
688
  columnIndex: state.columnIndex,
681
689
  rowIndex: j
@@ -883,13 +891,14 @@ function layoutTextBox(block, measure, { paginator, sectionMarginTop, sectionPag
883
891
  marginBottom: sectionMarginBottom,
884
892
  boxHeight: measure.height
885
893
  });
886
- const horizontal = block.position?.horizontal;
887
- const x = horizontal ? bandFragmentX(horizontal, {
894
+ const x = bandFragmentX(block.position?.horizontal, {
888
895
  pageWidth: state.page.size.w,
889
896
  marginLeft: state.page.margins.left,
890
897
  marginRight: state.page.margins.right,
898
+ activeColumnLeft: paginator.getColumnX(state.columnIndex),
899
+ activeColumnWidth: paginator.columnWidth,
891
900
  boxWidth: measure.width
892
- }) : paginator.getColumnX(state.columnIndex);
901
+ });
893
902
  const fragment = {
894
903
  kind: "textBox",
895
904
  blockId: block.id,
@@ -906,13 +915,14 @@ function layoutTextBox(block, measure, { paginator, sectionMarginTop, sectionPag
906
915
  }
907
916
  if (block.position !== void 0) {
908
917
  const state = paginator.getCurrentState();
909
- const horizontal = block.position.horizontal;
910
- const x = horizontal ? bandFragmentX(horizontal, {
918
+ const x = bandFragmentX(block.position.horizontal, {
911
919
  pageWidth: state.page.size.w,
912
920
  marginLeft: state.page.margins.left,
913
921
  marginRight: state.page.margins.right,
922
+ activeColumnLeft: paginator.getColumnX(state.columnIndex),
923
+ activeColumnWidth: paginator.columnWidth,
914
924
  boxWidth: measure.width
915
- }) : paginator.getColumnX(state.columnIndex);
925
+ });
916
926
  const vertical = block.position.vertical;
917
927
  const anchorBlockId = readTextBoxAnchorBlockId(block);
918
928
  const anchorParagraph = vertical?.relativeTo === "paragraph" && typeof anchorBlockId === "string" ? state.page.fragments.find((fragment) => fragment.kind === "paragraph" && fragment.blockId === anchorBlockId) : void 0;
@@ -1004,7 +1014,7 @@ function handleSectionBreak(_block, paginator, nextSectionConfig, nextSectionTyp
1004
1014
  if (!paginator.retargetCurrentBlankPage()) panic("Odd-page section target must be blank");
1005
1015
  break;
1006
1016
  case "continuous": {
1007
- const currentPage = paginator.pages.at(-1);
1017
+ const currentPage = paginator.states.at(-1)?.page;
1008
1018
  const nextSize = nextSectionConfig.pageSize;
1009
1019
  const pageSizeChanges = currentPage != null && (Math.round(nextSize.w) !== Math.round(currentPage.size.w) || Math.round(nextSize.h) !== Math.round(currentPage.size.h));
1010
1020
  if (nextSectionIndex !== void 0) paginator.startSection({
@@ -2,6 +2,10 @@ import { FlowBlock, Measure, TableBlock, TableMeasure, TextBoxBlock, TextBoxMeas
2
2
  import { FloatingImageZone } from "./floatingZones.js";
3
3
  import "./measureParagraph.js";
4
4
  //#region src/layout-engine/measure/measureBlocks.d.ts
5
+ type MeasureBlocksOptions = {
6
+ /** Header/footer tabs may be authored in the page margin, beyond body width. */
7
+ allowEndTabOverflow?: boolean;
8
+ };
5
9
  declare function measureTableBlock(tableBlock: TableBlock, contentWidth: number, fieldValues?: ReadonlyMap<number, string>): TableMeasure;
6
10
  type PhysicalBandPageGeometry = {
7
11
  pageWidth: number | number[];
@@ -28,7 +32,7 @@ type BandPageGeometry = {
28
32
  /**
29
33
  * Measure a block based on its type.
30
34
  */
31
- declare function measureBlock(block: FlowBlock, contentWidth: number, floatingZones?: FloatingImageZone[], cumulativeY?: number, fieldValues?: ReadonlyMap<number, string>): Measure;
35
+ declare function measureBlock(block: FlowBlock, contentWidth: number, floatingZones?: FloatingImageZone[], cumulativeY?: number, fieldValues?: ReadonlyMap<number, string>, options?: MeasureBlocksOptions): Measure;
32
36
  declare function measureTextBoxBlock(tb: TextBoxBlock, fieldValues?: ReadonlyMap<number, string>): TextBoxMeasure;
33
37
  /**
34
38
  * Measure all blocks with floating image support.
@@ -37,7 +41,7 @@ declare function measureTextBoxBlock(tb: TextBoxBlock, fieldValues?: ReadonlyMap
37
41
  * Then measures each block, passing the zones so paragraphs can calculate
38
42
  * per-line widths based on vertical overlap with floating images.
39
43
  */
40
- declare function measureBlocks(blocks: FlowBlock[], contentWidth: number | number[], marginTop?: number | number[], pageGeometry?: BandPageGeometry, fieldValues?: ReadonlyMap<number, string>): Measure[];
44
+ declare function measureBlocks(blocks: FlowBlock[], contentWidth: number | number[], marginTop?: number | number[], pageGeometry?: BandPageGeometry, fieldValues?: ReadonlyMap<number, string>, options?: MeasureBlocksOptions): Measure[];
41
45
  declare function measureSingleBlockWithoutFloatingZones(block: FlowBlock, blockWidth: number, blockIndex: number): Measure;
42
46
  //#endregion
43
- export { measureBlock, measureBlocks, measureSingleBlockWithoutFloatingZones, measureTableBlock, measureTextBoxBlock };
47
+ export { MeasureBlocksOptions, measureBlock, measureBlocks, measureSingleBlockWithoutFloatingZones, measureTableBlock, measureTextBoxBlock };
@@ -385,6 +385,7 @@ function extractFloatingZones(blocks, contentWidth, marginTop = 0, pageGeometry)
385
385
  const blockMarginRight = perBlockNumberValue(marginRightInput, blockIndex, defaultMarginRight);
386
386
  const blockPageWidth = perBlockNumberValue(pageWidthInput, blockIndex, defaultPageWidth);
387
387
  const blockContentWidth = perBlockNumberValue(contentWidth, blockIndex, defaultContentWidth);
388
+ const blockContentLeft = perBlockNumberValue(contentLeftInput, blockIndex, defaultContentLeft);
388
389
  const vertical = tb.position.vertical;
389
390
  const pageFrameRelative = isPageFrameRelativeAnchor(vertical?.relativeTo);
390
391
  const topY = pageFrameRelative ? bandTopContentY(vertical, {
@@ -408,13 +409,14 @@ function extractFloatingZones(blocks, contentWidth, marginTop = 0, pageGeometry)
408
409
  });
409
410
  continue;
410
411
  }
411
- const horizontal = tb.position.horizontal;
412
- const contentX = (horizontal ? bandFragmentX(horizontal, {
412
+ const contentX = bandFragmentX(tb.position.horizontal, {
413
413
  pageWidth: blockPageWidth,
414
414
  marginLeft: blockMarginLeft,
415
415
  marginRight: blockMarginRight,
416
+ activeColumnLeft: blockContentLeft,
417
+ activeColumnWidth: blockContentWidth,
416
418
  boxWidth: measure.width
417
- }) : blockMarginLeft) - blockMarginLeft;
419
+ }) - blockContentLeft;
418
420
  const wrapSide = textBoxWrapSide({
419
421
  box: tb,
420
422
  contentX,
@@ -518,12 +520,12 @@ function activateFloatingTablePageRect(template, anchorPageY) {
518
520
  /**
519
521
  * Measure a block based on its type.
520
522
  */
521
- function measureBlock(block, contentWidth, floatingZones, cumulativeY, fieldValues) {
523
+ function measureBlock(block, contentWidth, floatingZones, cumulativeY, fieldValues, options) {
522
524
  switch (block.kind) {
523
525
  case "paragraph": {
524
526
  const pBlock = block;
525
527
  const hasFieldRuns = pBlock.runs.some((run) => run.kind === "field");
526
- const cacheable = (!floatingZones || floatingZones.length === 0) && (!fieldValues || !hasFieldRuns);
528
+ const cacheable = (!floatingZones || floatingZones.length === 0) && (!fieldValues || !hasFieldRuns) && options?.allowEndTabOverflow !== true;
527
529
  if (cacheable) {
528
530
  const cached = getCachedParagraphMeasure(pBlock, contentWidth);
529
531
  if (cached) return cached;
@@ -531,6 +533,7 @@ function measureBlock(block, contentWidth, floatingZones, cumulativeY, fieldValu
531
533
  const measureOpts = { paragraphYOffset: cumulativeY ?? 0 };
532
534
  if (floatingZones) measureOpts.floatingZones = floatingZones;
533
535
  if (fieldValues) measureOpts.fieldValues = fieldValues;
536
+ if (options?.allowEndTabOverflow === true) measureOpts.allowEndTabOverflow = true;
534
537
  const result = measureParagraph(pBlock, contentWidth, measureOpts);
535
538
  if (cacheable) setCachedParagraphMeasure(pBlock, contentWidth, result);
536
539
  return result;
@@ -604,7 +607,7 @@ function isContinuousSectionBreak(block) {
604
607
  * Then measures each block, passing the zones so paragraphs can calculate
605
608
  * per-line widths based on vertical overlap with floating images.
606
609
  */
607
- function measureBlocks(blocks, contentWidth, marginTop = 0, pageGeometry, fieldValues) {
610
+ function measureBlocks(blocks, contentWidth, marginTop = 0, pageGeometry, fieldValues, options) {
608
611
  const defaultWidth = Array.isArray(contentWidth) ? contentWidth[0] ?? 0 : contentWidth;
609
612
  const extractedZones = extractFloatingZones(blocks, contentWidth, marginTop, pageGeometry);
610
613
  const floatingZonesWithAnchors = [];
@@ -694,7 +697,7 @@ function measureBlocks(blocks, contentWidth, marginTop = 0, pageGeometry, fieldV
694
697
  }));
695
698
  const zones = activeZones.length > 0 || projectedTableZones.length > 0 ? [...activeZones, ...projectedTableZones] : void 0;
696
699
  try {
697
- const measure = measureBlock(block, blockWidth, zones, cumulativeY, fieldValues);
700
+ const measure = measureBlock(block, blockWidth, zones, cumulativeY, fieldValues, options);
698
701
  const clearingZones = measure.kind === "table" ? zones : zones?.filter((zone) => zone.fullWidthBlock);
699
702
  if (clearingZones?.length && (measure.kind === "image" || measure.kind === "table" && !block.floating)) {
700
703
  const blockHeight = measure.kind === "image" ? measure.height : measure.totalHeight;
@@ -13,6 +13,8 @@ type MeasureParagraphOptions = {
13
13
  /** Field run `pmStart` -> resolved display text, so a field measures at its
14
14
  * painted width instead of the cached fallback. */
15
15
  fieldValues?: ReadonlyMap<number, string>;
16
+ /** Header/footer tabs may be authored in the page margin, beyond body width. */
17
+ allowEndTabOverflow?: boolean;
16
18
  };
17
19
  /**
18
20
  * Minimum horizontal room a line must offer before we treat it as usable for
@@ -987,7 +987,7 @@ function measureParagraph(block, maxWidth, options) {
987
987
  let tabWidth = tabResult.width;
988
988
  const authoredEndpoint = contentX + tabWidth + followingWidth;
989
989
  const activeContentRightEdge = maxWidth - currentLine.rightOffset;
990
- const preservesAuthoredEndStop = tabResult.alignment === "end" && authoredEndpoint <= activeContentRightEdge + WIDTH_TOLERANCE;
990
+ const preservesAuthoredEndStop = tabResult.alignment === "end" && (options?.allowEndTabOverflow === true || authoredEndpoint <= activeContentRightEdge + WIDTH_TOLERANCE);
991
991
  const landsOnLeftIndent = tabResult.alignment === "start" && indentLeft > 0 && Math.abs(contentX + tabWidth - indentLeft) <= WIDTH_TOLERANCE;
992
992
  const lineRightEdgeX = indentLeft + (isFirstLine ? firstLineOffset + markerInlineWidth : 0) + currentLine.availableWidth + currentLine.leftOffset;
993
993
  if (!preservesAuthoredEndStop && !landsOnLeftIndent && !hasFollowingTabOnLine(runs, runIndex) && canClampTabToRightEdge(tabResult.alignment, currentLine.width, hasPriorTabOnLine(runs, runIndex), followingWidth, currentLine.availableWidth) && (tabWidth > 0 || followingWidth > 0) && contentX + tabWidth + followingWidth > lineRightEdgeX + WIDTH_TOLERANCE) tabWidth = Math.max(1, lineRightEdgeX - contentX - followingWidth);
@@ -92,7 +92,7 @@ declare function resolveColumnLeft({ leftMargin, columnWidths, columns, columnIn
92
92
  */
93
93
  declare function createPaginator(options: PaginatorOptions): {
94
94
  /** All pages created so far. */
95
- pages: Page[];
95
+ readonly pages: Page[];
96
96
  /** All page states. */
97
97
  states: PageState[];
98
98
  /** Column width in pixels (use getColumnWidth() for current value after updates). */
@@ -12,6 +12,7 @@ const SECTION_START_PLACEMENT = {
12
12
  CONTINUOUS: "continuous",
13
13
  NEXT_PAGE: "nextPage"
14
14
  };
15
+ const isEmptyCarrier = (fragment) => fragment.kind === "paragraph" && fragment.paginationRole === "empty-carrier";
15
16
  /** Calculate active column widths, preferring authored unequal widths. */
16
17
  function calculateColumnWidths(pageWidth, leftMargin, rightMargin, columns) {
17
18
  if (columns.widths?.length === columns.count && columns.widths.every((width) => Number.isFinite(width) && width > 0)) return [...columns.widths];
@@ -63,6 +64,7 @@ function createPaginator(options) {
63
64
  };
64
65
  const pages = [];
65
66
  const states = [];
67
+ const paginationStateByPage = /* @__PURE__ */ new WeakMap();
66
68
  function getContentBottom() {
67
69
  return pageSize.h - margins.bottom;
68
70
  }
@@ -112,10 +114,30 @@ function createPaginator(options) {
112
114
  columnIndex
113
115
  });
114
116
  }
117
+ function getPagePaginationState(page) {
118
+ const state = paginationStateByPage.get(page);
119
+ if (!state) panic("Paginator: page visibility state is missing");
120
+ return state;
121
+ }
122
+ function materializeCarrierGeometry(state) {
123
+ if (!state) return;
124
+ const paginationState = getPagePaginationState(state.page);
125
+ if (!paginationState.carrierGeometryNeedsMaterialization) return;
126
+ const x = getColumnX(0);
127
+ const width = columnWidths[0] ?? getContentWidth();
128
+ for (const fragment of state.page.fragments) {
129
+ if (!isEmptyCarrier(fragment)) continue;
130
+ fragment.x = x;
131
+ fragment.y = state.topMargin;
132
+ fragment.width = width;
133
+ }
134
+ paginationState.carrierGeometryNeedsMaterialization = false;
135
+ }
115
136
  /**
116
137
  * Create a new page and add it to the list.
117
138
  */
118
139
  function createNewPage() {
140
+ materializeCarrierGeometry(states.at(-1));
119
141
  if (pendingPageSize || pendingMargins) applyPendingLayout();
120
142
  const pageNumber = pages.length + 1;
121
143
  const logicalNumber = nextLogicalPageNumber;
@@ -150,6 +172,10 @@ function createPaginator(options) {
150
172
  footnoteDemandHeight: 0,
151
173
  trailingSpacing: 0
152
174
  };
175
+ paginationStateByPage.set(page, {
176
+ visibleFragmentCount: 0,
177
+ carrierGeometryNeedsMaterialization: false
178
+ });
153
179
  pages.push(page);
154
180
  states.push(state);
155
181
  columnRegionTop = topMargin;
@@ -242,6 +268,11 @@ function createPaginator(options) {
242
268
  }
243
269
  function commitFragment(state, fragment) {
244
270
  consumeSharedSectionPage(state);
271
+ const paginationState = getPagePaginationState(state.page);
272
+ if (!isEmptyCarrier(fragment)) {
273
+ materializeCarrierGeometry(state);
274
+ paginationState.visibleFragmentCount += 1;
275
+ }
245
276
  state.page.fragments.push(fragment);
246
277
  }
247
278
  function addUnflowedFragment(fragment) {
@@ -289,7 +320,7 @@ function createPaginator(options) {
289
320
  function forcePageBreak(breakOptions = {}) {
290
321
  const current = states.at(-1);
291
322
  if (current?.page.fragments.length) consumeSharedSectionPage(current);
292
- if (breakOptions.coalesceBlankPage && current && current.page.fragments.length === 0 && current.cursorY === current.topMargin) {
323
+ if (breakOptions.coalesceBlankPage && current && getPagePaginationState(current.page).visibleFragmentCount === 0 && current.cursorY === current.topMargin) {
293
324
  if (current.page.sectionIndex !== currentSectionIndex) {
294
325
  retargetCurrentBlankPage();
295
326
  return current;
@@ -312,7 +343,9 @@ function createPaginator(options) {
312
343
  const pageMargins = getPageMargins(state.page.number, logicalNumber);
313
344
  const xDelta = pageMargins.left - previousMargins.left;
314
345
  const yDelta = pageMargins.top - previousMargins.top;
315
- for (const fragment of state.page.fragments) {
346
+ const paginationState = getPagePaginationState(state.page);
347
+ if (paginationState.visibleFragmentCount === 0) paginationState.carrierGeometryNeedsMaterialization = state.page.fragments.length > 0;
348
+ else for (const fragment of state.page.fragments) {
316
349
  fragment.x += xDelta;
317
350
  fragment.y += yDelta;
318
351
  }
@@ -327,7 +360,7 @@ function createPaginator(options) {
327
360
  }
328
361
  function retargetCurrentBlankPage() {
329
362
  const current = states.at(-1);
330
- if (!current || current.page.fragments.length > 0 || current.cursorY !== current.topMargin) return false;
363
+ if (!current || getPagePaginationState(current.page).visibleFragmentCount > 0 || current.cursorY !== current.topMargin) return false;
331
364
  if (pendingPageSize || pendingMargins) applyPendingLayout();
332
365
  retargetSectionMetadata(current.page);
333
366
  const pageMargins = getPageMargins(current.page.number, current.page.logicalNumber);
@@ -342,6 +375,7 @@ function createPaginator(options) {
342
375
  else delete current.page.columns;
343
376
  current.topMargin = topMargin;
344
377
  current.cursorY = topMargin;
378
+ getPagePaginationState(current.page).carrierGeometryNeedsMaterialization = current.page.fragments.length > 0;
345
379
  current.columnIndex = 0;
346
380
  current.rawContentBottom = rawContentBottom;
347
381
  current.footnoteHeight = footnoteHeightFloor;
@@ -421,7 +455,10 @@ function createPaginator(options) {
421
455
  }
422
456
  return {
423
457
  /** All pages created so far. */
424
- pages,
458
+ get pages() {
459
+ materializeCarrierGeometry(states.at(-1));
460
+ return pages;
461
+ },
425
462
  /** All page states. */
426
463
  states,
427
464
  /** Column width in pixels (use getColumnWidth() for current value after updates). */
@@ -33,6 +33,8 @@ type BandHorizontalGeometry = {
33
33
  pageWidth: number;
34
34
  marginLeft: number;
35
35
  marginRight: number;
36
+ activeColumnLeft: number;
37
+ activeColumnWidth: number;
36
38
  boxWidth: number;
37
39
  };
38
40
  /**
@@ -97,11 +97,12 @@ function bandTopContentY(vertical, geometry) {
97
97
  /**
98
98
  * Page-absolute `[left, right]` (px) of the frame a horizontal anchor positions
99
99
  * within. `inside`/`outsideMargin` map to the left/right margin strips (page
100
- * parity is not modelled); `column`/`character` fall back to the content box
101
- * (folio has no per-column/character X here). eigenpal #694.
100
+ * parity is not modelled); `column` and an omitted horizontal anchor use the
101
+ * active flow-column frame, while `character` falls back to the content box
102
+ * because character X is unavailable. eigenpal #694.
102
103
  */
103
104
  function bandHorizontalFrame(relativeTo, geometry) {
104
- const { pageWidth, marginLeft, marginRight } = geometry;
105
+ const { pageWidth, marginLeft, marginRight, activeColumnLeft, activeColumnWidth } = geometry;
105
106
  switch (relativeTo) {
106
107
  case "page": return {
107
108
  left: 0,
@@ -117,10 +118,13 @@ function bandHorizontalFrame(relativeTo, geometry) {
117
118
  left: pageWidth - marginRight,
118
119
  right: pageWidth
119
120
  };
120
- case "margin":
121
121
  case "column":
122
- case "character":
123
122
  case void 0: return {
123
+ left: activeColumnLeft,
124
+ right: activeColumnLeft + activeColumnWidth
125
+ };
126
+ case "margin":
127
+ case "character": return {
124
128
  left: marginLeft,
125
129
  right: pageWidth - marginRight
126
130
  };