@beyondwork/docx-react-component 1.0.85 → 1.0.87

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +338 -13
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +112 -33
  29. package/src/ui/editor-command-bag.ts +4 -0
  30. package/src/ui/editor-shell-view.tsx +1 -0
  31. package/src/ui/editor-surface-controller.tsx +1 -0
  32. package/src/ui/headless/revision-decoration-model.ts +11 -13
  33. package/src/ui-tailwind/chrome/editor-action-registry.ts +7 -26
  34. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  35. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  36. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +6 -2
  37. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  38. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  39. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  40. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  41. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  42. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  43. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +41 -44
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  45. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +8 -3
  46. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -0
  47. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +75 -31
  48. package/src/ui-tailwind/review/tw-workflow-tab.tsx +159 -8
  49. package/src/ui-tailwind/review-workspace/types.ts +4 -0
  50. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  51. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +8 -10
  52. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -17
  53. package/src/ui-tailwind/tw-review-workspace.tsx +27 -2
@@ -291,6 +291,7 @@ import type {
291
291
  ParagraphNode,
292
292
  SectionProperties,
293
293
  SubPartsCatalog,
294
+ TocRegion,
294
295
  } from "../model/canonical-document.ts";
295
296
  import {
296
297
  isSupportedFieldFamily,
@@ -3565,6 +3566,7 @@ export function createDocumentRuntime(
3565
3566
  emitError(runtimeError);
3566
3567
  return {
3567
3568
  kind: "rejected",
3569
+ refreshClass: "blocked",
3568
3570
  opId: (command.origin as { opId?: string } | undefined)?.opId,
3569
3571
  newRevisionToken: "",
3570
3572
  blockedReasons: [
@@ -5319,6 +5321,7 @@ export function createDocumentRuntime(
5319
5321
  });
5320
5322
  return completeDispatch({
5321
5323
  kind: "rejected",
5324
+ refreshClass: "blocked",
5322
5325
  opId,
5323
5326
  newRevisionToken: "",
5324
5327
  blockedReasons: [{ code: "suggesting_unsupported", message }],
@@ -5335,6 +5338,7 @@ export function createDocumentRuntime(
5335
5338
  });
5336
5339
  return completeDispatch({
5337
5340
  kind: "rejected",
5341
+ refreshClass: "blocked",
5338
5342
  opId,
5339
5343
  newRevisionToken: "",
5340
5344
  blockedReasons: blockedReasons.map((r) => ({ code: r.code, message: r.message })),
@@ -5408,6 +5412,7 @@ export function createDocumentRuntime(
5408
5412
  });
5409
5413
  return completeDispatch({
5410
5414
  kind: "equivalent",
5415
+ refreshClass: "local-text-equivalent",
5411
5416
  opId,
5412
5417
  newRevisionToken: state.revisionToken,
5413
5418
  });
@@ -5475,6 +5480,7 @@ export function createDocumentRuntime(
5475
5480
  if (meta.invalidatesStructures) {
5476
5481
  return {
5477
5482
  kind: "structural-divergence",
5483
+ refreshClass: "full-projection",
5478
5484
  opId,
5479
5485
  newRevisionToken,
5480
5486
  scopeTagTouches: touches,
@@ -5499,6 +5505,7 @@ export function createDocumentRuntime(
5499
5505
  const adjustedRange = computeAdjustedRange(priorState, transaction);
5500
5506
  return {
5501
5507
  kind: "adjusted",
5508
+ refreshClass: "surface-only",
5502
5509
  opId,
5503
5510
  newRevisionToken,
5504
5511
  adjustedRange,
@@ -5508,6 +5515,7 @@ export function createDocumentRuntime(
5508
5515
 
5509
5516
  return {
5510
5517
  kind: "equivalent",
5518
+ refreshClass: "local-text-equivalent",
5511
5519
  opId,
5512
5520
  newRevisionToken,
5513
5521
  scopeTagTouches: touches,
@@ -5685,6 +5693,10 @@ export function createDocumentRuntime(
5685
5693
  emit({
5686
5694
  type: "toc_auto_refreshed",
5687
5695
  documentId: state.documentId,
5696
+ ...(refreshed.result.tocId ? { tocId: refreshed.result.tocId } : {}),
5697
+ ...(refreshed.document.fieldRegistry?.tocRegions
5698
+ ? { regionCount: refreshed.document.fieldRegistry.tocRegions.length }
5699
+ : {}),
5688
5700
  entryCount: refreshed.result.entryCount,
5689
5701
  trigger: flushedTrigger,
5690
5702
  });
@@ -7161,6 +7173,29 @@ function refreshDocumentTableOfContents(
7161
7173
  changed: boolean;
7162
7174
  protectionSelection?: import("../core/state/editor-state.ts").SelectionSnapshot;
7163
7175
  } {
7176
+ const selectedRegion = selectTocRegion(document.fieldRegistry?.tocRegions, options?.tocId);
7177
+ const refreshMode = options?.mode ?? "regenerate";
7178
+ if (refreshMode === "preserveCached") {
7179
+ const cachedEntries = selectedRegion?.cachedEntries ?? [];
7180
+ return {
7181
+ document,
7182
+ result: {
7183
+ ...(selectedRegion ? { tocId: selectedRegion.tocId } : {}),
7184
+ mode: "preserveCached",
7185
+ status: selectedRegion?.status ?? (cachedEntries.length > 0 ? "stale" : "missing"),
7186
+ entryCount: cachedEntries.length,
7187
+ entries: cachedEntries.map((entry) => ({
7188
+ level: entry.level,
7189
+ text: entry.text,
7190
+ pageIndex: tocPageTextToPageIndex(entry.pageText),
7191
+ ...(entry.pageText ? { pageText: entry.pageText } : {}),
7192
+ source: "cached",
7193
+ })),
7194
+ },
7195
+ changed: false,
7196
+ };
7197
+ }
7198
+
7164
7199
  const navigation = createDocumentNavigationSnapshot(document, selectionHead, activeStory);
7165
7200
  // Build a single O(N) map from paragraph offset → bookmark name so the
7166
7201
  // per-heading lookup below is O(1) instead of O(N) per heading.
@@ -7189,16 +7224,16 @@ function refreshDocumentTableOfContents(
7189
7224
  }
7190
7225
  }
7191
7226
  let changed = false;
7192
- let resultEntries: Array<{ level: number; text: string; pageIndex: number; bookmarkName?: string }> = [];
7227
+ let resultEntries: RuntimeTocEntry[] = [];
7193
7228
  let changedFrom: number | undefined;
7194
7229
  let changedTo: number | undefined;
7195
- const nextChildren = refreshBlocksWithCursor(document.content.children, (field, range) => {
7196
- if (field.fieldFamily !== "TOC") {
7197
- return field;
7198
- }
7230
+
7231
+ const buildEntriesForField = (field: FieldNode): RuntimeTocEntry[] => {
7199
7232
  const levelRange = options?.maxLevel
7200
7233
  ? { from: 1, to: options.maxLevel }
7201
- : parseTocLevelRange(field.instruction);
7234
+ : selectedRegion && normalizeTocInstruction(selectedRegion.instruction.raw) === normalizeTocInstruction(field.instruction)
7235
+ ? selectedRegion.instruction.outlineRange
7236
+ : parseTocLevelRange(field.instruction);
7202
7237
  const entries = navigation.headings
7203
7238
  .filter((heading) => heading.level >= levelRange.from && heading.level <= levelRange.to)
7204
7239
  .map((heading) => {
@@ -7213,6 +7248,30 @@ function refreshDocumentTableOfContents(
7213
7248
  if (resultEntries.length === 0) {
7214
7249
  resultEntries = entries;
7215
7250
  }
7251
+ return entries;
7252
+ };
7253
+
7254
+ const tocRangeRefresh = refreshTocBlockRanges(
7255
+ document.content.children,
7256
+ buildEntriesForField,
7257
+ resolveDisplayPageNumber,
7258
+ selectedRegion,
7259
+ );
7260
+ if (tocRangeRefresh.changed) {
7261
+ changed = true;
7262
+ }
7263
+
7264
+ const nextChildren = refreshBlocksWithCursor(tocRangeRefresh.blocks, (field, range) => {
7265
+ if (field.fieldFamily !== "TOC") {
7266
+ return field;
7267
+ }
7268
+ if (selectedRegion && normalizeTocInstruction(field.instruction) !== normalizeTocInstruction(selectedRegion.instruction.raw)) {
7269
+ return field;
7270
+ }
7271
+ if (field.refreshStatus === "current" && field.children.length === 0) {
7272
+ return field;
7273
+ }
7274
+ const entries = buildEntriesForField(field);
7216
7275
  const nextField: FieldNode = {
7217
7276
  ...field,
7218
7277
  children: buildTocInlineNodes(entries, resolveDisplayPageNumber),
@@ -7228,7 +7287,7 @@ function refreshDocumentTableOfContents(
7228
7287
  if (!changed) {
7229
7288
  return {
7230
7289
  document,
7231
- result: { entryCount: resultEntries.length, entries: resultEntries },
7290
+ result: buildTocRefreshResult(selectedRegion, "regenerate", resultEntries, "current"),
7232
7291
  changed: false,
7233
7292
  };
7234
7293
  }
@@ -7245,13 +7304,24 @@ function refreshDocumentTableOfContents(
7245
7304
  styles: nextDocument.styles,
7246
7305
  subParts: nextDocument.subParts,
7247
7306
  });
7248
- nextDocument.fieldRegistry = nextRegistry.tocStructure
7307
+ const nextTocRegions = nextRegistry.tocRegions?.map((region) => {
7308
+ if (!selectedRegion || normalizeTocInstruction(region.instruction.raw) === normalizeTocInstruction(selectedRegion.instruction.raw)) {
7309
+ return { ...region, status: "current" as const };
7310
+ }
7311
+ return region;
7312
+ });
7313
+ nextDocument.fieldRegistry = nextRegistry.tocStructure || nextTocRegions
7249
7314
  ? {
7250
7315
  ...nextRegistry,
7251
- tocStructure: {
7252
- ...nextRegistry.tocStructure,
7253
- status: "current",
7254
- },
7316
+ ...(nextTocRegions ? { tocRegions: nextTocRegions } : {}),
7317
+ ...(nextRegistry.tocStructure
7318
+ ? {
7319
+ tocStructure: {
7320
+ ...nextRegistry.tocStructure,
7321
+ status: "current",
7322
+ },
7323
+ }
7324
+ : {}),
7255
7325
  }
7256
7326
  : nextRegistry;
7257
7327
  let protectionSelection:
@@ -7263,12 +7333,267 @@ function refreshDocumentTableOfContents(
7263
7333
 
7264
7334
  return {
7265
7335
  document: nextDocument,
7266
- result: { entryCount: resultEntries.length, entries: resultEntries },
7336
+ result: buildTocRefreshResult(selectedRegion, "regenerate", resultEntries, "current"),
7267
7337
  changed: true,
7268
7338
  ...(protectionSelection ? { protectionSelection } : {}),
7269
7339
  };
7270
7340
  }
7271
7341
 
7342
+ type RuntimeTocEntry = {
7343
+ level: number;
7344
+ text: string;
7345
+ pageIndex: number;
7346
+ bookmarkName?: string;
7347
+ };
7348
+
7349
+ function selectTocRegion(
7350
+ regions: readonly TocRegion[] | undefined,
7351
+ tocId: string | undefined,
7352
+ ): TocRegion | undefined {
7353
+ if (!regions || regions.length === 0) {
7354
+ return undefined;
7355
+ }
7356
+ if (!tocId) {
7357
+ return regions[0];
7358
+ }
7359
+ return regions.find((region) => region.tocId === tocId);
7360
+ }
7361
+
7362
+ function buildTocRefreshResult(
7363
+ region: TocRegion | undefined,
7364
+ mode: "preserveCached" | "regenerate",
7365
+ entries: readonly RuntimeTocEntry[],
7366
+ status: "current" | "stale" | "missing",
7367
+ ): TocRefreshResult {
7368
+ return {
7369
+ ...(region ? { tocId: region.tocId } : {}),
7370
+ mode,
7371
+ status,
7372
+ entryCount: entries.length,
7373
+ entries: entries.map((entry) => ({
7374
+ level: entry.level,
7375
+ text: entry.text,
7376
+ pageIndex: entry.pageIndex,
7377
+ source: "generated",
7378
+ })),
7379
+ };
7380
+ }
7381
+
7382
+ function tocPageTextToPageIndex(pageText: string | undefined): number {
7383
+ const value = Number.parseInt(pageText ?? "", 10);
7384
+ return Number.isFinite(value) && value > 0 ? value - 1 : 0;
7385
+ }
7386
+
7387
+ function normalizeTocInstruction(instruction: string): string {
7388
+ return instruction.replace(/\s+/gu, " ").trim();
7389
+ }
7390
+
7391
+ function refreshTocBlockRanges(
7392
+ blocks: readonly BlockNode[],
7393
+ buildEntriesForField: (field: FieldNode) => RuntimeTocEntry[],
7394
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
7395
+ selectedRegion?: TocRegion,
7396
+ ): { blocks: BlockNode[]; changed: boolean } {
7397
+ const nextBlocks: BlockNode[] = [];
7398
+ let changed = false;
7399
+
7400
+ for (let index = 0; index < blocks.length; index += 1) {
7401
+ const block = blocks[index];
7402
+
7403
+ if (block.type === "paragraph") {
7404
+ const tocField = findTocFieldNode(block);
7405
+ if (
7406
+ tocField &&
7407
+ (!selectedRegion || normalizeTocInstruction(tocField.instruction) === normalizeTocInstruction(selectedRegion.instruction.raw)) &&
7408
+ startsTocParagraphRegion(blocks, index)
7409
+ ) {
7410
+ const endIndex = findTocParagraphRegionEnd(blocks, index);
7411
+ const region = blocks.slice(index, endIndex + 1).filter(
7412
+ (candidate): candidate is ParagraphNode => candidate.type === "paragraph",
7413
+ );
7414
+ const replacement = buildTocRegionParagraphs(
7415
+ region,
7416
+ tocField,
7417
+ buildEntriesForField(tocField),
7418
+ resolveDisplayPageNumber,
7419
+ );
7420
+ if (tocRegionSignature(region) !== tocRegionSignature(replacement)) {
7421
+ changed = true;
7422
+ }
7423
+ nextBlocks.push(...replacement);
7424
+ index = endIndex;
7425
+ continue;
7426
+ }
7427
+ nextBlocks.push(block);
7428
+ continue;
7429
+ }
7430
+
7431
+ if (block.type === "sdt" || block.type === "custom_xml") {
7432
+ const refreshed = refreshTocBlockRanges(
7433
+ block.children,
7434
+ buildEntriesForField,
7435
+ resolveDisplayPageNumber,
7436
+ selectedRegion,
7437
+ );
7438
+ if (refreshed.changed) {
7439
+ changed = true;
7440
+ nextBlocks.push({ ...block, children: refreshed.blocks });
7441
+ } else {
7442
+ nextBlocks.push(block);
7443
+ }
7444
+ continue;
7445
+ }
7446
+
7447
+ if (block.type === "table") {
7448
+ let tableChanged = false;
7449
+ const rows = block.rows.map((row) => {
7450
+ let rowChanged = false;
7451
+ const cells = row.cells.map((cell) => {
7452
+ const refreshed = refreshTocBlockRanges(
7453
+ cell.children,
7454
+ buildEntriesForField,
7455
+ resolveDisplayPageNumber,
7456
+ selectedRegion,
7457
+ );
7458
+ if (!refreshed.changed) {
7459
+ return cell;
7460
+ }
7461
+ tableChanged = true;
7462
+ rowChanged = true;
7463
+ return { ...cell, children: refreshed.blocks };
7464
+ });
7465
+ return rowChanged ? { ...row, cells } : row;
7466
+ });
7467
+ nextBlocks.push(tableChanged ? { ...block, rows } : block);
7468
+ changed = changed || tableChanged;
7469
+ continue;
7470
+ }
7471
+
7472
+ nextBlocks.push(block);
7473
+ }
7474
+
7475
+ return { blocks: nextBlocks, changed };
7476
+ }
7477
+
7478
+ function startsTocParagraphRegion(blocks: readonly BlockNode[], index: number): boolean {
7479
+ const current = blocks[index];
7480
+ if (current?.type === "paragraph" && isTocParagraphStyle(current.styleId)) {
7481
+ return true;
7482
+ }
7483
+ const next = blocks[index + 1];
7484
+ return next?.type === "paragraph" && isTocParagraphStyle(next.styleId);
7485
+ }
7486
+
7487
+ function findTocParagraphRegionEnd(blocks: readonly BlockNode[], startIndex: number): number {
7488
+ let endIndex = startIndex;
7489
+ while (endIndex + 1 < blocks.length) {
7490
+ const next = blocks[endIndex + 1];
7491
+ if (next?.type !== "paragraph" || !isTocParagraphStyle(next.styleId)) {
7492
+ break;
7493
+ }
7494
+ endIndex += 1;
7495
+ }
7496
+ return endIndex;
7497
+ }
7498
+
7499
+ function isTocParagraphStyle(styleId: string | undefined): boolean {
7500
+ return /^TOC\d+$/u.test(styleId ?? "");
7501
+ }
7502
+
7503
+ function findTocFieldNode(paragraph: ParagraphNode): FieldNode | undefined {
7504
+ return paragraph.children.find(
7505
+ (child): child is FieldNode => child.type === "field" && child.fieldFamily === "TOC",
7506
+ );
7507
+ }
7508
+
7509
+ function buildTocRegionParagraphs(
7510
+ existingRegion: readonly ParagraphNode[],
7511
+ tocField: FieldNode,
7512
+ entries: readonly RuntimeTocEntry[],
7513
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
7514
+ ): ParagraphNode[] {
7515
+ const firstTemplate = existingRegion[0];
7516
+ if (!firstTemplate) {
7517
+ return [];
7518
+ }
7519
+ const templateByStyle = new Map<string, ParagraphNode>();
7520
+ for (const paragraph of existingRegion) {
7521
+ if (paragraph.styleId && !templateByStyle.has(paragraph.styleId)) {
7522
+ templateByStyle.set(paragraph.styleId, paragraph);
7523
+ }
7524
+ }
7525
+ const refreshedField: FieldNode = {
7526
+ ...tocField,
7527
+ children: [],
7528
+ refreshStatus: "current",
7529
+ };
7530
+
7531
+ if (entries.length === 0) {
7532
+ return [{ ...firstTemplate, children: [refreshedField] }];
7533
+ }
7534
+
7535
+ return entries.map((entry, index) => {
7536
+ const styleId = `TOC${Math.max(1, Math.min(9, entry.level))}`;
7537
+ const template = templateByStyle.get(styleId) ?? firstTemplate;
7538
+ const children = buildTocEntryInlineNodes(entry, resolveDisplayPageNumber);
7539
+ return {
7540
+ ...template,
7541
+ styleId,
7542
+ children: index === 0 ? [refreshedField, ...children] : children,
7543
+ };
7544
+ });
7545
+ }
7546
+
7547
+ function buildTocEntryInlineNodes(
7548
+ entry: RuntimeTocEntry,
7549
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
7550
+ ): InlineNode[] {
7551
+ const children: InlineNode[] = [];
7552
+ if (entry.bookmarkName) {
7553
+ children.push({
7554
+ type: "hyperlink",
7555
+ href: `#${entry.bookmarkName}`,
7556
+ children: [{ type: "text", text: entry.text }],
7557
+ });
7558
+ } else {
7559
+ children.push({ type: "text", text: entry.text });
7560
+ }
7561
+ children.push({ type: "tab" });
7562
+ const displayed = resolveDisplayPageNumber?.(entry.pageIndex);
7563
+ children.push({
7564
+ type: "text",
7565
+ text: String(displayed ?? entry.pageIndex + 1),
7566
+ });
7567
+ return children;
7568
+ }
7569
+
7570
+ function tocRegionSignature(paragraphs: readonly ParagraphNode[]): string {
7571
+ return paragraphs
7572
+ .map((paragraph) => `${paragraph.styleId ?? ""}\u0001${tocInlineSignature(paragraph.children)}`)
7573
+ .join("\u0002");
7574
+ }
7575
+
7576
+ function tocInlineSignature(children: readonly InlineNode[]): string {
7577
+ return children
7578
+ .map((child) => {
7579
+ switch (child.type) {
7580
+ case "text":
7581
+ return `t:${child.text}`;
7582
+ case "tab":
7583
+ return "tab";
7584
+ case "hard_break":
7585
+ return "br";
7586
+ case "hyperlink":
7587
+ return `link:${child.href}:${tocInlineSignature(child.children)}`;
7588
+ case "field":
7589
+ return `field:${child.fieldFamily ?? ""}:${child.refreshStatus ?? ""}:${child.instruction}:${tocInlineSignature(child.children)}`;
7590
+ default:
7591
+ return child.type;
7592
+ }
7593
+ })
7594
+ .join("\u0001");
7595
+ }
7596
+
7272
7597
  function refreshBlocksWithCursor(
7273
7598
  blocks: readonly BlockNode[],
7274
7599
  visitField: (field: FieldNode, range: { from: number; to: number }) => FieldNode,
@@ -205,3 +205,52 @@ export function formatPageNumber(n: number, format: string | undefined): string
205
205
  return String(n);
206
206
  }
207
207
  }
208
+
209
+ export interface ChapterPageNumberFormatOptions {
210
+ format?: string;
211
+ chapterNumber?: string;
212
+ chapterSeparator?: string;
213
+ }
214
+
215
+ /**
216
+ * Format a PAGE/PAGEREF value using section page-numbering settings.
217
+ *
218
+ * `chapStyle` names the heading level/style whose numbering contributes the
219
+ * chapter marker; pagination resolves that marker per page and stores it as
220
+ * `chapterNumber`. When no marker is available, the display degrades to the
221
+ * normal page number instead of inventing a chapter prefix.
222
+ */
223
+ export function formatPageNumberWithChapter(
224
+ n: number,
225
+ options: ChapterPageNumberFormatOptions | undefined,
226
+ ): string {
227
+ const pageNumber = formatPageNumber(n, options?.format);
228
+ const chapterNumber = normalizeChapterNumber(options?.chapterNumber);
229
+ if (!chapterNumber) {
230
+ return pageNumber;
231
+ }
232
+ return `${chapterNumber}${chapterSeparatorToText(options?.chapterSeparator)}${pageNumber}`;
233
+ }
234
+
235
+ function normalizeChapterNumber(value: string | undefined): string | undefined {
236
+ const trimmed = value?.trim();
237
+ return trimmed && trimmed.length > 0 ? trimmed : undefined;
238
+ }
239
+
240
+ function chapterSeparatorToText(value: string | undefined): string {
241
+ switch (value) {
242
+ case "period":
243
+ return ".";
244
+ case "colon":
245
+ return ":";
246
+ case "emDash":
247
+ return "—";
248
+ case "enDash":
249
+ return "–";
250
+ case "hyphen":
251
+ case undefined:
252
+ return "-";
253
+ default:
254
+ return "-";
255
+ }
256
+ }
@@ -15,7 +15,6 @@ import type {
15
15
  } from "../../../model/canonical-document.ts";
16
16
  import { resolvePageFieldDisplayText } from "../../layout/resolve-page-fields.ts";
17
17
  import type { RuntimePageGraph, RuntimePageNode } from "../../layout/page-graph.ts";
18
- import { formatPageNumber } from "./page-number-format.ts";
19
18
 
20
19
  /**
21
20
  * Layer-03 alias for the layout module's `RuntimePageGraph`. Exists so
@@ -214,18 +213,27 @@ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
214
213
  // preserve the existing display text unchanged.
215
214
  return undefined;
216
215
 
217
- case "NOTEREF":
218
- // NOTEREF is classified as SupportedFieldFamily (see
219
- // `src/model/canonical-document.ts::SupportedFieldFamily`) but
220
- // is NOT implemented in the resolver yet. Returning
221
- // `refreshStatus: "unresolvable"` is the honest answer — the
222
- // caller knows the family is recognized but the runtime has
223
- // no resolution path. This distinguishes them from TOC
224
- // (intentional `undefined` = preserve existing display) and
225
- // from PreserveOnlyFieldFamily (parse-only, no refresh slot).
226
- // Tracked in L03 audit 2026-04-22 as Task-4 follow-up: implement
227
- // NOTEREF via footnote/endnote anchor lookup.
228
- return { displayText: "", refreshStatus: "unresolvable" };
216
+ case "NOTEREF": {
217
+ if (!entry.fieldTarget) {
218
+ return { displayText: "", refreshStatus: "unresolvable" };
219
+ }
220
+ const bookmark = bookmarkMap.get(entry.fieldTarget);
221
+ if (!bookmark) {
222
+ return { displayText: "", refreshStatus: "unresolvable" };
223
+ }
224
+ const paragraph = findParagraphByIndex(contentRoot, bookmark.paragraphIndex);
225
+ if (!paragraph) {
226
+ return { displayText: "", refreshStatus: "unresolvable" };
227
+ }
228
+ const noteText = resolveNoteReferenceText(paragraph, bookmark.bookmarkId);
229
+ return noteText !== undefined
230
+ ? {
231
+ displayText: noteText,
232
+ refreshStatus: "current",
233
+ ...(entry.switches?.hyperlink ? { asHyperlink: true as const } : {}),
234
+ }
235
+ : { displayText: "", refreshStatus: "unresolvable" };
236
+ }
229
237
 
230
238
  default:
231
239
  return undefined;
@@ -284,33 +292,7 @@ function resolvePageGraphFieldText(
284
292
  page: RuntimePageNode,
285
293
  graph: RuntimePageGraph,
286
294
  ): string {
287
- switch (family) {
288
- case "PAGE":
289
- return formatPageNumber(
290
- page.stories.displayPageNumber,
291
- page.layout?.pageNumbering?.format,
292
- );
293
- case "NUMPAGES":
294
- return String(graph.contentPageCount);
295
- case "SECTIONPAGES":
296
- return formatPageNumber(
297
- countContentPagesInSection(graph, page.sectionIndex),
298
- page.layout?.pageNumbering?.format,
299
- );
300
- }
301
- }
302
-
303
- function countContentPagesInSection(
304
- graph: RuntimePageGraph,
305
- sectionIndex: number,
306
- ): number {
307
- let count = 0;
308
- for (const page of graph.pages) {
309
- if (page.sectionIndex === sectionIndex && !page.isBlankFiller) {
310
- count += 1;
311
- }
312
- }
313
- return count;
295
+ return resolvePageFieldDisplayText(family, "", { page, graph });
314
296
  }
315
297
 
316
298
  /**
@@ -411,6 +393,45 @@ function resolveRefText(
411
393
  return result;
412
394
  }
413
395
 
396
+ function findParagraphByIndex(
397
+ root: DocumentContainerNode,
398
+ paragraphIndex: number,
399
+ ): ParagraphNode | undefined {
400
+ let index = -1;
401
+ let result: ParagraphNode | undefined;
402
+ walkParagraphs(root, (para) => {
403
+ if (result !== undefined) return;
404
+ index += 1;
405
+ if (index === paragraphIndex) {
406
+ result = para;
407
+ }
408
+ });
409
+ return result;
410
+ }
411
+
412
+ function resolveNoteReferenceText(
413
+ paragraph: ParagraphNode,
414
+ bookmarkId: string,
415
+ ): string | undefined {
416
+ let inside = false;
417
+ for (const child of paragraph.children) {
418
+ if (child.type === "bookmark_start" && child.bookmarkId === bookmarkId) {
419
+ inside = true;
420
+ continue;
421
+ }
422
+ if (child.type === "bookmark_end" && child.bookmarkId === bookmarkId) {
423
+ break;
424
+ }
425
+ if (!inside) {
426
+ continue;
427
+ }
428
+ if (child.type === "footnote_ref") {
429
+ return child.noteId;
430
+ }
431
+ }
432
+ return undefined;
433
+ }
434
+
414
435
  /**
415
436
  * Resolve a STYLEREF field by finding the first paragraph whose `styleId`
416
437
  * matches `styleId` and returning its flattened text (trimmed).
@@ -56,7 +56,10 @@ import {
56
56
  type RuntimePageGraph,
57
57
  type RuntimePageNode,
58
58
  } from "./page-graph.ts";
59
- import { projectSurfaceBlocksToPageFragments } from "./project-block-fragments.ts";
59
+ import {
60
+ projectLineBoxesForPageFragments,
61
+ projectSurfaceBlocksToPageFragments,
62
+ } from "./project-block-fragments.ts";
60
63
  import {
61
64
  resolvePageStories,
62
65
  resolveTotalPageCount,
@@ -466,6 +469,12 @@ export function createLayoutEngine(
466
469
  pages,
467
470
  pageStack.splits,
468
471
  pageStack.columnByBlockIdByPageIndex,
472
+ pageStack.fragmentMeasurementsByPageIndex,
473
+ );
474
+ const lineBoxesByPageIndex = projectLineBoxesForPageFragments(
475
+ pages,
476
+ bodyFragmentsByPageIndex,
477
+ pageStack.fragmentMeasurementsByPageIndex,
469
478
  );
470
479
  // P8.1b — merge per-note fragments (regionKind: "footnote-area") into the
471
480
  // main fragments map so buildPageGraph sees them alongside body fragments.
@@ -479,6 +488,7 @@ export function createLayoutEngine(
479
488
  sections,
480
489
  stories,
481
490
  fragmentsByPageIndex,
491
+ lineBoxesByPageIndex,
482
492
  noteAllocationsByPageIndex: pageStack.noteAllocationsByPageIndex,
483
493
  });
484
494
 
@@ -638,6 +648,12 @@ export function createLayoutEngine(
638
648
  freshSnapshotsToRebuild,
639
649
  freshResult.splits,
640
650
  freshResult.columnByBlockIdByPageIndex,
651
+ freshResult.fragmentMeasurementsByPageIndex,
652
+ );
653
+ const freshLineBoxesByPageIndex = projectLineBoxesForPageFragments(
654
+ freshSnapshotsToRebuild,
655
+ freshBodyFragmentsByPageIndex,
656
+ freshResult.fragmentMeasurementsByPageIndex,
641
657
  );
642
658
  // P8.1b — merge per-note fragments into the fresh fragments map.
643
659
  const freshFragmentsByPageIndex = new Map(freshBodyFragmentsByPageIndex);
@@ -651,6 +667,7 @@ export function createLayoutEngine(
651
667
  sections,
652
668
  stories: freshStories,
653
669
  fragmentsByPageIndex: freshFragmentsByPageIndex,
670
+ lineBoxesByPageIndex: freshLineBoxesByPageIndex,
654
671
  noteAllocationsByPageIndex: freshResult.noteAllocationsByPageIndex,
655
672
  });
656
673
  const freshNodes = freshGraph.pages;