@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -1,4 +1,5 @@
1
1
  import { twip } from "./twip.ts";
2
+ import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
2
3
 
3
4
  interface TableWidthLike {
4
5
  value: number;
@@ -233,31 +234,31 @@ function stripKnownProperties(xml: string, stripSpec: PropertyStripSpec): string
233
234
  function buildTablePropertiesInnerXml(table: TablePropertiesLike): string {
234
235
  const children: string[] = [];
235
236
  if (table.styleId) {
236
- children.push(`<w:tblStyle w:val="${escapeAttribute(table.styleId)}"/>`);
237
+ children.push(`<w:tblStyle w:val="${escapeXmlAttribute(table.styleId)}"/>`);
237
238
  }
238
239
  if (table.width) {
239
240
  children.push(serializeWidth("tblW", table.width));
240
241
  }
241
242
  if (table.alignment) {
242
- children.push(`<w:jc w:val="${escapeAttribute(table.alignment)}"/>`);
243
+ children.push(`<w:jc w:val="${escapeXmlAttribute(table.alignment)}"/>`);
243
244
  }
244
245
  if (table.indent) {
245
- children.push(`<w:tblInd w:w="${table.indent.value}" w:type="${escapeAttribute(table.indent.type)}"/>`);
246
+ children.push(`<w:tblInd w:w="${table.indent.value}" w:type="${escapeXmlAttribute(table.indent.type)}"/>`);
246
247
  }
247
248
  if (table.layoutMode) {
248
- children.push(`<w:tblLayout w:type="${escapeAttribute(table.layoutMode)}"/>`);
249
+ children.push(`<w:tblLayout w:type="${escapeXmlAttribute(table.layoutMode)}"/>`);
249
250
  }
250
251
  if (table.cellSpacing) {
251
- children.push(`<w:tblCellSpacing w:w="${table.cellSpacing.value}" w:type="${escapeAttribute(table.cellSpacing.type)}"/>`);
252
+ children.push(`<w:tblCellSpacing w:w="${table.cellSpacing.value}" w:type="${escapeXmlAttribute(table.cellSpacing.type)}"/>`);
252
253
  }
253
254
  if (table.bidiVisual !== undefined) {
254
255
  children.push(table.bidiVisual ? `<w:bidiVisual/>` : `<w:bidiVisual w:val="0"/>`);
255
256
  }
256
257
  if (table.caption !== undefined) {
257
- children.push(`<w:tblCaption w:val="${escapeAttribute(table.caption)}"/>`);
258
+ children.push(`<w:tblCaption w:val="${escapeXmlAttribute(table.caption)}"/>`);
258
259
  }
259
260
  if (table.description !== undefined) {
260
- children.push(`<w:tblDescription w:val="${escapeAttribute(table.description)}"/>`);
261
+ children.push(`<w:tblDescription w:val="${escapeXmlAttribute(table.description)}"/>`);
261
262
  }
262
263
  if (table.floating) {
263
264
  const floatingXml = serializeTableFloating(table.floating);
@@ -286,11 +287,11 @@ function buildTablePropertiesInnerXml(table: TablePropertiesLike): string {
286
287
 
287
288
  function serializeTableFloating(floating: TableFloatingPropertiesLike): string {
288
289
  const attrs: string[] = [];
289
- if (floating.horizontalAnchor) attrs.push(`w:horzAnchor="${escapeAttribute(floating.horizontalAnchor)}"`);
290
- if (floating.verticalAnchor) attrs.push(`w:vertAnchor="${escapeAttribute(floating.verticalAnchor)}"`);
291
- if (floating.horizontalAlign) attrs.push(`w:tblpXSpec="${escapeAttribute(floating.horizontalAlign)}"`);
290
+ if (floating.horizontalAnchor) attrs.push(`w:horzAnchor="${escapeXmlAttribute(floating.horizontalAnchor)}"`);
291
+ if (floating.verticalAnchor) attrs.push(`w:vertAnchor="${escapeXmlAttribute(floating.verticalAnchor)}"`);
292
+ if (floating.horizontalAlign) attrs.push(`w:tblpXSpec="${escapeXmlAttribute(floating.horizontalAlign)}"`);
292
293
  if (floating.horizontalOffset !== undefined) attrs.push(`w:tblpX="${floating.horizontalOffset}"`);
293
- if (floating.verticalAlign) attrs.push(`w:tblpYSpec="${escapeAttribute(floating.verticalAlign)}"`);
294
+ if (floating.verticalAlign) attrs.push(`w:tblpYSpec="${escapeXmlAttribute(floating.verticalAlign)}"`);
294
295
  if (floating.verticalOffset !== undefined) attrs.push(`w:tblpY="${floating.verticalOffset}"`);
295
296
  if (floating.leftFromText !== undefined) attrs.push(`w:leftFromText="${floating.leftFromText}"`);
296
297
  if (floating.rightFromText !== undefined) attrs.push(`w:rightFromText="${floating.rightFromText}"`);
@@ -306,7 +307,7 @@ function serializeTableFloating(floating: TableFloatingPropertiesLike): string {
306
307
  function buildTableRowPropertiesInnerXml(row: TableRowPropertiesLike): string {
307
308
  const children: string[] = [];
308
309
  if (row.cnfStyle) {
309
- children.push(`<w:cnfStyle w:val="${escapeAttribute(row.cnfStyle)}"/>`);
310
+ children.push(`<w:cnfStyle w:val="${escapeXmlAttribute(row.cnfStyle)}"/>`);
310
311
  }
311
312
  if (row.gridBefore !== undefined) {
312
313
  children.push(`<w:gridBefore w:val="${twip(row.gridBefore)}"/>`);
@@ -328,7 +329,7 @@ function buildTableRowPropertiesInnerXml(row: TableRowPropertiesLike): string {
328
329
  children.push(row.cantSplit ? `<w:cantSplit/>` : `<w:cantSplit w:val="0"/>`);
329
330
  }
330
331
  if (row.height !== undefined) {
331
- const hRuleAttr = row.heightRule ? ` w:hRule="${escapeAttribute(row.heightRule)}"` : "";
332
+ const hRuleAttr = row.heightRule ? ` w:hRule="${escapeXmlAttribute(row.heightRule)}"` : "";
332
333
  children.push(`<w:trHeight w:val="${twip(row.height)}"${hRuleAttr}/>`);
333
334
  }
334
335
  // ST_OnOff element (A.3):
@@ -343,7 +344,7 @@ function buildTableRowPropertiesInnerXml(row: TableRowPropertiesLike): string {
343
344
  children.push(`<w:tblHeader w:val="false"/>`);
344
345
  }
345
346
  if (row.horizontalAlignment) {
346
- children.push(`<w:jc w:val="${escapeAttribute(row.horizontalAlignment)}"/>`);
347
+ children.push(`<w:jc w:val="${escapeXmlAttribute(row.horizontalAlignment)}"/>`);
347
348
  }
348
349
  return children.join("");
349
350
  }
@@ -351,7 +352,7 @@ function buildTableRowPropertiesInnerXml(row: TableRowPropertiesLike): string {
351
352
  function buildTableCellPropertiesInnerXml(cell: TableCellPropertiesLike): string {
352
353
  const children: string[] = [];
353
354
  if (cell.cnfStyle) {
354
- children.push(`<w:cnfStyle w:val="${escapeAttribute(cell.cnfStyle)}"/>`);
355
+ children.push(`<w:cnfStyle w:val="${escapeXmlAttribute(cell.cnfStyle)}"/>`);
355
356
  }
356
357
  if (cell.width) {
357
358
  children.push(serializeWidth("tcW", cell.width));
@@ -391,10 +392,10 @@ function buildTableCellPropertiesInnerXml(cell: TableCellPropertiesLike): string
391
392
  children.push(cell.fitText ? `<w:tcFitText/>` : `<w:tcFitText w:val="0"/>`);
392
393
  }
393
394
  if (cell.textDirection) {
394
- children.push(`<w:textDirection w:val="${escapeAttribute(cell.textDirection)}"/>`);
395
+ children.push(`<w:textDirection w:val="${escapeXmlAttribute(cell.textDirection)}"/>`);
395
396
  }
396
397
  if (cell.verticalAlign) {
397
- children.push(`<w:vAlign w:val="${escapeAttribute(cell.verticalAlign)}"/>`);
398
+ children.push(`<w:vAlign w:val="${escapeXmlAttribute(cell.verticalAlign)}"/>`);
398
399
  }
399
400
  return children.join("");
400
401
  }
@@ -402,7 +403,7 @@ function buildTableCellPropertiesInnerXml(cell: TableCellPropertiesLike): string
402
403
  function serializeWidth(elementName: "tblW" | "tcW", width: TableWidthLike): string {
403
404
  // OOXML allows w:w to be percentage (pct) or twentieths-of-a-percent too, but
404
405
  // both are integer-typed in the schema. Always round at the authoring edge.
405
- return `<w:${elementName} w:w="${twip(width.value)}" w:type="${escapeAttribute(width.type)}"/>`;
406
+ return `<w:${elementName} w:w="${twip(width.value)}" w:type="${escapeXmlAttribute(width.type)}"/>`;
406
407
  }
407
408
 
408
409
  function serializeBorders(borders: TableBordersLike): string {
@@ -415,10 +416,10 @@ function serializeBorders(borders: TableBordersLike): string {
415
416
 
416
417
  function serializeBorderSpec(elementName: string, border: BorderSpecLike): string {
417
418
  const attrs: string[] = [];
418
- if (border.value) attrs.push(`w:val="${escapeAttribute(border.value)}"`);
419
+ if (border.value) attrs.push(`w:val="${escapeXmlAttribute(border.value)}"`);
419
420
  if (border.size !== undefined) attrs.push(`w:sz="${twip(border.size)}"`);
420
421
  if (border.space !== undefined) attrs.push(`w:space="${twip(border.space)}"`);
421
- if (border.color) attrs.push(`w:color="${escapeAttribute(border.color)}"`);
422
+ if (border.color) attrs.push(`w:color="${escapeXmlAttribute(border.color)}"`);
422
423
  return attrs.length > 0 ? `<w:${elementName} ${attrs.join(" ")}/>` : "";
423
424
  }
424
425
 
@@ -434,7 +435,7 @@ function serializeTableCellMargins(margins: TableCellMarginsLike): string {
434
435
  function serializeTableLook(tblLook: TableLookLike): string {
435
436
  const attrs: string[] = [];
436
437
  if (tblLook.val) {
437
- attrs.push(`w:val="${escapeAttribute(tblLook.val)}"`);
438
+ attrs.push(`w:val="${escapeXmlAttribute(tblLook.val)}"`);
438
439
  }
439
440
  for (const [key, attr] of [
440
441
  ["firstRow", "w:firstRow"],
@@ -457,16 +458,8 @@ function serializeTableLook(tblLook: TableLookLike): string {
457
458
 
458
459
  function serializeCellShading(shading: CellShadingLike): string {
459
460
  const attrs: string[] = [];
460
- if (shading.val) attrs.push(`w:val="${escapeAttribute(shading.val)}"`);
461
- if (shading.color) attrs.push(`w:color="${escapeAttribute(shading.color)}"`);
462
- if (shading.fill) attrs.push(`w:fill="${escapeAttribute(shading.fill)}"`);
461
+ if (shading.val) attrs.push(`w:val="${escapeXmlAttribute(shading.val)}"`);
462
+ if (shading.color) attrs.push(`w:color="${escapeXmlAttribute(shading.color)}"`);
463
+ if (shading.fill) attrs.push(`w:fill="${escapeXmlAttribute(shading.fill)}"`);
463
464
  return attrs.length > 0 ? `<w:shd ${attrs.join(" ")}/>` : "";
464
465
  }
465
-
466
- function escapeAttribute(value: string): string {
467
- return value
468
- .replace(/&/gu, "&amp;")
469
- .replace(/"/gu, "&quot;")
470
- .replace(/</gu, "&lt;")
471
- .replace(/>/gu, "&gt;");
472
- }
@@ -0,0 +1,40 @@
1
+ import type {
2
+ ExternalCustody,
3
+ ExternalCustodyResolver,
4
+ ExternalCustodyRestoredContent,
5
+ } from "../../api/external-custody-types.ts";
6
+
7
+ export type ReimportResult =
8
+ | { outcome: "skipped" /* no custody attached */ }
9
+ | { outcome: "resolver_missing" /* custody present, no host resolver */ }
10
+ | { outcome: "tampered" /* incoming doc body hash ≠ originContentHash */ }
11
+ | { outcome: "deferred" /* resolver returned undefined; keep custody for retry */ }
12
+ | { outcome: "restored"; content: ExternalCustodyRestoredContent };
13
+
14
+ export interface MaybeRestoreArgs {
15
+ custody: ExternalCustody | undefined;
16
+ resolver: ExternalCustodyResolver | undefined;
17
+ /** sha256:{hex} of canonicalized word/document.xml of the incoming docx. */
18
+ incomingDocxHash: string;
19
+ }
20
+
21
+ /**
22
+ * Re-import pipeline. Classifies the call into a closed outcome set so
23
+ * the caller can decide how to merge the restored content back into
24
+ * the runtime state.
25
+ */
26
+ export async function maybeRestoreFromExternalCustody(
27
+ args: MaybeRestoreArgs,
28
+ ): Promise<ReimportResult> {
29
+ if (!args.custody) return { outcome: "skipped" };
30
+ if (!args.resolver) return { outcome: "resolver_missing" };
31
+ if (args.custody.originContentHash !== args.incomingDocxHash) {
32
+ return { outcome: "tampered" };
33
+ }
34
+ const restored = await args.resolver.restore({
35
+ custodyId: args.custody.custodyId,
36
+ originContentHash: args.custody.originContentHash,
37
+ });
38
+ if (!restored) return { outcome: "deferred" };
39
+ return { outcome: "restored", content: restored };
40
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Load scheduler — main-thread time-slicing primitive for the staged
3
+ * document-load pipeline.
4
+ *
5
+ * The loader calls `scheduler.yield()` between parse stages so the browser
6
+ * can paint, service input, and run React commits. `scheduleIdle(task)`
7
+ * queues low-priority work (e.g., sub-part hydration, compatibility report)
8
+ * for post-skeleton execution.
9
+ *
10
+ * Backend cascade (first available wins):
11
+ * 1. `globalThis.scheduler.yield()` — native browser API (Chrome 129+, Edge).
12
+ * 2. `MessageChannel.postMessage` — universal DOM fallback, ~0.1ms per yield.
13
+ * 3. `setTimeout(0)` — last-resort fallback.
14
+ * 4. `sync` — SSR / Node test harness. `yield()` resolves immediately;
15
+ * `scheduleIdle` runs inline.
16
+ *
17
+ * The `sync` backend is selected when `typeof document === "undefined"` so
18
+ * existing Node-side tests drive the staged pipeline with byte-identical
19
+ * behavior to the eager pipeline (no real yielding, no idle deferral).
20
+ */
21
+
22
+ export type LoadSchedulerBackend =
23
+ | "scheduler-api"
24
+ | "message-channel"
25
+ | "timeout"
26
+ | "sync";
27
+
28
+ export interface LoadScheduler {
29
+ readonly backend: LoadSchedulerBackend;
30
+ /** Yield to the browser. Resolves on next scheduled task / microtask. */
31
+ yield(): Promise<void>;
32
+ /** Schedule low-priority work for post-skeleton execution. */
33
+ scheduleIdle(task: () => void): void;
34
+ /** Cancel pending idle tasks. Must be called on unmount / dispose. */
35
+ dispose(): void;
36
+ }
37
+
38
+ export interface CreateLoadSchedulerOptions {
39
+ /** Frame deadline in ms. Default 4ms (keeps browser at 60fps). */
40
+ frameDeadlineMs?: number;
41
+ /**
42
+ * Force a specific backend (test-only). When omitted, the scheduler
43
+ * detects the best available backend at construction time.
44
+ */
45
+ backendOverride?: LoadSchedulerBackend;
46
+ }
47
+
48
+ const DEFAULT_FRAME_DEADLINE_MS = 4;
49
+
50
+ /**
51
+ * Returns true when the elapsed time since `lastYieldAt` exceeds the
52
+ * scheduler's frame deadline. Callers use this inside tight loops to decide
53
+ * when to `await scheduler.yield()`.
54
+ */
55
+ export function shouldYield(
56
+ scheduler: LoadScheduler & { readonly frameDeadlineMs?: number },
57
+ lastYieldAt: number,
58
+ ): boolean {
59
+ const now = typeof performance !== "undefined" ? performance.now() : Date.now();
60
+ const deadline = scheduler.frameDeadlineMs ?? DEFAULT_FRAME_DEADLINE_MS;
61
+ return now - lastYieldAt >= deadline;
62
+ }
63
+
64
+ /**
65
+ * Returns a monotonic timestamp suitable for `shouldYield` comparisons.
66
+ */
67
+ export function nowMs(): number {
68
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
69
+ }
70
+
71
+ interface InternalScheduler extends LoadScheduler {
72
+ readonly frameDeadlineMs: number;
73
+ }
74
+
75
+ export function createLoadScheduler(
76
+ options: CreateLoadSchedulerOptions = {},
77
+ ): LoadScheduler {
78
+ const frameDeadlineMs = options.frameDeadlineMs ?? DEFAULT_FRAME_DEADLINE_MS;
79
+ const backend = options.backendOverride ?? detectBackend();
80
+
81
+ switch (backend) {
82
+ case "scheduler-api":
83
+ return createSchedulerApiBackend(frameDeadlineMs);
84
+ case "message-channel":
85
+ return createMessageChannelBackend(frameDeadlineMs);
86
+ case "timeout":
87
+ return createTimeoutBackend(frameDeadlineMs);
88
+ case "sync":
89
+ return createSyncBackend(frameDeadlineMs);
90
+ }
91
+ }
92
+
93
+ function detectBackend(): LoadSchedulerBackend {
94
+ if (typeof document === "undefined") {
95
+ return "sync";
96
+ }
97
+ const g = globalThis as unknown as {
98
+ scheduler?: { yield?: () => Promise<void> };
99
+ };
100
+ if (typeof g.scheduler?.yield === "function") {
101
+ return "scheduler-api";
102
+ }
103
+ if (typeof MessageChannel !== "undefined") {
104
+ return "message-channel";
105
+ }
106
+ return "timeout";
107
+ }
108
+
109
+ function createSchedulerApiBackend(frameDeadlineMs: number): InternalScheduler {
110
+ const g = globalThis as unknown as {
111
+ scheduler: { yield: () => Promise<void> };
112
+ };
113
+ const pendingIdleHandles = new Set<number>();
114
+ return {
115
+ backend: "scheduler-api",
116
+ frameDeadlineMs,
117
+ yield: () => g.scheduler.yield(),
118
+ scheduleIdle(task) {
119
+ const handle = scheduleIdleCallback(task, pendingIdleHandles);
120
+ pendingIdleHandles.add(handle);
121
+ },
122
+ dispose() {
123
+ disposeIdleHandles(pendingIdleHandles);
124
+ },
125
+ };
126
+ }
127
+
128
+ function createMessageChannelBackend(frameDeadlineMs: number): InternalScheduler {
129
+ const pendingIdleHandles = new Set<number>();
130
+ return {
131
+ backend: "message-channel",
132
+ frameDeadlineMs,
133
+ yield() {
134
+ return new Promise<void>((resolve) => {
135
+ const channel = new MessageChannel();
136
+ channel.port1.onmessage = () => {
137
+ channel.port1.close();
138
+ channel.port2.close();
139
+ resolve();
140
+ };
141
+ channel.port2.postMessage(null);
142
+ });
143
+ },
144
+ scheduleIdle(task) {
145
+ const handle = scheduleIdleCallback(task, pendingIdleHandles);
146
+ pendingIdleHandles.add(handle);
147
+ },
148
+ dispose() {
149
+ disposeIdleHandles(pendingIdleHandles);
150
+ },
151
+ };
152
+ }
153
+
154
+ function createTimeoutBackend(frameDeadlineMs: number): InternalScheduler {
155
+ const pendingIdleHandles = new Set<number>();
156
+ return {
157
+ backend: "timeout",
158
+ frameDeadlineMs,
159
+ yield() {
160
+ return new Promise<void>((resolve) => {
161
+ setTimeout(resolve, 0);
162
+ });
163
+ },
164
+ scheduleIdle(task) {
165
+ const handle = setTimeout(task, 0) as unknown as number;
166
+ pendingIdleHandles.add(handle);
167
+ },
168
+ dispose() {
169
+ for (const handle of pendingIdleHandles) {
170
+ clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
171
+ }
172
+ pendingIdleHandles.clear();
173
+ },
174
+ };
175
+ }
176
+
177
+ function createSyncBackend(frameDeadlineMs: number): InternalScheduler {
178
+ return {
179
+ backend: "sync",
180
+ frameDeadlineMs,
181
+ yield: () => Promise.resolve(),
182
+ scheduleIdle(task) {
183
+ task();
184
+ },
185
+ dispose() {
186
+ /* no-op */
187
+ },
188
+ };
189
+ }
190
+
191
+ type IdleHandle = number;
192
+
193
+ function scheduleIdleCallback(
194
+ task: () => void,
195
+ store: Set<IdleHandle>,
196
+ ): IdleHandle {
197
+ const g = globalThis as unknown as {
198
+ requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number;
199
+ cancelIdleCallback?: (handle: number) => void;
200
+ };
201
+ if (typeof g.requestIdleCallback === "function") {
202
+ const handle = g.requestIdleCallback(
203
+ () => {
204
+ store.delete(handle);
205
+ task();
206
+ },
207
+ { timeout: 50 },
208
+ );
209
+ return handle;
210
+ }
211
+ const handle = setTimeout(() => {
212
+ store.delete(handle as unknown as number);
213
+ task();
214
+ }, 0) as unknown as number;
215
+ return handle;
216
+ }
217
+
218
+ function disposeIdleHandles(store: Set<IdleHandle>): void {
219
+ const g = globalThis as unknown as {
220
+ cancelIdleCallback?: (handle: number) => void;
221
+ };
222
+ for (const handle of store) {
223
+ if (typeof g.cancelIdleCallback === "function") {
224
+ g.cancelIdleCallback(handle);
225
+ } else {
226
+ clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
227
+ }
228
+ }
229
+ store.clear();
230
+ }
@@ -36,6 +36,11 @@ import type {
36
36
  ParsedTableRowNode,
37
37
  } from "../ooxml/parse-main-document.ts";
38
38
  import { classifyFieldInstruction, buildFieldRegistry } from "../ooxml/parse-fields.ts";
39
+ import {
40
+ type LoadScheduler,
41
+ nowMs,
42
+ shouldYield,
43
+ } from "../load-scheduler.ts";
39
44
 
40
45
  export interface NormalizedTextDocument {
41
46
  content: DocumentRootNode;
@@ -115,6 +120,84 @@ export function normalizeParsedTextDocument(
115
120
  };
116
121
  }
117
122
 
123
+ /**
124
+ * Fastload P6: async sibling of `normalizeParsedTextDocument` that yields to
125
+ * the browser every {@link NORMALIZE_YIELD_STRIDE} top-level blocks when
126
+ * {@link shouldYield} fires against the scheduler's frame deadline. Shares
127
+ * the private normalizeBlocks / normalizeParagraph / normalizeInlineChildren
128
+ * helpers with the sync export — only the outermost block walk is duplicated.
129
+ *
130
+ * Byte-equivalent to the sync export on any corpus (fixture parity is asserted
131
+ * in `test/io/normalize-text-async.test.ts` across every F*.docx fixture).
132
+ */
133
+ const NORMALIZE_YIELD_STRIDE = 256;
134
+
135
+ export async function normalizeParsedTextDocumentAsync(
136
+ document: ParsedMainDocument,
137
+ packagePartName = "/word/document.xml",
138
+ scheduler: LoadScheduler,
139
+ options?: { styles?: import("../../model/canonical-document.ts").StylesCatalog },
140
+ ): Promise<NormalizedTextDocument> {
141
+ const state: NormalizationState = {
142
+ nextFragmentIndex: 1,
143
+ nextWarningIndex: 1,
144
+ nextDiagnosticIndex: 1,
145
+ cursor: 0,
146
+ media: {
147
+ items: {},
148
+ },
149
+ preservation: {
150
+ opaqueFragments: {},
151
+ packageParts: {},
152
+ },
153
+ diagnostics: {
154
+ warnings: [],
155
+ errors: [],
156
+ },
157
+ };
158
+
159
+ const children: BlockNode[] = [];
160
+ let previousParagraph = false;
161
+ let lastYieldAt = nowMs();
162
+
163
+ for (let i = 0; i < document.blocks.length; i += 1) {
164
+ const block = document.blocks[i];
165
+ const normalizedBlocks = normalizeBlocks(block, state, packagePartName);
166
+ for (const normalizedBlock of normalizedBlocks) {
167
+ if (previousParagraph && normalizedBlock.type === "paragraph") {
168
+ state.cursor += 1;
169
+ }
170
+ children.push(normalizedBlock);
171
+ previousParagraph = normalizedBlock.type === "paragraph";
172
+ }
173
+ if (
174
+ i > 0 &&
175
+ i % NORMALIZE_YIELD_STRIDE === 0 &&
176
+ shouldYield(scheduler, lastYieldAt)
177
+ ) {
178
+ await scheduler.yield();
179
+ lastYieldAt = nowMs();
180
+ }
181
+ }
182
+
183
+ const content: DocumentRootNode = { type: "doc", children };
184
+
185
+ const styles = options?.styles ?? { paragraphs: {}, characters: {}, tables: {} };
186
+ const fieldRegistry = buildFieldRegistry({ content, styles });
187
+ const hasFields = fieldRegistry.supported.length > 0 || fieldRegistry.preserveOnly.length > 0;
188
+
189
+ return {
190
+ content,
191
+ media: state.media,
192
+ preservation: state.preservation,
193
+ diagnostics: state.diagnostics,
194
+ ...(document.finalSectionProperties !== undefined
195
+ ? { finalSectionProperties: document.finalSectionProperties }
196
+ : {}),
197
+ ...(hasFields ? { fieldRegistry } : {}),
198
+ };
199
+ }
200
+
118
201
  function normalizeBlocks(
119
202
  block: ParsedBlockNode,
120
203
  state: NormalizationState,