docxodus 9.9.0 → 10.0.0

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 (111) hide show
  1. package/README.md +178 -0
  2. package/dist/canonical.d.ts +7 -0
  3. package/dist/canonical.d.ts.map +1 -0
  4. package/dist/canonical.js +56 -0
  5. package/dist/canonical.js.map +1 -0
  6. package/dist/docxodus.worker.d.ts +2 -1
  7. package/dist/docxodus.worker.d.ts.map +1 -1
  8. package/dist/docxodus.worker.js +244 -3
  9. package/dist/docxodus.worker.js.map +1 -1
  10. package/dist/editor.bundle.js +1914 -419
  11. package/dist/editor.d.ts +22 -2
  12. package/dist/editor.d.ts.map +1 -1
  13. package/dist/editor.js +94 -9
  14. package/dist/editor.js.map +1 -1
  15. package/dist/embed.bundle.js +2803 -469
  16. package/dist/embed.iife.js +2801 -467
  17. package/dist/export-assets.json +283 -0
  18. package/dist/export-browser.bundle.js +7990 -0
  19. package/dist/export-browser.d.ts +266 -0
  20. package/dist/export-browser.d.ts.map +1 -0
  21. package/dist/export-browser.js +2915 -0
  22. package/dist/export-browser.js.map +1 -0
  23. package/dist/export-resource-limits-v1.json +57 -0
  24. package/dist/font-contract.d.ts +150 -0
  25. package/dist/font-contract.d.ts.map +1 -0
  26. package/dist/font-contract.js +59 -0
  27. package/dist/font-contract.js.map +1 -0
  28. package/dist/font-runtime.d.ts +33 -0
  29. package/dist/font-runtime.d.ts.map +1 -0
  30. package/dist/font-runtime.js +1100 -0
  31. package/dist/font-runtime.js.map +1 -0
  32. package/dist/index.d.ts +62 -5
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +130 -16
  35. package/dist/index.js.map +1 -1
  36. package/dist/page-geometry.d.ts +8 -1
  37. package/dist/page-geometry.d.ts.map +1 -1
  38. package/dist/page-geometry.js +37 -11
  39. package/dist/page-geometry.js.map +1 -1
  40. package/dist/pagination.bundle.js +1827 -410
  41. package/dist/pagination.d.ts +249 -15
  42. package/dist/pagination.d.ts.map +1 -1
  43. package/dist/pagination.js +1942 -453
  44. package/dist/pagination.js.map +1 -1
  45. package/dist/react.d.ts +11 -4
  46. package/dist/react.d.ts.map +1 -1
  47. package/dist/react.js +38 -5
  48. package/dist/react.js.map +1 -1
  49. package/dist/render-report-v2.schema.json +1581 -0
  50. package/dist/ribbon-chrome.d.ts +1 -1
  51. package/dist/ribbon-chrome.d.ts.map +1 -1
  52. package/dist/ribbon-chrome.js +45 -0
  53. package/dist/ribbon-chrome.js.map +1 -1
  54. package/dist/ribbon.js +70 -0
  55. package/dist/ribbon.js.map +1 -1
  56. package/dist/session.bundle.js +695 -29
  57. package/dist/session.d.ts +134 -19
  58. package/dist/session.d.ts.map +1 -1
  59. package/dist/session.js +562 -25
  60. package/dist/session.js.map +1 -1
  61. package/dist/types.d.ts +1207 -16
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js.map +1 -1
  64. package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm +0 -0
  65. package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm.br +0 -0
  66. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
  67. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm.br +0 -0
  68. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  69. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  70. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  71. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  72. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  73. package/dist/wasm/_framework/System.Collections.Concurrent.wasm.br +0 -0
  74. package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
  75. package/dist/wasm/_framework/System.Collections.Immutable.wasm.br +0 -0
  76. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  77. package/dist/wasm/_framework/System.Collections.wasm.br +0 -0
  78. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  79. package/dist/wasm/_framework/System.IO.Compression.wasm.br +0 -0
  80. package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
  81. package/dist/wasm/_framework/System.IO.Packaging.wasm.br +0 -0
  82. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  83. package/dist/wasm/_framework/System.Linq.wasm.br +0 -0
  84. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  85. package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
  86. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  87. package/dist/wasm/_framework/System.Private.Uri.wasm.br +0 -0
  88. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  89. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
  90. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  91. package/dist/wasm/_framework/System.Private.Xml.wasm.br +0 -0
  92. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  93. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm.br +0 -0
  94. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  95. package/dist/wasm/_framework/System.Runtime.wasm.br +0 -0
  96. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  97. package/dist/wasm/_framework/System.Security.Cryptography.wasm.br +0 -0
  98. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  99. package/dist/wasm/_framework/System.Text.Json.wasm.br +0 -0
  100. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  101. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm.br +0 -0
  102. package/dist/wasm/_framework/dotnet.boot.js +21 -21
  103. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  104. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  105. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  106. package/dist/worker-proxy.bundle.js +196 -29
  107. package/dist/worker-proxy.d.ts +36 -3
  108. package/dist/worker-proxy.d.ts.map +1 -1
  109. package/dist/worker-proxy.js +186 -33
  110. package/dist/worker-proxy.js.map +1 -1
  111. package/package.json +22 -6
@@ -0,0 +1,2915 @@
1
+ /**
2
+ * UI-free browser materializer for deterministic, standalone paginated HTML.
3
+ *
4
+ * Conversion happens in the existing WASM worker. Layout happens in a private,
5
+ * attached browsing context so no caller styles, globals, or editor state can
6
+ * influence the artifact.
7
+ */
8
+ import limitsContractJson from "./export-resource-limits-v1.json";
9
+ import { assertWellFormedUnicode, canonicalJson } from "./canonical.js";
10
+ import { PaginationEngine } from "./pagination.js";
11
+ import { createWorkerDocxodus, workerErrorCode, } from "./worker-proxy.js";
12
+ import { CommentRenderMode, PaginationMode, } from "./types.js";
13
+ import { BrowserFontError, createBrowserFontTask, } from "./font-runtime.js";
14
+ export { fontFamilyKey, FONT_RESOLVER_CONTRACT_ID, FONT_RESOLVER_SCHEMA_VERSION, FONT_SUBSTITUTION_CONTRACT, FONT_SUBSTITUTION_CONTRACT_MATERIAL, FONT_SUBSTITUTION_CONTRACT_VERSION, normalizeFontFamilyName, } from "./font-contract.js";
15
+ export { inventoryDocumentFontRequests, parseCssFontFamily } from "./font-runtime.js";
16
+ const LIMITS_CONTRACT = limitsContractJson;
17
+ export const DEFAULT_EXPORT_RESOURCE_LIMITS = Object.freeze({ ...LIMITS_CONTRACT.defaults });
18
+ export const HARD_EXPORT_RESOURCE_LIMITS = Object.freeze({ ...LIMITS_CONTRACT.hardCeilings });
19
+ export const DEFAULT_EXPORT_TIMEOUT_MS = LIMITS_CONTRACT.timeoutMs.default;
20
+ export const HARD_EXPORT_TIMEOUT_MS = LIMITS_CONTRACT.timeoutMs.hardCeiling;
21
+ export class DocxodusExportError extends Error {
22
+ constructor(code, phase, message, remediation, options = {}) {
23
+ super(message);
24
+ this.name = "DocxodusExportError";
25
+ this.code = code;
26
+ this.phase = phase;
27
+ this.remediation = remediation;
28
+ this.detail = options.detail;
29
+ this.pending = options.pending;
30
+ this.partUri = options.partUri;
31
+ this.anchorId = options.anchorId;
32
+ this.resource = options.resource;
33
+ this.cause = options.cause;
34
+ this.report = options.report;
35
+ }
36
+ toJSON() {
37
+ return {
38
+ name: this.name,
39
+ code: this.code,
40
+ severity: "error",
41
+ phase: this.phase,
42
+ message: this.message,
43
+ remediation: this.remediation,
44
+ ...(this.detail === undefined ? {} : { detail: this.detail }),
45
+ ...(this.pending === undefined ? {} : { pending: [...this.pending] }),
46
+ ...(this.partUri === undefined ? {} : { partUri: this.partUri }),
47
+ ...(this.anchorId === undefined ? {} : { anchorId: this.anchorId }),
48
+ ...(this.resource === undefined ? {} : { resource: this.resource }),
49
+ ...(this.report === undefined ? {} : { report: this.report }),
50
+ };
51
+ }
52
+ }
53
+ const RECONSTRUCTIBLE_ERROR_CODES = new Set([
54
+ "invalid_argument",
55
+ "invalid_document",
56
+ "source_digest_mismatch",
57
+ "document_version_unrepresentable",
58
+ "conversion_failure",
59
+ "browser_launch_failure",
60
+ "resource_policy_failure",
61
+ "readiness_timeout",
62
+ "operation_cancelled",
63
+ "pagination_failure",
64
+ "pdf_write_failure",
65
+ "output_write_failure",
66
+ "output_verification_failure",
67
+ "resource_limit",
68
+ "unsupported_runtime",
69
+ "filesystem_failure",
70
+ ]);
71
+ const RECONSTRUCTIBLE_PHASES = new Set([
72
+ "input_validation",
73
+ "package_preflight",
74
+ "browser_launch",
75
+ "wasm_initialization",
76
+ "docx_conversion",
77
+ "font_loading",
78
+ "image_decoding",
79
+ "chart_svg_materialization",
80
+ "pagination",
81
+ "running_story_placement",
82
+ "page_tree_stability",
83
+ "pdf_print",
84
+ "output_verification",
85
+ "output_write",
86
+ "filesystem_commit",
87
+ "cleanup",
88
+ ]);
89
+ /**
90
+ * Rebuilds a `DocxodusExportError` from a `DocxodusExportError.toJSON()`-shaped object that
91
+ * crossed a serialization boundary (a Node-backed `FontResolver` reaching the page as a
92
+ * Playwright binding, for instance), so the original code/phase/remediation survive instead
93
+ * of collapsing into a generic message string. Returns `undefined` for anything that isn't
94
+ * recognizably a serialized `DocxodusExportError`.
95
+ */
96
+ export function reconstructDocxodusExportError(value) {
97
+ if (!value || typeof value !== "object")
98
+ return undefined;
99
+ const record = value;
100
+ if (typeof record.code !== "string" || !RECONSTRUCTIBLE_ERROR_CODES.has(record.code)) {
101
+ return undefined;
102
+ }
103
+ if (typeof record.phase !== "string" || !RECONSTRUCTIBLE_PHASES.has(record.phase)) {
104
+ return undefined;
105
+ }
106
+ if (typeof record.message !== "string" || typeof record.remediation !== "string")
107
+ return undefined;
108
+ return new DocxodusExportError(record.code, record.phase, record.message, record.remediation, {
109
+ detail: typeof record.detail === "string" ? record.detail : undefined,
110
+ partUri: typeof record.partUri === "string" ? record.partUri : undefined,
111
+ anchorId: typeof record.anchorId === "string" ? record.anchorId : undefined,
112
+ resource: typeof record.resource === "string" ? record.resource : undefined,
113
+ });
114
+ }
115
+ class PageTreeInstabilityError extends Error {
116
+ constructor(message) {
117
+ super(message);
118
+ this.name = "PageTreeInstabilityError";
119
+ }
120
+ }
121
+ const REPORT_SCHEMA = "https://docxodus.dev/schemas/render/render-report/v2";
122
+ const TEXT_ENCODER = new TextEncoder();
123
+ const ALLOWED_REVIEW_PROFILES = new Set(["final", "original", "markup"]);
124
+ const ALLOWED_COMMENT_PROFILES = new Set(["hidden", "inline", "endnotes", "margin"]);
125
+ const ALLOWED_UNSUPPORTED_POLICIES = new Set(["warn", "strict"]);
126
+ const PACKAGE_LIMIT_FINDINGS = new Set([
127
+ "entry_count_limit_exceeded",
128
+ "entry_expansion_limit_exceeded",
129
+ "entry_uri_limit_exceeded",
130
+ "compression_ratio_limit_exceeded",
131
+ "total_expansion_limit_exceeded",
132
+ "xml_size_limit_exceeded",
133
+ ]);
134
+ const RUNTIME_ASSET_GRAPH_MAX_BYTES = 1024 * 1024;
135
+ const RUNTIME_ASSET_COUNT_MAX = 10000;
136
+ const RUNTIME_ASSET_BYTES_MAX = 64 * 1024 * 1024;
137
+ const RUNTIME_ASSET_TOTAL_BYTES_MAX = 1024 * 1024 * 1024;
138
+ function compareCodeUnits(left, right) {
139
+ return left < right ? -1 : left > right ? 1 : 0;
140
+ }
141
+ const STANDALONE_CSP = [
142
+ "default-src 'none'",
143
+ "base-uri 'none'",
144
+ "connect-src 'none'",
145
+ "font-src data:",
146
+ "form-action 'none'",
147
+ "frame-src 'none'",
148
+ "img-src data:",
149
+ "media-src data:",
150
+ "object-src 'none'",
151
+ "script-src 'none'",
152
+ "style-src 'unsafe-inline'",
153
+ ].join("; ");
154
+ const RENDER_CSP = `${STANDALONE_CSP}; navigate-to 'none'`;
155
+ function fail(code, phase, message, remediation, options = {}) {
156
+ throw new DocxodusExportError(code, phase, message, remediation, options);
157
+ }
158
+ function normalizeOptions(options) {
159
+ if (!options || typeof options !== "object") {
160
+ fail("invalid_argument", "input_validation", "Export options are required.", "Supply explicit reviewProfile and commentProfile values.");
161
+ }
162
+ if (!ALLOWED_REVIEW_PROFILES.has(options.reviewProfile)) {
163
+ fail("invalid_argument", "input_validation", "reviewProfile is invalid.", "Use final, original, or markup.");
164
+ }
165
+ if (!ALLOWED_COMMENT_PROFILES.has(options.commentProfile)) {
166
+ fail("invalid_argument", "input_validation", "commentProfile is invalid.", "Use hidden, inline, endnotes, or margin.");
167
+ }
168
+ const reviewProfileAlreadyApplied = options.reviewProfileAlreadyApplied ?? false;
169
+ if (typeof reviewProfileAlreadyApplied !== "boolean") {
170
+ fail("invalid_argument", "input_validation", "reviewProfileAlreadyApplied must be boolean.", "Omit it or pass true only for exact policy-derived final/original bytes.");
171
+ }
172
+ if (reviewProfileAlreadyApplied && options.reviewProfile === "markup") {
173
+ fail("invalid_argument", "input_validation", "reviewProfileAlreadyApplied is invalid with the markup profile.", "Use unchanged source bytes for markup, or choose final/original.");
174
+ }
175
+ const unsupportedContent = options.unsupportedContent ?? "warn";
176
+ if (!ALLOWED_UNSUPPORTED_POLICIES.has(unsupportedContent)) {
177
+ fail("invalid_argument", "input_validation", "unsupportedContent is invalid.", "Use warn or strict.");
178
+ }
179
+ const documentVersion = options.documentVersion ?? 0;
180
+ if (!Number.isSafeInteger(documentVersion) || documentVersion < 0) {
181
+ fail("document_version_unrepresentable", "input_validation", "documentVersion must be a non-negative JavaScript safe integer.", "Use a value between 0 and Number.MAX_SAFE_INTEGER.");
182
+ }
183
+ if (options.expectedSourceDigest !== undefined
184
+ && !/^[0-9a-f]{64}$/.test(options.expectedSourceDigest)) {
185
+ fail("invalid_argument", "input_validation", "expectedSourceDigest must be a lower-case SHA-256 hex digest.", "Supply exactly 64 lower-case hexadecimal characters.");
186
+ }
187
+ const title = options.title ?? "";
188
+ if (typeof title !== "string") {
189
+ fail("invalid_argument", "input_validation", "title must be a string.", "Supply a plain document title or omit it for the normative empty title.");
190
+ }
191
+ try {
192
+ assertWellFormedUnicode(title);
193
+ }
194
+ catch {
195
+ fail("invalid_argument", "input_validation", "title contains an unpaired UTF-16 surrogate.", "Supply well-formed Unicode text that can be encoded as strict UTF-8.");
196
+ }
197
+ if (options.strictFonts !== undefined && typeof options.strictFonts !== "boolean") {
198
+ fail("invalid_argument", "input_validation", "strictFonts must be boolean.", "Pass true or false.");
199
+ }
200
+ if (options.fontResolver !== undefined && typeof options.fontResolver !== "function") {
201
+ fail("invalid_argument", "input_validation", "fontResolver must be a function.", "Pass a FontResolver conforming to the versioned resolver contract, or omit it.");
202
+ }
203
+ if (options.signal !== undefined
204
+ && (typeof options.signal !== "object"
205
+ || typeof options.signal.addEventListener !== "function"
206
+ || typeof options.signal.removeEventListener !== "function"
207
+ || typeof options.signal.aborted !== "boolean")) {
208
+ fail("invalid_argument", "input_validation", "signal must be an AbortSignal.", "Pass a standards-compliant AbortSignal or omit it.");
209
+ }
210
+ let wasmBasePath;
211
+ try {
212
+ if (options.wasmBasePath !== undefined
213
+ && (typeof options.wasmBasePath !== "string" || options.wasmBasePath.length === 0)) {
214
+ throw new TypeError("empty or non-string path");
215
+ }
216
+ const resolved = new URL(options.wasmBasePath ?? "./wasm/", import.meta.url);
217
+ if (!new Set(["http:", "https:", "file:"]).has(resolved.protocol)) {
218
+ throw new TypeError("unsupported runtime URL scheme");
219
+ }
220
+ wasmBasePath = resolved.href;
221
+ }
222
+ catch {
223
+ fail("invalid_argument", "input_validation", "wasmBasePath must be a valid non-empty URL string.", "Point it only at the closed, hash-verified Docxodus runtime asset directory.");
224
+ }
225
+ const limits = { ...DEFAULT_EXPORT_RESOURCE_LIMITS };
226
+ if (options.limits !== undefined
227
+ && (!options.limits || typeof options.limits !== "object" || Array.isArray(options.limits))) {
228
+ fail("invalid_argument", "input_validation", "limits must be an object.", "Supply only lower integer values from ExportResourceLimits.");
229
+ }
230
+ for (const [name, value] of Object.entries(options.limits ?? {})) {
231
+ if (!(name in limits)) {
232
+ fail("invalid_argument", "input_validation", `Unknown export limit: ${name}.`, "Use a key from ExportResourceLimits.");
233
+ }
234
+ const key = name;
235
+ if (!Number.isSafeInteger(value) || value <= 0) {
236
+ fail("invalid_argument", "input_validation", `Export limit ${name} must be a positive safe integer.`, "Supply a positive integer no greater than the published default.");
237
+ }
238
+ if (value > DEFAULT_EXPORT_RESOURCE_LIMITS[key]) {
239
+ fail("invalid_argument", "input_validation", `Export limit ${name} may only lower the default.`, `Use ${DEFAULT_EXPORT_RESOURCE_LIMITS[key]} or less.`);
240
+ }
241
+ limits[key] = value;
242
+ }
243
+ const timeoutMs = options.timeoutMs ?? LIMITS_CONTRACT.timeoutMs.default;
244
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0
245
+ || timeoutMs > LIMITS_CONTRACT.timeoutMs.hardCeiling) {
246
+ fail("invalid_argument", "input_validation", "timeoutMs is outside the supported range.", `Use an integer from 1 through ${LIMITS_CONTRACT.timeoutMs.hardCeiling}.`);
247
+ }
248
+ return Object.freeze({
249
+ documentVersion,
250
+ expectedSourceDigest: options.expectedSourceDigest,
251
+ reviewProfile: options.reviewProfile,
252
+ reviewProfileAlreadyApplied,
253
+ commentProfile: options.commentProfile,
254
+ title,
255
+ unsupportedContent,
256
+ strictFonts: options.strictFonts ?? false,
257
+ ...(options.fontResolver ? { fontResolver: options.fontResolver } : {}),
258
+ timeoutMs,
259
+ limits: Object.freeze(limits),
260
+ wasmBasePath,
261
+ signal: options.signal,
262
+ });
263
+ }
264
+ async function ownedBytes(document, maximum, signal) {
265
+ if (signal?.aborted) {
266
+ fail("operation_cancelled", "input_validation", "Export was cancelled before input snapshotting.", "Retry with a non-aborted signal.");
267
+ }
268
+ if (document instanceof Uint8Array) {
269
+ enforceLimit(document.byteLength, maximum, "compressedDocxBytes", "input_validation");
270
+ return new Uint8Array(document);
271
+ }
272
+ if (typeof File !== "undefined" && document instanceof File) {
273
+ enforceLimit(document.size, maximum, "compressedDocxBytes", "input_validation");
274
+ const bytes = new Uint8Array(await document.arrayBuffer());
275
+ if (signal?.aborted) {
276
+ fail("operation_cancelled", "input_validation", "Export was cancelled while reading the input File.", "Retry with a non-aborted signal.");
277
+ }
278
+ enforceLimit(bytes.byteLength, maximum, "compressedDocxBytes", "input_validation");
279
+ return bytes;
280
+ }
281
+ fail("invalid_argument", "input_validation", "document must be a File or Uint8Array.", "Pass immutable DOCX bytes or a browser File.");
282
+ }
283
+ function monotonicNow() {
284
+ return globalThis.performance?.now() ?? Date.now();
285
+ }
286
+ async function runPhase(state, phase, pending, operation) {
287
+ state.phase = phase;
288
+ const started = monotonicNow();
289
+ if (state.signal?.aborted) {
290
+ fail("operation_cancelled", phase, `Export was cancelled during ${phase}.`, "Retry with a non-aborted signal.", { pending });
291
+ }
292
+ const remaining = state.deadline - monotonicNow();
293
+ if (remaining <= 0) {
294
+ fail("readiness_timeout", phase, `Export timed out during ${phase}.`, "Increase timeoutMs or remove the pending resource.", { pending });
295
+ }
296
+ let timer;
297
+ let abortListener;
298
+ try {
299
+ const timeout = new Promise((_, reject) => {
300
+ timer = setTimeout(() => reject(new DocxodusExportError("readiness_timeout", phase, `Export timed out during ${phase}.`, "Increase timeoutMs or remove the pending resource.", { pending })), remaining);
301
+ });
302
+ const cancellation = new Promise((_, reject) => {
303
+ if (!state.signal)
304
+ return;
305
+ abortListener = () => reject(new DocxodusExportError("operation_cancelled", phase, `Export was cancelled during ${phase}.`, "Retry with a non-aborted signal.", { pending }));
306
+ state.signal.addEventListener("abort", abortListener, { once: true });
307
+ });
308
+ const result = await Promise.race([Promise.resolve().then(operation), timeout, cancellation]);
309
+ // A synchronous DOM operation cannot be pre-empted by the timer because it
310
+ // blocks the event loop. Reject it immediately after control returns; hot
311
+ // pagination loops also invoke the cooperative checkpoint below.
312
+ if (state.signal?.aborted) {
313
+ fail("operation_cancelled", phase, `Export was cancelled during ${phase}.`, "Retry with a non-aborted signal.", { pending });
314
+ }
315
+ if (monotonicNow() >= state.deadline) {
316
+ fail("readiness_timeout", phase, `Export timed out during ${phase}.`, "Increase timeoutMs or remove the pending resource.", { pending });
317
+ }
318
+ state.readiness.push({
319
+ phase,
320
+ status: "complete",
321
+ elapsedMs: Math.max(0, monotonicNow() - started),
322
+ pending: [],
323
+ });
324
+ return result;
325
+ }
326
+ catch (error) {
327
+ state.readiness.push({
328
+ phase,
329
+ status: error instanceof DocxodusExportError && error.code === "operation_cancelled"
330
+ ? "cancelled"
331
+ : "failed",
332
+ elapsedMs: Math.max(0, monotonicNow() - started),
333
+ pending: [...pending],
334
+ });
335
+ throw error;
336
+ }
337
+ finally {
338
+ if (timer !== undefined)
339
+ clearTimeout(timer);
340
+ if (abortListener && state.signal)
341
+ state.signal.removeEventListener("abort", abortListener);
342
+ }
343
+ }
344
+ function utf8Bytes(value) {
345
+ return TEXT_ENCODER.encode(value);
346
+ }
347
+ function utf8ByteLength(value) {
348
+ let length = 0;
349
+ for (let index = 0; index < value.length; index++) {
350
+ const unit = value.charCodeAt(index);
351
+ if (unit < 0x80)
352
+ length++;
353
+ else if (unit < 0x800)
354
+ length += 2;
355
+ else if (unit >= 0xd800 && unit <= 0xdbff
356
+ && value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) {
357
+ length += 4;
358
+ index++;
359
+ }
360
+ else
361
+ length += 3;
362
+ }
363
+ return length;
364
+ }
365
+ function preflightConvertedHtml(source, options) {
366
+ enforceLimit(utf8ByteLength(source), options.limits.htmlOutputBytes, "htmlOutputBytes", "docx_conversion");
367
+ let prospectiveNodes = 2;
368
+ let inText = false;
369
+ for (let index = 0; index < source.length; index++) {
370
+ if (source[index] !== "<") {
371
+ if (!inText && !/\s/.test(source[index])) {
372
+ prospectiveNodes++;
373
+ inText = true;
374
+ }
375
+ continue;
376
+ }
377
+ inText = false;
378
+ const next = source[index + 1];
379
+ if (next && next !== "/" && next !== "!" && next !== "?")
380
+ prospectiveNodes++;
381
+ if (prospectiveNodes > options.limits.domNodes) {
382
+ fail("resource_limit", "docx_conversion", `domNodes limit exceeded before HTML attachment (${prospectiveNodes} > ${options.limits.domNodes}).`, "Use a smaller document or a lower-complexity conversion profile.");
383
+ }
384
+ }
385
+ }
386
+ function countDomNodes(document, maximum, phase) {
387
+ const walker = document.createTreeWalker(document, 0xffffffff);
388
+ let count = 0;
389
+ while (walker.nextNode()) {
390
+ count++;
391
+ if (count > maximum) {
392
+ fail("resource_limit", phase, `domNodes limit exceeded (${count} > ${maximum}).`, "Use a smaller document or a lower-complexity conversion profile.");
393
+ }
394
+ }
395
+ return count;
396
+ }
397
+ async function sha256(bytes) {
398
+ if (!globalThis.crypto?.subtle) {
399
+ fail("unsupported_runtime", "output_verification", "Web Crypto SHA-256 is unavailable.", "Run the exporter in a secure, standards-compliant browser context.");
400
+ }
401
+ // crypto.subtle.digest snapshots the BufferSource synchronously, so the view is safe to pass
402
+ // directly; copying first would double peak memory for ceiling-sized inputs. The cast only
403
+ // discharges TypeScript's SharedArrayBuffer union -- every buffer reaching here is a plain
404
+ // ArrayBuffer from fetch, TextEncoder, or an owned snapshot.
405
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
406
+ return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
407
+ }
408
+ async function boundedResponseBytes(response, maximum, label) {
409
+ const declared = response.headers.get("content-length");
410
+ if (declared !== null && /^(?:0|[1-9]\d*)$/.test(declared)
411
+ && BigInt(declared) > BigInt(maximum)) {
412
+ fail("unsupported_runtime", "wasm_initialization", `${label} exceeds its admitted byte length.`, "Deploy the closed, bounded runtime asset graph generated with this package.");
413
+ }
414
+ if (!response.body) {
415
+ const bytes = new Uint8Array(await response.arrayBuffer());
416
+ if (bytes.byteLength > maximum) {
417
+ fail("unsupported_runtime", "wasm_initialization", `${label} exceeds its admitted byte length.`, "Deploy the closed, bounded runtime asset graph generated with this package.");
418
+ }
419
+ return bytes;
420
+ }
421
+ const reader = response.body.getReader();
422
+ const chunks = [];
423
+ let total = 0;
424
+ try {
425
+ while (true) {
426
+ const next = await reader.read();
427
+ if (next.done)
428
+ break;
429
+ total += next.value.byteLength;
430
+ if (total > maximum) {
431
+ await reader.cancel();
432
+ fail("unsupported_runtime", "wasm_initialization", `${label} exceeds its admitted byte length.`, "Deploy the closed, bounded runtime asset graph generated with this package.");
433
+ }
434
+ chunks.push(next.value);
435
+ }
436
+ }
437
+ finally {
438
+ reader.releaseLock();
439
+ }
440
+ const bytes = new Uint8Array(total);
441
+ let offset = 0;
442
+ for (const chunk of chunks) {
443
+ bytes.set(chunk, offset);
444
+ offset += chunk.byteLength;
445
+ }
446
+ return bytes;
447
+ }
448
+ async function loadRuntimeAssetIdentity(wasmBasePath, signal) {
449
+ const manifestUrl = new URL("./export-assets.json", import.meta.url);
450
+ const response = await globalThis.fetch(manifestUrl, {
451
+ cache: "no-store",
452
+ credentials: "same-origin",
453
+ signal,
454
+ });
455
+ if (!response.ok) {
456
+ fail("unsupported_runtime", "wasm_initialization", `The runtime asset graph could not be loaded (${response.status}).`, "Deploy export-assets.json beside the browser export bundle.");
457
+ }
458
+ const graphBytes = await boundedResponseBytes(response, RUNTIME_ASSET_GRAPH_MAX_BYTES, "Runtime asset graph");
459
+ let graphText;
460
+ try {
461
+ graphText = new TextDecoder("utf-8", { fatal: true }).decode(graphBytes);
462
+ }
463
+ catch {
464
+ fail("unsupported_runtime", "wasm_initialization", "The runtime asset graph is not strict UTF-8.", "Regenerate export-assets.json without malformed byte sequences.");
465
+ }
466
+ const manifest = strictJsonParse(graphText, (detail) => fail("unsupported_runtime", "wasm_initialization", "The runtime asset graph is malformed.", "Deploy the versioned export-assets.json generated with this bundle.", { detail }));
467
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)
468
+ || Object.keys(manifest).some((key) => !["schema", "schemaVersion", "packageVersion", "assets"].includes(key))
469
+ || manifest.schema !== "https://docxodus.dev/schemas/export/export-assets/v1"
470
+ || manifest.schemaVersion !== 1 || typeof manifest.packageVersion !== "string"
471
+ || manifest.packageVersion.length === 0
472
+ || !Array.isArray(manifest.assets) || manifest.assets.length === 0
473
+ || manifest.assets.length > RUNTIME_ASSET_COUNT_MAX) {
474
+ fail("unsupported_runtime", "wasm_initialization", "The runtime asset graph is malformed.", "Deploy the versioned export-assets.json generated with this bundle.");
475
+ }
476
+ try {
477
+ assertWellFormedUnicode(manifest.packageVersion);
478
+ }
479
+ catch {
480
+ fail("unsupported_runtime", "wasm_initialization", "The runtime asset graph packageVersion is not well-formed Unicode.", "Regenerate export-assets.json from valid package metadata.");
481
+ }
482
+ let aggregateAssetBytes = 0;
483
+ const paths = new Set();
484
+ const assets = manifest.assets.map((entry, index) => {
485
+ if (!entry || typeof entry !== "object") {
486
+ fail("unsupported_runtime", "wasm_initialization", `Runtime asset entry ${index} is malformed.`, "Regenerate the package runtime asset graph.");
487
+ }
488
+ const record = entry;
489
+ if (Object.keys(record).some((key) => !["path", "mediaType", "byteLength", "sha256"].includes(key))
490
+ || typeof record.path !== "string" || typeof record.mediaType !== "string"
491
+ || !Number.isSafeInteger(record.byteLength) || record.byteLength < 0
492
+ || record.byteLength > RUNTIME_ASSET_BYTES_MAX
493
+ || typeof record.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(record.sha256)) {
494
+ fail("unsupported_runtime", "wasm_initialization", `Runtime asset entry ${index} has invalid identity fields.`, "Regenerate the package runtime asset graph.");
495
+ }
496
+ const path = record.path;
497
+ const segments = path.split("/");
498
+ const extension = path.slice(path.lastIndexOf("."));
499
+ const expectedMediaType = {
500
+ ".css": "text/css",
501
+ ".dat": "application/octet-stream",
502
+ ".js": "text/javascript",
503
+ ".json": "application/json",
504
+ ".wasm": "application/wasm",
505
+ };
506
+ if (!path.startsWith("./") || path.includes("\\") || path.includes("?") || path.includes("#")
507
+ || segments.some((segment, segmentIndex) => (segmentIndex > 0 && (segment === "" || segment === "." || segment === "..")))
508
+ || paths.has(path) || expectedMediaType[extension] !== record.mediaType) {
509
+ fail("unsupported_runtime", "wasm_initialization", `Runtime asset entry ${index} has a non-canonical or duplicate path.`, "Regenerate the package runtime asset graph.");
510
+ }
511
+ paths.add(path);
512
+ aggregateAssetBytes += record.byteLength;
513
+ if (aggregateAssetBytes > RUNTIME_ASSET_TOTAL_BYTES_MAX) {
514
+ fail("unsupported_runtime", "wasm_initialization", "The runtime asset graph exceeds its aggregate byte ceiling.", "Deploy a bounded Docxodus runtime package.");
515
+ }
516
+ return {
517
+ path,
518
+ mediaType: record.mediaType,
519
+ byteLength: record.byteLength,
520
+ sha256: record.sha256,
521
+ };
522
+ });
523
+ for (let index = 1; index < assets.length; index++) {
524
+ if (compareCodeUnits(assets[index - 1].path, assets[index].path) >= 0) {
525
+ fail("unsupported_runtime", "wasm_initialization", "Runtime asset entries are not in canonical path order.", "Regenerate export-assets.json with the package asset generator.");
526
+ }
527
+ }
528
+ const materializer = assets.find((entry) => entry.path === "./export-browser.bundle.js");
529
+ if (!materializer || !paths.has("./docxodus.worker.js")
530
+ || !paths.has("./wasm/_framework/dotnet.js")) {
531
+ fail("unsupported_runtime", "wasm_initialization", "The runtime asset graph does not identify the browser materializer.", "Regenerate export-assets.json from the complete runtime package.");
532
+ }
533
+ const materializerResponse = await globalThis.fetch(import.meta.url, {
534
+ cache: "no-store",
535
+ credentials: "same-origin",
536
+ signal,
537
+ });
538
+ if (!materializerResponse.ok) {
539
+ fail("unsupported_runtime", "wasm_initialization", `The loaded browser materializer could not be verified (${materializerResponse.status}).`, "Serve the browser export bundle from a readable same-origin URL.");
540
+ }
541
+ const materializerBytes = await boundedResponseBytes(materializerResponse, materializer.byteLength, "Browser materializer");
542
+ const materializerDigest = await sha256(materializerBytes);
543
+ if (materializerDigest !== materializer.sha256) {
544
+ fail("unsupported_runtime", "wasm_initialization", "The loaded browser materializer does not match export-assets.json.", "Deploy the bundle and asset graph from the same Docxodus build.");
545
+ }
546
+ const verifiedRuntimeAssets = assets.filter((entry) => entry.path === "./docxodus.worker.js" || entry.path.startsWith("./wasm/_framework/"));
547
+ const resolvedWasmBasePath = new URL(wasmBasePath.endsWith("/") ? wasmBasePath : `${wasmBasePath}/`, import.meta.url);
548
+ // Assets are verified concurrently, but every outcome is captured rather than thrown so the
549
+ // reported failure is always the first asset in declaration order, not whichever lost the race.
550
+ const verifications = await Promise.all(verifiedRuntimeAssets.map(async (asset) => {
551
+ const url = asset.path === "./docxodus.worker.js"
552
+ ? new URL("./docxodus.worker.js", import.meta.url)
553
+ : new URL(asset.path.slice("./wasm/".length), resolvedWasmBasePath);
554
+ try {
555
+ const assetResponse = await globalThis.fetch(url, {
556
+ cache: "force-cache",
557
+ credentials: "same-origin",
558
+ signal,
559
+ });
560
+ if (!assetResponse.ok) {
561
+ return () => fail("unsupported_runtime", "wasm_initialization", `Runtime asset ${asset.path} could not be verified (${assetResponse.status}).`, "Deploy every runtime asset named by export-assets.json.");
562
+ }
563
+ const bytes = await boundedResponseBytes(assetResponse, asset.byteLength, `Runtime asset ${asset.path}`);
564
+ if (bytes.byteLength !== asset.byteLength || await sha256(bytes) !== asset.sha256) {
565
+ return () => fail("unsupported_runtime", "wasm_initialization", `Runtime asset ${asset.path} does not match export-assets.json.`, "Deploy the worker, WASM directory, browser bundle, and asset graph from one build.");
566
+ }
567
+ return undefined;
568
+ }
569
+ catch (error) {
570
+ return () => { throw error; };
571
+ }
572
+ }));
573
+ for (const raise of verifications)
574
+ raise?.();
575
+ return {
576
+ packageVersion: manifest.packageVersion,
577
+ graphDigest: await sha256(utf8Bytes(canonicalJson({
578
+ schemaVersion: manifest.schemaVersion,
579
+ packageVersion: manifest.packageVersion,
580
+ assets,
581
+ }))),
582
+ materializerDigest,
583
+ assetCount: assets.length,
584
+ verifiedRuntimeAssetCount: verifiedRuntimeAssets.length,
585
+ };
586
+ }
587
+ export { canonicalJson };
588
+ async function canonicalMaterialDigest(domain, value) {
589
+ if (!/^[\x20-\x7e]+$/.test(domain)) {
590
+ throw new TypeError("Canonical digest domain tags must be printable ASCII");
591
+ }
592
+ const domainBytes = utf8Bytes(domain);
593
+ const materialBytes = utf8Bytes(canonicalJson(value));
594
+ const input = new Uint8Array(domainBytes.byteLength + 1 + materialBytes.byteLength);
595
+ input.set(domainBytes, 0);
596
+ input[domainBytes.byteLength] = 0;
597
+ input.set(materialBytes, domainBytes.byteLength + 1);
598
+ return sha256(input);
599
+ }
600
+ function constantTimeDigestEqual(left, right) {
601
+ let difference = left.length ^ right.length;
602
+ const length = Math.max(left.length, right.length);
603
+ for (let index = 0; index < length; index++) {
604
+ difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
605
+ }
606
+ return difference === 0;
607
+ }
608
+ function manifestFailure(path, detail) {
609
+ fail("invalid_document", "package_preflight", `DOCX preflight returned a manifest that violates schema v1 at ${path}.`, "Use the matching hardened #493 package-manifest producer and consumer.", { detail });
610
+ }
611
+ function strictJsonParse(source, onFailure = (detail) => manifestFailure("$", detail)) {
612
+ let cursor = 0;
613
+ const whitespace = () => {
614
+ while (cursor < source.length && /[\u0009\u000a\u000d\u0020]/.test(source[cursor]))
615
+ cursor++;
616
+ };
617
+ const parseStringToken = () => {
618
+ const start = cursor;
619
+ if (source[cursor++] !== '"')
620
+ throw new SyntaxError(`Expected string at ${start}`);
621
+ while (cursor < source.length) {
622
+ const character = source[cursor++];
623
+ if (character === '"')
624
+ return JSON.parse(source.slice(start, cursor));
625
+ if (character.charCodeAt(0) < 0x20)
626
+ throw new SyntaxError(`Control character at ${cursor - 1}`);
627
+ if (character !== "\\")
628
+ continue;
629
+ if (cursor >= source.length)
630
+ throw new SyntaxError("Unterminated JSON escape");
631
+ const escape = source[cursor++];
632
+ if (escape === "u") {
633
+ if (!/^[0-9a-fA-F]{4}$/.test(source.slice(cursor, cursor + 4))) {
634
+ throw new SyntaxError(`Invalid Unicode escape at ${cursor - 2}`);
635
+ }
636
+ cursor += 4;
637
+ }
638
+ else if (!'"\\/bfnrt'.includes(escape)) {
639
+ throw new SyntaxError(`Invalid JSON escape at ${cursor - 2}`);
640
+ }
641
+ }
642
+ throw new SyntaxError("Unterminated JSON string");
643
+ };
644
+ const parseValue = (depth) => {
645
+ if (depth > 128)
646
+ throw new SyntaxError("JSON nesting is too deep");
647
+ whitespace();
648
+ const character = source[cursor];
649
+ if (character === '"') {
650
+ parseStringToken();
651
+ return;
652
+ }
653
+ if (character === "{") {
654
+ cursor++;
655
+ whitespace();
656
+ const keys = new Set();
657
+ if (source[cursor] === "}") {
658
+ cursor++;
659
+ return;
660
+ }
661
+ while (cursor < source.length) {
662
+ whitespace();
663
+ const key = parseStringToken();
664
+ if (keys.has(key))
665
+ throw new SyntaxError(`Duplicate JSON property ${JSON.stringify(key)}`);
666
+ keys.add(key);
667
+ whitespace();
668
+ if (source[cursor++] !== ":")
669
+ throw new SyntaxError(`Expected colon at ${cursor - 1}`);
670
+ parseValue(depth + 1);
671
+ whitespace();
672
+ const separator = source[cursor++];
673
+ if (separator === "}")
674
+ return;
675
+ if (separator !== ",")
676
+ throw new SyntaxError(`Expected object separator at ${cursor - 1}`);
677
+ }
678
+ throw new SyntaxError("Unterminated JSON object");
679
+ }
680
+ if (character === "[") {
681
+ cursor++;
682
+ whitespace();
683
+ if (source[cursor] === "]") {
684
+ cursor++;
685
+ return;
686
+ }
687
+ while (cursor < source.length) {
688
+ parseValue(depth + 1);
689
+ whitespace();
690
+ const separator = source[cursor++];
691
+ if (separator === "]")
692
+ return;
693
+ if (separator !== ",")
694
+ throw new SyntaxError(`Expected array separator at ${cursor - 1}`);
695
+ }
696
+ throw new SyntaxError("Unterminated JSON array");
697
+ }
698
+ const rest = source.slice(cursor);
699
+ const literal = /^(?:true|false|null)/.exec(rest)?.[0]
700
+ ?? /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(rest)?.[0];
701
+ if (!literal)
702
+ throw new SyntaxError(`Invalid JSON value at ${cursor}`);
703
+ cursor += literal.length;
704
+ };
705
+ try {
706
+ parseValue(0);
707
+ whitespace();
708
+ if (cursor !== source.length)
709
+ throw new SyntaxError(`Trailing JSON data at ${cursor}`);
710
+ return JSON.parse(source);
711
+ }
712
+ catch (error) {
713
+ return onFailure(error instanceof Error ? error.message : String(error));
714
+ }
715
+ }
716
+ function recordAt(value, path, required, optional = []) {
717
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
718
+ manifestFailure(path, "expected an object");
719
+ }
720
+ const record = value;
721
+ const allowed = new Set([...required, ...optional]);
722
+ for (const key of Object.keys(record)) {
723
+ if (!allowed.has(key))
724
+ manifestFailure(`${path}.${key}`, "unknown property");
725
+ }
726
+ for (const key of required) {
727
+ if (!Object.prototype.hasOwnProperty.call(record, key)) {
728
+ manifestFailure(`${path}.${key}`, "missing required property");
729
+ }
730
+ }
731
+ return record;
732
+ }
733
+ function arrayAt(value, path) {
734
+ if (!Array.isArray(value))
735
+ manifestFailure(path, "expected an array");
736
+ return value;
737
+ }
738
+ function stringAt(value, path, allowEmpty = true) {
739
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
740
+ manifestFailure(path, "expected a string");
741
+ }
742
+ try {
743
+ assertWellFormedUnicode(value);
744
+ }
745
+ catch (error) {
746
+ manifestFailure(path, error instanceof Error ? error.message : String(error));
747
+ }
748
+ return value;
749
+ }
750
+ function integerAt(value, path, minimum = 0) {
751
+ if (!Number.isSafeInteger(value) || value < minimum) {
752
+ manifestFailure(path, `expected a safe integer >= ${minimum}`);
753
+ }
754
+ return value;
755
+ }
756
+ function booleanAt(value, path) {
757
+ if (typeof value !== "boolean")
758
+ manifestFailure(path, "expected a boolean");
759
+ return value;
760
+ }
761
+ function enumAt(value, path, allowed) {
762
+ if (typeof value !== "string" || !allowed.includes(value)) {
763
+ manifestFailure(path, `expected one of ${allowed.join(", ")}`);
764
+ }
765
+ return value;
766
+ }
767
+ function digestAt(value, path, nullable) {
768
+ if (value === null && nullable)
769
+ return;
770
+ const digest = recordAt(value, path, ["algorithm", "value"]);
771
+ if (digest.algorithm !== "SHA-256")
772
+ manifestFailure(`${path}.algorithm`, "expected SHA-256");
773
+ if (typeof digest.value !== "string" || !/^[0-9a-f]{64}$/.test(digest.value)) {
774
+ manifestFailure(`${path}.value`, "expected 64 lower-case hexadecimal digits");
775
+ }
776
+ }
777
+ function decimalAt(value, path) {
778
+ if (typeof value !== "string" || !/^(?:0|[1-9]\d*)$/.test(value)) {
779
+ manifestFailure(path, "expected a canonical non-negative base-10 integer string");
780
+ }
781
+ return BigInt(value);
782
+ }
783
+ function validatePackageManifestJson(source) {
784
+ const value = strictJsonParse(source);
785
+ const manifest = recordAt(value, "$", [
786
+ "schema", "schemaVersion", "packageKind", "isValid", "rawPackageBytesDigest",
787
+ "orderedOpcContentDigest", "normalizedSemanticDigest", "entries", "contentTypes",
788
+ "relationships", "facts", "findings",
789
+ ]);
790
+ if (manifest.schema !== "https://docxodus.dev/schemas/verification/package-manifest/v1") {
791
+ manifestFailure("$.schema", "unexpected package-manifest discriminator");
792
+ }
793
+ if (manifest.schemaVersion !== 1)
794
+ manifestFailure("$.schemaVersion", "expected 1");
795
+ enumAt(manifest.packageKind, "$.packageKind", [
796
+ "opc", "zip", "zip-encrypted", "ole-encrypted", "ole", "malformed",
797
+ ]);
798
+ booleanAt(manifest.isValid, "$.isValid");
799
+ digestAt(manifest.rawPackageBytesDigest, "$.rawPackageBytesDigest", false);
800
+ digestAt(manifest.orderedOpcContentDigest, "$.orderedOpcContentDigest", true);
801
+ digestAt(manifest.normalizedSemanticDigest, "$.normalizedSemanticDigest", true);
802
+ for (const [index, item] of arrayAt(manifest.entries, "$.entries").entries()) {
803
+ const path = `$.entries[${index}]`;
804
+ const entry = recordAt(item, path, [
805
+ "uri", "occurrence", "contentType", "contentTypeSource", "size", "compressedSize",
806
+ "rawBytesDigest", "normalizedXmlDigest", "isXml", "isEncrypted",
807
+ ]);
808
+ stringAt(entry.uri, `${path}.uri`, false);
809
+ integerAt(entry.occurrence, `${path}.occurrence`);
810
+ if (entry.contentType !== null)
811
+ stringAt(entry.contentType, `${path}.contentType`, false);
812
+ enumAt(entry.contentTypeSource, `${path}.contentTypeSource`, [
813
+ "override", "default", "implicit", "unresolved",
814
+ ]);
815
+ decimalAt(entry.size, `${path}.size`);
816
+ decimalAt(entry.compressedSize, `${path}.compressedSize`);
817
+ digestAt(entry.rawBytesDigest, `${path}.rawBytesDigest`, true);
818
+ digestAt(entry.normalizedXmlDigest, `${path}.normalizedXmlDigest`, true);
819
+ booleanAt(entry.isXml, `${path}.isXml`);
820
+ if (entry.isEncrypted !== null)
821
+ booleanAt(entry.isEncrypted, `${path}.isEncrypted`);
822
+ }
823
+ for (const [index, item] of arrayAt(manifest.contentTypes, "$.contentTypes").entries()) {
824
+ const path = `$.contentTypes[${index}]`;
825
+ const declaration = recordAt(item, path, ["kind", "key", "contentType", "occurrence"]);
826
+ enumAt(declaration.kind, `${path}.kind`, ["default", "override"]);
827
+ stringAt(declaration.key, `${path}.key`, false);
828
+ stringAt(declaration.contentType, `${path}.contentType`, false);
829
+ integerAt(declaration.occurrence, `${path}.occurrence`);
830
+ }
831
+ for (const [index, item] of arrayAt(manifest.relationships, "$.relationships").entries()) {
832
+ const path = `$.relationships[${index}]`;
833
+ const relationship = recordAt(item, path, [
834
+ "ownerUri", "id", "type", "target", "targetMode", "resolvedTargetUri", "isTargetPresent",
835
+ ]);
836
+ stringAt(relationship.ownerUri, `${path}.ownerUri`, false);
837
+ stringAt(relationship.id, `${path}.id`, false);
838
+ stringAt(relationship.type, `${path}.type`, false);
839
+ stringAt(relationship.target, `${path}.target`, false);
840
+ enumAt(relationship.targetMode, `${path}.targetMode`, ["Internal", "External"]);
841
+ if (relationship.resolvedTargetUri !== null) {
842
+ stringAt(relationship.resolvedTargetUri, `${path}.resolvedTargetUri`, false);
843
+ }
844
+ if (relationship.isTargetPresent !== null) {
845
+ booleanAt(relationship.isTargetPresent, `${path}.isTargetPresent`);
846
+ }
847
+ }
848
+ const facts = recordAt(manifest.facts, "$.facts", [
849
+ "mainDocumentUri", "isStrictOoxml", "isMacroEnabled", "hasCoreProperties",
850
+ "hasExtendedProperties", "hasCustomProperties", "sectionCount", "paragraphCount", "tableCount",
851
+ "headerPartCount", "footerPartCount", "footnoteCount", "endnoteCount", "styleCount",
852
+ "numberingDefinitionCount", "themePartCount", "mediaPartCount", "customXmlPartCount",
853
+ "drawingCount", "altChunkCount", "fieldCount", "revisions", "annotations",
854
+ ]);
855
+ if (facts.mainDocumentUri !== null)
856
+ stringAt(facts.mainDocumentUri, "$.facts.mainDocumentUri", false);
857
+ for (const name of [
858
+ "isStrictOoxml", "isMacroEnabled", "hasCoreProperties", "hasExtendedProperties",
859
+ "hasCustomProperties",
860
+ ])
861
+ booleanAt(facts[name], `$.facts.${name}`);
862
+ for (const name of [
863
+ "sectionCount", "paragraphCount", "tableCount", "headerPartCount", "footerPartCount",
864
+ "footnoteCount", "endnoteCount", "styleCount", "numberingDefinitionCount", "themePartCount",
865
+ "mediaPartCount", "customXmlPartCount", "drawingCount", "altChunkCount", "fieldCount",
866
+ ])
867
+ integerAt(facts[name], `$.facts.${name}`);
868
+ const revisions = recordAt(facts.revisions, "$.facts.revisions", [
869
+ "insertions", "deletions", "moveFrom", "moveTo", "propertyChanges", "runPropertyChanges",
870
+ "structuralChanges", "otherChanges", "total",
871
+ ]);
872
+ for (const name of Object.keys(revisions))
873
+ integerAt(revisions[name], `$.facts.revisions.${name}`);
874
+ const annotations = recordAt(facts.annotations, "$.facts.annotations", [
875
+ "comments", "commentReplies", "threadedCommentMetadata", "resolvedComments", "people",
876
+ "docxodusAnnotations",
877
+ ]);
878
+ for (const name of Object.keys(annotations))
879
+ integerAt(annotations[name], `$.facts.annotations.${name}`);
880
+ for (const [index, item] of arrayAt(manifest.findings, "$.findings").entries()) {
881
+ const path = `$.findings[${index}]`;
882
+ const finding = recordAt(item, path, ["code", "severity", "message", "location"]);
883
+ stringAt(finding.code, `${path}.code`, false);
884
+ enumAt(finding.severity, `${path}.severity`, ["info", "warning", "error"]);
885
+ stringAt(finding.message, `${path}.message`, false);
886
+ if (finding.location !== null) {
887
+ const location = recordAt(finding.location, `${path}.location`, [
888
+ "entryUri", "ownerUri", "relationshipId", "targetUri", "propertyPath",
889
+ ]);
890
+ for (const name of Object.keys(location)) {
891
+ if (location[name] !== null)
892
+ stringAt(location[name], `${path}.location.${name}`);
893
+ }
894
+ }
895
+ }
896
+ return manifest;
897
+ }
898
+ function addWarning(state, warning) {
899
+ enforceDiagnosticAdmission(state, 1);
900
+ state.warnings.push(warning);
901
+ }
902
+ function addResource(state, resource) {
903
+ enforceDiagnosticAdmission(state, 1);
904
+ state.resources.push(resource);
905
+ }
906
+ function addUnsupportedContent(state, outcome) {
907
+ enforceDiagnosticAdmission(state, 1);
908
+ state.unsupportedContent.push(outcome);
909
+ }
910
+ function enforceDiagnosticAdmission(state, additional) {
911
+ const current = state.warnings.length + state.resources.length
912
+ + state.unsupportedContent.length + state.fonts.length;
913
+ if (current + additional > state.limits.renderDiagnostics) {
914
+ fail("resource_limit", state.phase, `renderDiagnostics limit exceeded (${current + additional} > ${state.limits.renderDiagnostics}).`, "Use a smaller document or a versioned deployment policy with a higher diagnostic ceiling.");
915
+ }
916
+ }
917
+ function enforceLimit(actual, maximum, name, phase) {
918
+ if (actual > maximum) {
919
+ fail("resource_limit", phase, `${name} limit exceeded (${actual} > ${maximum}).`, `Use a smaller document or raise the deployment ceiling in a versioned limits contract.`);
920
+ }
921
+ }
922
+ function inspectionLimits(options) {
923
+ return {
924
+ opcEntries: options.limits.opcEntries,
925
+ expandedOpcBytes: options.limits.expandedOpcBytes,
926
+ xmlPartBytes: options.limits.xmlPartBytes,
927
+ opcUriCharacters: options.limits.opcUriCharacters,
928
+ opcCompressionRatio: options.limits.opcCompressionRatio,
929
+ };
930
+ }
931
+ async function preflightManifest(manifest, bytes, options, state, isSourcePackage) {
932
+ const sourceDigest = manifest.rawPackageBytesDigest.value;
933
+ const recomputedDigest = await sha256(bytes);
934
+ if (!constantTimeDigestEqual(sourceDigest, recomputedDigest)) {
935
+ fail("invalid_document", "package_preflight", "The #493 source digest does not match the exact bytes supplied to export.", "Use a matching package-manifest producer and immutable source snapshot.", {
936
+ detail: `manifest=${sourceDigest}; recomputed=${recomputedDigest}`,
937
+ });
938
+ }
939
+ if (isSourcePackage && options.expectedSourceDigest
940
+ && !constantTimeDigestEqual(options.expectedSourceDigest, sourceDigest)) {
941
+ fail("source_digest_mismatch", "package_preflight", "The source digest does not match expectedSourceDigest.", "Render the exact verified source bytes or update the expected digest.", {
942
+ detail: `expected=${options.expectedSourceDigest}; actual=${sourceDigest}`,
943
+ });
944
+ }
945
+ for (const finding of manifest.findings) {
946
+ if (finding.severity === "info")
947
+ continue;
948
+ addWarning(state, {
949
+ code: finding.code,
950
+ severity: "warning",
951
+ phase: "package_preflight",
952
+ message: finding.message,
953
+ remediation: "Inspect the named package part before relying on export fidelity.",
954
+ ...(finding.location?.entryUri ? { partUri: finding.location.entryUri } : {}),
955
+ ...(finding.location?.targetUri ? { resource: finding.location.targetUri } : {}),
956
+ });
957
+ }
958
+ const packageLimitFinding = manifest.findings.find((finding) => PACKAGE_LIMIT_FINDINGS.has(finding.code));
959
+ if (packageLimitFinding) {
960
+ fail("resource_limit", "package_preflight", packageLimitFinding.message, "Use a smaller package or raise the corresponding versioned package-inspection ceiling.", { detail: packageLimitFinding.code });
961
+ }
962
+ enforceLimit(bytes.byteLength, options.limits.compressedDocxBytes, "compressedDocxBytes", "package_preflight");
963
+ enforceLimit(manifest.entries.length, options.limits.opcEntries, "opcEntries", "package_preflight");
964
+ let expandedBytes = 0n;
965
+ const expandedLimit = BigInt(options.limits.expandedOpcBytes);
966
+ const xmlLimit = BigInt(options.limits.xmlPartBytes);
967
+ const ratioLimit = BigInt(options.limits.opcCompressionRatio);
968
+ for (const [index, entry] of manifest.entries.entries()) {
969
+ if (entry.uri.length > options.limits.opcUriCharacters) {
970
+ fail("resource_limit", "package_preflight", `opcUriCharacters limit exceeded for entry ${index} (${entry.uri.length} > ${options.limits.opcUriCharacters}).`, "Use a package with shorter canonical OPC part names.", { partUri: entry.uri });
971
+ }
972
+ const size = decimalAt(entry.size, `$.entries[${index}].size`);
973
+ const compressedSize = decimalAt(entry.compressedSize, `$.entries[${index}].compressedSize`);
974
+ expandedBytes += size;
975
+ if (expandedBytes > expandedLimit) {
976
+ fail("resource_limit", "package_preflight", `expandedOpcBytes limit exceeded (${expandedBytes.toString()} > ${expandedLimit.toString()}).`, "Use a smaller package or lower-expansion resources.");
977
+ }
978
+ if (entry.isXml && size > xmlLimit) {
979
+ fail("resource_limit", "package_preflight", `xmlPartBytes limit exceeded (${size.toString()} > ${xmlLimit.toString()}).`, "Split or reduce the XML part before export.", { partUri: entry.uri });
980
+ }
981
+ if ((compressedSize === 0n && size > 0n)
982
+ || (compressedSize > 0n && size > compressedSize * ratioLimit)) {
983
+ fail("resource_limit", "package_preflight", `opcCompressionRatio limit exceeded for ${entry.uri}.`, "Repackage the document without a suspiciously high expansion ratio.", { partUri: entry.uri });
984
+ }
985
+ }
986
+ if (manifest.packageKind !== "opc" || !manifest.isValid || !manifest.facts.mainDocumentUri) {
987
+ fail("invalid_document", "package_preflight", `The input is not a valid DOCX OPC package (${manifest.packageKind}).`, "Repair or decrypt the document before export.");
988
+ }
989
+ const mainRelationship = manifest.relationships.find((relationship) => relationship.ownerUri === "/"
990
+ && relationship.targetMode === "Internal"
991
+ && relationship.type.endsWith("/officeDocument"));
992
+ if (!mainRelationship
993
+ || mainRelationship.resolvedTargetUri !== manifest.facts.mainDocumentUri
994
+ || mainRelationship.isTargetPresent !== true
995
+ || !manifest.entries.some((entry) => entry.uri === manifest.facts.mainDocumentUri)) {
996
+ fail("invalid_document", "package_preflight", "The manifest's main-document identity is incomplete or inconsistent.", "Repair the package-level officeDocument relationship and target part.");
997
+ }
998
+ if (options.reviewProfileAlreadyApplied && manifest.facts.revisions.total !== 0) {
999
+ fail("invalid_argument", "package_preflight", "reviewProfileAlreadyApplied input still contains native tracked revisions.", "Supply the exact already-accepted/rejected package or set reviewProfileAlreadyApplied to false.");
1000
+ }
1001
+ const externalAutomatic = manifest.relationships.filter((relationship) => relationship.targetMode === "External"
1002
+ && !relationship.type.toLowerCase().endsWith("/hyperlink"));
1003
+ for (const relationship of externalAutomatic) {
1004
+ const warning = {
1005
+ code: "external_automatic_resource_omitted",
1006
+ severity: "warning",
1007
+ phase: "package_preflight",
1008
+ message: "An external automatic resource is not fetched by standalone export.",
1009
+ remediation: "Embed the resource in the DOCX package before export.",
1010
+ partUri: relationship.ownerUri,
1011
+ resource: relationship.target,
1012
+ };
1013
+ if (options.unsupportedContent !== "strict")
1014
+ addWarning(state, warning);
1015
+ addResource(state, { kind: "external_link", status: "omitted", resource: relationship.target });
1016
+ }
1017
+ if (externalAutomatic.length > 0 && options.unsupportedContent === "strict") {
1018
+ fail("resource_policy_failure", "package_preflight", "Strict export rejected an external automatic resource.", "Embed all automatic resources or use unsupportedContent: warn.");
1019
+ }
1020
+ if (manifest.facts.isMacroEnabled) {
1021
+ const warning = {
1022
+ code: "macro_content_not_exported",
1023
+ severity: "warning",
1024
+ phase: "package_preflight",
1025
+ message: "Macro content is not active or embedded in standalone HTML.",
1026
+ remediation: "Remove macros or use warn policy for a static visual export.",
1027
+ };
1028
+ if (options.unsupportedContent !== "strict")
1029
+ addWarning(state, warning);
1030
+ if (options.unsupportedContent === "strict") {
1031
+ fail("resource_policy_failure", "package_preflight", warning.message, warning.remediation);
1032
+ }
1033
+ }
1034
+ if (manifest.facts.altChunkCount > 0) {
1035
+ const warning = {
1036
+ code: "altchunk_not_supported",
1037
+ severity: "warning",
1038
+ phase: "package_preflight",
1039
+ message: "Arbitrary altChunk content is not a supported standalone export input.",
1040
+ remediation: "Materialize altChunk content into ordinary WordprocessingML before export.",
1041
+ };
1042
+ if (options.unsupportedContent !== "strict")
1043
+ addWarning(state, warning);
1044
+ if (options.unsupportedContent === "strict") {
1045
+ fail("resource_policy_failure", "package_preflight", warning.message, warning.remediation);
1046
+ }
1047
+ }
1048
+ // Every family the preflight once reported now draws: all tracked-revision families —
1049
+ // insertions, deletions, moves, run and block property changes (issue #539), tracked cell
1050
+ // revisions, custom XML revision ranges (issue #538) — and, since issue #540, comment
1051
+ // topology (replies nest beneath their thread root, resolved comments are badged and muted),
1052
+ // so there is no revision or comment warning left to raise here.
1053
+ }
1054
+ function conversionOptions(options) {
1055
+ const commentRenderMode = {
1056
+ hidden: CommentRenderMode.Disabled,
1057
+ inline: CommentRenderMode.Inline,
1058
+ endnotes: CommentRenderMode.EndnoteStyle,
1059
+ margin: CommentRenderMode.Margin,
1060
+ }[options.commentProfile];
1061
+ return {
1062
+ pageTitle: options.title,
1063
+ cssPrefix: "docx-",
1064
+ fabricateClasses: true,
1065
+ additionalCss: "",
1066
+ commentRenderMode,
1067
+ commentCssClassPrefix: "comment-",
1068
+ paginationMode: PaginationMode.Paginated,
1069
+ paginationScale: 1,
1070
+ paginationCssClassPrefix: "page-",
1071
+ renderAnnotations: true,
1072
+ renderFootnotesAndEndnotes: true,
1073
+ renderHeadersAndFooters: true,
1074
+ renderTrackedChanges: options.reviewProfile === "markup",
1075
+ showDeletedContent: true,
1076
+ renderMoveOperations: true,
1077
+ renderUnsupportedContentPlaceholders: true,
1078
+ stampAnchors: true,
1079
+ };
1080
+ }
1081
+ function bootstrapHtml(title = "") {
1082
+ const safeTitle = title.replace(/[&<>"']/g, (character) => ({
1083
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;",
1084
+ })[character]);
1085
+ return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${RENDER_CSP}"><title>${safeTitle}</title></head><body></body></html>`;
1086
+ }
1087
+ async function createIsolatedFrame(hostDocument, state, html = bootstrapHtml(), phase = "browser_launch") {
1088
+ if (!hostDocument.defaultView || !hostDocument.documentElement) {
1089
+ fail("unsupported_runtime", "browser_launch", "An attached browser document is required.", "Call the browser exporter from a live Window, not a worker or detached document.");
1090
+ }
1091
+ const frame = hostDocument.createElement("iframe");
1092
+ frame.dataset.docxodusExportRealm = "v1";
1093
+ frame.setAttribute("aria-hidden", "true");
1094
+ frame.setAttribute("tabindex", "-1");
1095
+ frame.sandbox.add("allow-same-origin");
1096
+ frame.style.position = "fixed";
1097
+ frame.style.left = "-100000px";
1098
+ frame.style.top = "0";
1099
+ frame.style.width = "1600px";
1100
+ frame.style.height = "1200px";
1101
+ frame.style.border = "0";
1102
+ frame.style.pointerEvents = "none";
1103
+ const loaded = new Promise((resolve, reject) => {
1104
+ frame.addEventListener("load", () => resolve(), { once: true });
1105
+ frame.addEventListener("error", () => reject(new Error("isolated frame failed to load")), { once: true });
1106
+ });
1107
+ frame.srcdoc = html;
1108
+ (hostDocument.body ?? hostDocument.documentElement).appendChild(frame);
1109
+ try {
1110
+ await runPhase(state, phase, ["isolated browsing context"], () => loaded);
1111
+ }
1112
+ catch (error) {
1113
+ frame.remove();
1114
+ throw error;
1115
+ }
1116
+ if (!frame.contentDocument?.defaultView) {
1117
+ frame.remove();
1118
+ fail("browser_launch_failure", "browser_launch", "The isolated export frame is inaccessible.", "Allow same-origin srcdoc frames while retaining the script-free sandbox.");
1119
+ }
1120
+ return frame;
1121
+ }
1122
+ const STANDALONE_DATA_MEDIA_TYPES = new Set([
1123
+ "image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp", "image/tiff",
1124
+ "image/x-icon", "font/woff", "font/woff2", "font/ttf", "font/otf",
1125
+ "application/font-woff", "application/vnd.ms-fontobject",
1126
+ ]);
1127
+ function dataUrlInfo(value) {
1128
+ if (!value.startsWith("data:"))
1129
+ return undefined;
1130
+ const comma = value.indexOf(",");
1131
+ if (comma < 0)
1132
+ return undefined;
1133
+ const metadata = value.slice(5, comma);
1134
+ const segments = metadata.split(";");
1135
+ const mediaType = (segments.shift() ?? "").toLowerCase();
1136
+ if (mediaType === "" && value === "data:,")
1137
+ return { mediaType: "", byteLength: 0 };
1138
+ if (!STANDALONE_DATA_MEDIA_TYPES.has(mediaType))
1139
+ return undefined;
1140
+ if (segments.some((segment) => segment.toLowerCase() !== "base64"))
1141
+ return undefined;
1142
+ if (!segments.some((segment) => segment.toLowerCase() === "base64"))
1143
+ return undefined;
1144
+ const payload = value.slice(comma + 1);
1145
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(payload)) {
1146
+ return undefined;
1147
+ }
1148
+ const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
1149
+ return { mediaType, byteLength: payload.length / 4 * 3 - padding };
1150
+ }
1151
+ function automaticUrlAllowed(value, allowFragment = false) {
1152
+ const trimmed = value.trim();
1153
+ return trimmed === "" || dataUrlInfo(trimmed) !== undefined
1154
+ || (allowFragment && trimmed.startsWith("#"));
1155
+ }
1156
+ function standaloneSrcsetAllowed(value) {
1157
+ // Fail closed to one self-contained candidate. A loose comma split would
1158
+ // misparse the comma that is part of every data URL and could retain a later
1159
+ // network candidate.
1160
+ const match = /^\s*(data:\S+?)(?:\s+(?:\d+(?:\.\d+)?x|\d+w))?\s*$/i.exec(value);
1161
+ return !!match && dataUrlInfo(match[1]) !== undefined;
1162
+ }
1163
+ const SVG_URL_PRESENTATION_ATTRIBUTES = new Set([
1164
+ "clip-path", "cursor", "fill", "filter", "marker", "marker-end", "marker-mid",
1165
+ "marker-start", "mask", "stroke",
1166
+ ]);
1167
+ function estimateDataUrlBytes(value) {
1168
+ return dataUrlInfo(value.trim())?.byteLength;
1169
+ }
1170
+ function policyWarning(state, options, warning) {
1171
+ if (options.unsupportedContent === "strict") {
1172
+ fail("resource_policy_failure", warning.phase, warning.message, warning.remediation, {
1173
+ detail: warning.detail,
1174
+ partUri: warning.partUri,
1175
+ anchorId: warning.anchorId,
1176
+ resource: warning.resource,
1177
+ });
1178
+ }
1179
+ addWarning(state, { ...warning, severity: "warning" });
1180
+ }
1181
+ function consumeCssEscape(source, start) {
1182
+ let cursor = start + 1;
1183
+ if (cursor >= source.length)
1184
+ return { value: "\ufffd", end: cursor };
1185
+ if (source[cursor] === "\r" && source[cursor + 1] === "\n") {
1186
+ return { value: "", end: cursor + 2 };
1187
+ }
1188
+ if (source[cursor] === "\n" || source[cursor] === "\r" || source[cursor] === "\f") {
1189
+ return { value: "", end: cursor + 1 };
1190
+ }
1191
+ const hexStart = cursor;
1192
+ while (cursor < source.length && cursor - hexStart < 6 && /[0-9a-f]/i.test(source[cursor])) {
1193
+ cursor++;
1194
+ }
1195
+ if (cursor > hexStart) {
1196
+ const point = Number.parseInt(source.slice(hexStart, cursor), 16);
1197
+ if (/\s/.test(source[cursor] ?? "")) {
1198
+ if (source[cursor] === "\r" && source[cursor + 1] === "\n")
1199
+ cursor += 2;
1200
+ else
1201
+ cursor++;
1202
+ }
1203
+ return {
1204
+ value: point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)
1205
+ ? "\ufffd"
1206
+ : String.fromCodePoint(point),
1207
+ end: cursor,
1208
+ };
1209
+ }
1210
+ return { value: source[cursor], end: cursor + 1 };
1211
+ }
1212
+ function consumeCssName(source, start) {
1213
+ let value = "";
1214
+ let cursor = start;
1215
+ while (cursor < source.length) {
1216
+ const character = source[cursor];
1217
+ if (character === "\\") {
1218
+ const escape = consumeCssEscape(source, cursor);
1219
+ value += escape.value;
1220
+ cursor = escape.end;
1221
+ }
1222
+ else if (/[a-z0-9_-]/i.test(character) || character.charCodeAt(0) >= 0x80) {
1223
+ value += character;
1224
+ cursor++;
1225
+ }
1226
+ else {
1227
+ break;
1228
+ }
1229
+ }
1230
+ return { value, end: cursor };
1231
+ }
1232
+ function consumeCssComment(source, start) {
1233
+ const end = source.indexOf("*/", start + 2);
1234
+ return end < 0 ? source.length : end + 2;
1235
+ }
1236
+ function consumeCssString(source, start) {
1237
+ const quote = source[start];
1238
+ let cursor = start + 1;
1239
+ while (cursor < source.length) {
1240
+ if (source[cursor] === quote)
1241
+ return cursor + 1;
1242
+ // CSS bad-string recovery ends at an unescaped newline and resumes tokenization
1243
+ // afterward. Swallowing the remainder would hide later declarations from the audit.
1244
+ if (source[cursor] === "\n" || source[cursor] === "\r" || source[cursor] === "\f") {
1245
+ return cursor;
1246
+ }
1247
+ if (source[cursor] === "\\")
1248
+ cursor = consumeCssEscape(source, cursor).end;
1249
+ else
1250
+ cursor++;
1251
+ }
1252
+ return cursor;
1253
+ }
1254
+ function consumeCssFunction(source, openParenthesis) {
1255
+ let depth = 0;
1256
+ let cursor = openParenthesis;
1257
+ while (cursor < source.length) {
1258
+ if (source.startsWith("/*", cursor)) {
1259
+ cursor = consumeCssComment(source, cursor);
1260
+ }
1261
+ else if (source[cursor] === "\"" || source[cursor] === "'") {
1262
+ cursor = consumeCssString(source, cursor);
1263
+ }
1264
+ else if (source[cursor] === "\\") {
1265
+ cursor = consumeCssEscape(source, cursor).end;
1266
+ }
1267
+ else if (source[cursor] === "(") {
1268
+ depth++;
1269
+ cursor++;
1270
+ }
1271
+ else if (source[cursor] === ")") {
1272
+ depth--;
1273
+ cursor++;
1274
+ if (depth === 0)
1275
+ return cursor;
1276
+ }
1277
+ else {
1278
+ cursor++;
1279
+ }
1280
+ }
1281
+ return cursor;
1282
+ }
1283
+ function decodeCssEscapedText(source, stripComments) {
1284
+ let decoded = "";
1285
+ for (let cursor = 0; cursor < source.length;) {
1286
+ if (stripComments && source.startsWith("/*", cursor)) {
1287
+ cursor = consumeCssComment(source, cursor);
1288
+ }
1289
+ else if (source[cursor] === "\\") {
1290
+ const escape = consumeCssEscape(source, cursor);
1291
+ decoded += escape.value;
1292
+ cursor = escape.end;
1293
+ }
1294
+ else {
1295
+ decoded += source[cursor++];
1296
+ }
1297
+ }
1298
+ return decoded;
1299
+ }
1300
+ function decodeCssUrlComponent(source) {
1301
+ const trimmed = source.trim();
1302
+ if (trimmed.length >= 2 && (trimmed[0] === "\"" || trimmed[0] === "'")
1303
+ && trimmed.at(-1) === trimmed[0]) {
1304
+ // Comment delimiters are ordinary payload bytes inside a CSS string.
1305
+ return decodeCssEscapedText(trimmed.slice(1, -1), false);
1306
+ }
1307
+ return decodeCssEscapedText(trimmed, true).trim();
1308
+ }
1309
+ /** Tokenize the security-relevant CSS grammar while honoring escapes, strings, and comments. */
1310
+ function cssSecurityTokens(css) {
1311
+ const tokens = [];
1312
+ const functionStack = [];
1313
+ for (let cursor = 0; cursor < css.length;) {
1314
+ if (css.startsWith("/*", cursor)) {
1315
+ cursor = consumeCssComment(css, cursor);
1316
+ continue;
1317
+ }
1318
+ if (css[cursor] === "\"" || css[cursor] === "'") {
1319
+ const end = consumeCssString(css, cursor);
1320
+ const context = functionStack.at(-1);
1321
+ const isImageSource = context === "image-set" || context === "-webkit-image-set";
1322
+ if (isImageSource
1323
+ && end > cursor && css[end - 1] === css[cursor]) {
1324
+ tokens.push({
1325
+ kind: "url",
1326
+ start: cursor,
1327
+ end,
1328
+ value: decodeCssEscapedText(css.slice(cursor + 1, end - 1), false),
1329
+ });
1330
+ }
1331
+ cursor = Math.max(end, cursor + 1);
1332
+ continue;
1333
+ }
1334
+ if (css[cursor] === "@") {
1335
+ const name = consumeCssName(css, cursor + 1);
1336
+ if (name.value.toLowerCase() === "import") {
1337
+ let end = name.end;
1338
+ let depth = 0;
1339
+ while (end < css.length) {
1340
+ if (css.startsWith("/*", end))
1341
+ end = consumeCssComment(css, end);
1342
+ else if (css[end] === "\"" || css[end] === "'")
1343
+ end = consumeCssString(css, end);
1344
+ else if (css[end] === "(") {
1345
+ depth++;
1346
+ end++;
1347
+ }
1348
+ else if (css[end] === ")") {
1349
+ depth = Math.max(0, depth - 1);
1350
+ end++;
1351
+ }
1352
+ else if (css[end] === ";" && depth === 0) {
1353
+ end++;
1354
+ break;
1355
+ }
1356
+ else
1357
+ end++;
1358
+ }
1359
+ tokens.push({ kind: "import", start: cursor, end, value: css.slice(cursor, end) });
1360
+ cursor = end;
1361
+ continue;
1362
+ }
1363
+ }
1364
+ if (css[cursor] === "\\" || /[a-z_-]/i.test(css[cursor]) || css.charCodeAt(cursor) >= 0x80) {
1365
+ const name = consumeCssName(css, cursor);
1366
+ if (name.value.toLowerCase() === "url" && css[name.end] === "(") {
1367
+ let end = name.end + 1;
1368
+ while (end < css.length) {
1369
+ if (css.startsWith("/*", end))
1370
+ end = consumeCssComment(css, end);
1371
+ else if (css[end] === "\"" || css[end] === "'")
1372
+ end = consumeCssString(css, end);
1373
+ else if (css[end] === "\\")
1374
+ end = consumeCssEscape(css, end).end;
1375
+ else if (css[end++] === ")")
1376
+ break;
1377
+ }
1378
+ const innerEnd = css[end - 1] === ")" ? end - 1 : end;
1379
+ tokens.push({
1380
+ kind: "url",
1381
+ start: cursor,
1382
+ end,
1383
+ value: decodeCssUrlComponent(css.slice(name.end + 1, innerEnd)),
1384
+ });
1385
+ cursor = end;
1386
+ continue;
1387
+ }
1388
+ if (css[name.end] === "(") {
1389
+ const functionName = name.value.toLowerCase();
1390
+ const isImageSource = functionStack.some((context) => context === "image-set" || context === "-webkit-image-set");
1391
+ if (isImageSource
1392
+ && (functionName === "var" || functionName === "env" || functionName === "if")) {
1393
+ // A substitution can resolve to a quoted image-set source, including a
1394
+ // custom property declared elsewhere. It cannot be proven standalone
1395
+ // from the authored token stream, so remove the entire substitution.
1396
+ const end = consumeCssFunction(css, name.end);
1397
+ tokens.push({
1398
+ kind: "substitution",
1399
+ start: cursor,
1400
+ end,
1401
+ value: css.slice(cursor, end),
1402
+ });
1403
+ cursor = end;
1404
+ continue;
1405
+ }
1406
+ functionStack.push(functionName);
1407
+ cursor = name.end + 1;
1408
+ continue;
1409
+ }
1410
+ cursor = Math.max(name.end, cursor + 1);
1411
+ continue;
1412
+ }
1413
+ if (css[cursor] === "(") {
1414
+ functionStack.push("");
1415
+ cursor++;
1416
+ continue;
1417
+ }
1418
+ if (css[cursor] === ")") {
1419
+ functionStack.pop();
1420
+ cursor++;
1421
+ continue;
1422
+ }
1423
+ cursor++;
1424
+ }
1425
+ return tokens;
1426
+ }
1427
+ function sanitizeCss(css, state, options, resourceLabel) {
1428
+ let candidate = css;
1429
+ // Retokenize after every rewrite. Security tokens can otherwise be synthesized
1430
+ // when hostile identifier fragments meet at a removed construct boundary.
1431
+ for (let pass = 0; pass < 8; pass++) {
1432
+ const tokens = cssSecurityTokens(candidate);
1433
+ const actionable = tokens.filter((token) => token.kind !== "url" || !automaticUrlAllowed(token.value, true));
1434
+ if (actionable.length === 0)
1435
+ return candidate;
1436
+ let cursor = 0;
1437
+ let sanitized = "";
1438
+ for (const token of tokens) {
1439
+ sanitized += candidate.slice(cursor, token.start);
1440
+ if (token.kind === "import") {
1441
+ policyWarning(state, options, {
1442
+ code: "css_import_omitted",
1443
+ phase: "docx_conversion",
1444
+ message: "A CSS import was removed from standalone output.",
1445
+ remediation: "Inline the stylesheet and all of its resources.",
1446
+ resource: `${resourceLabel}: ${token.value.slice(0, 160)}`,
1447
+ });
1448
+ // A semicolon cannot be consumed as the optional trailing whitespace of a
1449
+ // hex escape, so identifier fragments cannot join across this boundary.
1450
+ sanitized += ";";
1451
+ }
1452
+ else if (token.kind === "url" && automaticUrlAllowed(token.value, true)) {
1453
+ sanitized += candidate.slice(token.start, token.end);
1454
+ }
1455
+ else {
1456
+ policyWarning(state, options, {
1457
+ code: "external_css_resource_omitted",
1458
+ phase: "docx_conversion",
1459
+ message: "An automatic CSS resource was removed from standalone output.",
1460
+ remediation: "Embed the resource as a data URL in the DOCX conversion output.",
1461
+ resource: token.value,
1462
+ });
1463
+ sanitized += "url(\"data:,\")";
1464
+ }
1465
+ cursor = token.end;
1466
+ }
1467
+ candidate = sanitized + candidate.slice(cursor);
1468
+ }
1469
+ policyWarning(state, options, {
1470
+ code: "external_css_resource_omitted",
1471
+ phase: "docx_conversion",
1472
+ message: "CSS could not be proven free of automatic external resources.",
1473
+ remediation: "Remove dynamic CSS resource substitutions and inline all resources.",
1474
+ resource: resourceLabel,
1475
+ });
1476
+ return "";
1477
+ }
1478
+ function sanitizeConvertedDocument(target, convertedHtml, state, options) {
1479
+ const view = target.defaultView;
1480
+ if (!view) {
1481
+ fail("browser_launch_failure", "browser_launch", "The export realm has no defaultView.", "Use an attached same-origin browser frame.");
1482
+ }
1483
+ const parsed = new view.DOMParser().parseFromString(convertedHtml, "text/html");
1484
+ const parserError = parsed.querySelector("parsererror");
1485
+ if (parserError || !parsed.body) {
1486
+ fail("conversion_failure", "docx_conversion", "Converted HTML could not be parsed.", "Inspect converter diagnostics and the source package.");
1487
+ }
1488
+ for (const element of Array.from(parsed.querySelectorAll("script, iframe, object, embed, video, audio, source, track, link, base"))) {
1489
+ policyWarning(state, options, {
1490
+ code: "active_or_external_content_omitted",
1491
+ phase: "docx_conversion",
1492
+ message: `A ${element.localName} element was removed from standalone output.`,
1493
+ remediation: "Represent the content as supported static WordprocessingML.",
1494
+ });
1495
+ element.remove();
1496
+ }
1497
+ for (const meta of Array.from(parsed.querySelectorAll("meta[http-equiv]"))) {
1498
+ meta.remove();
1499
+ }
1500
+ for (const style of Array.from(parsed.querySelectorAll("style"))) {
1501
+ style.textContent = sanitizeCss(style.textContent ?? "", state, options, "stylesheet");
1502
+ }
1503
+ const urlAttributes = new Set([
1504
+ "src", "poster", "data", "action", "formaction", "background", "xlink:href",
1505
+ ]);
1506
+ for (const element of Array.from(parsed.querySelectorAll("*"))) {
1507
+ for (const attribute of Array.from(element.attributes)) {
1508
+ const name = attribute.name.toLowerCase();
1509
+ if (name.startsWith("on")) {
1510
+ element.removeAttribute(attribute.name);
1511
+ continue;
1512
+ }
1513
+ if (name === "style") {
1514
+ element.setAttribute("style", sanitizeCss(attribute.value, state, options, "inline style"));
1515
+ continue;
1516
+ }
1517
+ if (name === "srcset") {
1518
+ if (!standaloneSrcsetAllowed(attribute.value)) {
1519
+ element.removeAttribute(attribute.name);
1520
+ policyWarning(state, options, {
1521
+ code: "srcset_omitted",
1522
+ phase: "docx_conversion",
1523
+ message: "A responsive image source set was removed because every candidate could not be proven standalone.",
1524
+ remediation: "Embed one image as a data URL in src.",
1525
+ resource: attribute.value,
1526
+ });
1527
+ }
1528
+ continue;
1529
+ }
1530
+ if (SVG_URL_PRESENTATION_ATTRIBUTES.has(name)
1531
+ && cssSecurityTokens(attribute.value).length > 0) {
1532
+ element.setAttribute(attribute.name, sanitizeCss(attribute.value, state, options, `SVG ${attribute.name}`));
1533
+ continue;
1534
+ }
1535
+ if (name === "href" && element.localName === "a") {
1536
+ const href = attribute.value.trim();
1537
+ if (href.startsWith("#") || /^(?:https?|mailto|tel):/i.test(href)) {
1538
+ if (!href.startsWith("#")) {
1539
+ element.setAttribute("rel", "noopener noreferrer");
1540
+ addResource(state, { kind: "external_link", status: "allowed_user_link", resource: href });
1541
+ }
1542
+ continue;
1543
+ }
1544
+ element.removeAttribute(attribute.name);
1545
+ policyWarning(state, options, {
1546
+ code: "unsafe_hyperlink_omitted",
1547
+ phase: "docx_conversion",
1548
+ message: "A hyperlink with an unsupported scheme was removed.",
1549
+ remediation: "Use an HTTPS, HTTP, mailto, tel, or document-fragment target.",
1550
+ resource: href,
1551
+ });
1552
+ continue;
1553
+ }
1554
+ if (name === "ping" && element.localName === "a") {
1555
+ element.removeAttribute(attribute.name);
1556
+ policyWarning(state, options, {
1557
+ code: "hyperlink_ping_omitted",
1558
+ phase: "docx_conversion",
1559
+ message: "A hyperlink ping target was removed from standalone output.",
1560
+ remediation: "Use an ordinary user-activated hyperlink without background tracking requests.",
1561
+ resource: attribute.value,
1562
+ });
1563
+ continue;
1564
+ }
1565
+ if ((name === "href" && element.localName !== "a") || urlAttributes.has(name)) {
1566
+ const allowFragment = element.namespaceURI === "http://www.w3.org/2000/svg";
1567
+ if (!automaticUrlAllowed(attribute.value, allowFragment)) {
1568
+ element.removeAttribute(attribute.name);
1569
+ policyWarning(state, options, {
1570
+ code: "external_automatic_resource_omitted",
1571
+ phase: "docx_conversion",
1572
+ message: "An automatic external resource was removed from standalone output.",
1573
+ remediation: "Embed the resource as a data URL before export.",
1574
+ resource: attribute.value,
1575
+ });
1576
+ }
1577
+ }
1578
+ }
1579
+ if (element.localName === "form") {
1580
+ element.removeAttribute("action");
1581
+ element.addEventListener("submit", (event) => event.preventDefault());
1582
+ }
1583
+ }
1584
+ // Admit the sanitized detached tree before importing it into the live layout realm or allowing
1585
+ // the browser to decode any data URL. This bounds the second DOM copy and decoded resources.
1586
+ countDomNodes(parsed, options.limits.domNodes, "docx_conversion");
1587
+ const admittedResources = automaticResourceCount(parsed);
1588
+ enforceLimit(admittedResources.count, options.limits.automaticResources, "automaticResources", "docx_conversion");
1589
+ enforceLimit(admittedResources.bytes, options.limits.automaticResourceBytes, "automaticResourceBytes", "docx_conversion");
1590
+ target.documentElement.lang = parsed.documentElement.lang || "en-US";
1591
+ target.title = options.title;
1592
+ for (const style of Array.from(parsed.head.querySelectorAll("style"))) {
1593
+ target.head.appendChild(target.importNode(style, true));
1594
+ }
1595
+ target.body.replaceChildren(...Array.from(parsed.body.childNodes, (node) => target.importNode(node, true)));
1596
+ }
1597
+ function inventoryConvertedContent(document, state, options) {
1598
+ for (const placeholder of Array.from(document.querySelectorAll("[data-content-type]"))) {
1599
+ const outcome = {
1600
+ contentType: placeholder.dataset.contentType ?? "Other",
1601
+ ...(placeholder.dataset.elementName ? { elementName: placeholder.dataset.elementName } : {}),
1602
+ ...(placeholder.closest("[data-source-anchor-id]")?.dataset.sourceAnchorId
1603
+ ? { anchorId: placeholder.closest("[data-source-anchor-id]").dataset.sourceAnchorId }
1604
+ : {}),
1605
+ action: "placeholder",
1606
+ };
1607
+ addUnsupportedContent(state, outcome);
1608
+ policyWarning(state, options, {
1609
+ code: "unsupported_content_placeholder",
1610
+ phase: "docx_conversion",
1611
+ message: `${outcome.contentType} is represented by a visible placeholder.`,
1612
+ remediation: "Replace it with a supported static representation for faithful export.",
1613
+ ...(outcome.anchorId ? { anchorId: outcome.anchorId } : {}),
1614
+ });
1615
+ }
1616
+ for (const image of Array.from(document.querySelectorAll("img"))) {
1617
+ const source = image.getAttribute("src") ?? "";
1618
+ const metadata = /^data:([^;,]+)/i.exec(source);
1619
+ addResource(state, {
1620
+ kind: "image",
1621
+ status: "embedded",
1622
+ resource: image.alt || undefined,
1623
+ mediaType: metadata?.[1],
1624
+ byteLength: estimateDataUrlBytes(source),
1625
+ });
1626
+ }
1627
+ for (const svg of Array.from(document.querySelectorAll("svg"))) {
1628
+ addResource(state, {
1629
+ kind: svg.classList.contains("chart") || svg.closest("[class*='chart']") ? "chart" : "svg",
1630
+ status: "inline",
1631
+ });
1632
+ }
1633
+ }
1634
+ async function awaitFonts(document) {
1635
+ if (document.fonts)
1636
+ await document.fonts.ready;
1637
+ }
1638
+ /**
1639
+ * Lifts the converter's measurement staging area out of `visibility: hidden` for the font
1640
+ * phase, and returns the undo.
1641
+ *
1642
+ * `font_loading` has to settle before pagination, because line breaking depends on which
1643
+ * faces actually resolved — and until pagination runs, every piece of document content is
1644
+ * still inside `#pagination-staging`, which the converter hides so its measurements never
1645
+ * paint. Left alone, the font inventory would see a document with no rendered text and
1646
+ * report no font dependencies at all.
1647
+ *
1648
+ * Only the staging root's own `visibility` is overridden, never a descendant's: content the
1649
+ * document itself hid keeps its own declaration and stays out of the inventory. Visibility
1650
+ * does not affect layout, so nothing the engine measures moves.
1651
+ */
1652
+ function revealMeasurementStaging(document) {
1653
+ const roots = Array.from(document.querySelectorAll("#pagination-staging, .page-staging"));
1654
+ const saved = roots.map((root) => ({
1655
+ root,
1656
+ value: root.style.getPropertyValue("visibility"),
1657
+ priority: root.style.getPropertyPriority("visibility"),
1658
+ }));
1659
+ for (const root of roots)
1660
+ root.style.setProperty("visibility", "visible", "important");
1661
+ return () => {
1662
+ for (const { root, value, priority } of saved) {
1663
+ if (value)
1664
+ root.style.setProperty("visibility", value, priority);
1665
+ else
1666
+ root.style.removeProperty("visibility");
1667
+ }
1668
+ };
1669
+ }
1670
+ /**
1671
+ * Admits one resolver-backed font inventory into the report.
1672
+ *
1673
+ * The inventory is canonical: `createBrowserFontTask` samples every rendered face,
1674
+ * resolves it through the configured resolver when there is one and through measured
1675
+ * browser availability when there is not, and returns both the resolutions and the
1676
+ * configuration identity the renderer fingerprint binds.
1677
+ */
1678
+ function recordFontResolution(result, state, options) {
1679
+ enforceLimit(result.resolutions.length, state.limits.fontRequests, "fontRequests", "font_loading");
1680
+ enforceDiagnosticAdmission(state, result.resolutions.length);
1681
+ state.fontIdentity = { ...result.identity };
1682
+ state.fonts.push(...result.resolutions.map((resolution) => ({
1683
+ ...resolution,
1684
+ requestedFamilies: [...resolution.requestedFamilies],
1685
+ requestedFamilyKinds: [...resolution.requestedFamilyKinds],
1686
+ ...(resolution.licenseEvidence
1687
+ ? { licenseEvidence: { ...resolution.licenseEvidence } }
1688
+ : {}),
1689
+ })));
1690
+ if (result.renderedTextNodeCount > 0 && result.resolutions.length === 0) {
1691
+ fail("resource_policy_failure", "font_loading", "Rendered text was present, but the canonical font inventory was unexpectedly empty.", "Report this font inventory invariant failure before relying on the output.");
1692
+ }
1693
+ for (const resolution of result.resolutions) {
1694
+ // Every one of these is a warning and never a failure on its own. An unresolvable or
1695
+ // substituted family is a font-policy matter governed by strictFonts below, not
1696
+ // unsupported content, so none of them route through policyWarning.
1697
+ const severity = "warning";
1698
+ if (resolution.status === "missing") {
1699
+ addWarning(state, {
1700
+ code: "font_unavailable",
1701
+ severity,
1702
+ phase: "font_loading",
1703
+ message: `No configured face resolved the required font family: ${resolution.requestedFamily}.`,
1704
+ remediation: "Install or explicitly supply the required font before export.",
1705
+ resource: resolution.requestedFamily,
1706
+ });
1707
+ }
1708
+ if (resolution.status === "load_failed") {
1709
+ addWarning(state, {
1710
+ code: "font_load_failed",
1711
+ severity,
1712
+ phase: "font_loading",
1713
+ message: `The configured font face for ${resolution.requestedFamily} could not be decoded or loaded.`,
1714
+ remediation: "Replace the configured font with a valid browser-supported face.",
1715
+ resource: resolution.requestedFamily,
1716
+ ...(resolution.loadFailureDetail ? { detail: resolution.loadFailureDetail } : {}),
1717
+ });
1718
+ }
1719
+ if (resolution.status === "substituted") {
1720
+ addWarning(state, {
1721
+ code: "font_substituted",
1722
+ severity,
1723
+ phase: "font_loading",
1724
+ message: `${resolution.requestedFamily} was resolved to ${resolution.resolvedFamily ?? "a configured substitute"}.`,
1725
+ remediation: "Supply an exact configured family when substitution is not acceptable.",
1726
+ resource: resolution.requestedFamily,
1727
+ });
1728
+ }
1729
+ if (resolution.faceMatch === "synthesized") {
1730
+ addWarning(state, {
1731
+ code: "font_face_synthesized",
1732
+ severity,
1733
+ phase: "font_loading",
1734
+ message: `The requested style, weight, or stretch for ${resolution.requestedFamily} requires synthesis.`,
1735
+ remediation: "Supply an exact configured face for this style, weight, and stretch.",
1736
+ resource: resolution.requestedFamily,
1737
+ });
1738
+ }
1739
+ if (resolution.metricCompatible === false) {
1740
+ addWarning(state, {
1741
+ code: "font_metric_mismatch",
1742
+ severity,
1743
+ phase: "font_loading",
1744
+ message: `The substitute selected for ${resolution.requestedFamily} is not metrically compatible.`,
1745
+ remediation: "Supply the exact family or review PageMap and line wrapping before publication.",
1746
+ resource: resolution.requestedFamily,
1747
+ });
1748
+ }
1749
+ if (resolution.glyphCoverage === "partial") {
1750
+ addWarning(state, {
1751
+ code: "font_glyph_coverage_partial",
1752
+ severity,
1753
+ phase: "font_loading",
1754
+ message: `The configured face for ${resolution.requestedFamily} covers only part of the rendered text.`,
1755
+ remediation: "Supply a face with complete glyph coverage for the reported sample.",
1756
+ resource: resolution.requestedFamily,
1757
+ });
1758
+ }
1759
+ }
1760
+ // Without a configured resolver every resolution is source: "browser" by construction, so
1761
+ // that alone signals an unattested environment. With one configured, a family a resolver
1762
+ // authoritatively reports missing is also source: "browser" (there is no face to attribute
1763
+ // it to) but already carries its own accurate font_unavailable warning above — it must not
1764
+ // also claim the whole environment is unattested, or misdirect the caller to "use a
1765
+ // resolver" when they already have.
1766
+ if (result.resolutions.some((resolution) => resolution.status === "unverified" || (!result.resolverConfigured && resolution.source === "browser"))) {
1767
+ addWarning(state, {
1768
+ code: "font_environment_unverified",
1769
+ severity: "warning",
1770
+ phase: "font_loading",
1771
+ message: "The browser loaded the requested CSS font families, but their exact files and substitutions are not attestable.",
1772
+ remediation: "Use an explicit verified font resolver when exact font identity is required.",
1773
+ });
1774
+ }
1775
+ // strictFonts is the gate #441 deliberately left to this issue. It demands an exact,
1776
+ // digest-identified, license-evidenced face with complete coverage — anything less is
1777
+ // a font environment the caller asked not to publish from. `verified` is computed once,
1778
+ // where every raw signal is in scope, by the resolution's producer (font-runtime.ts).
1779
+ const strictFailures = result.resolutions.filter((resolution) => !resolution.verified);
1780
+ if (options.strictFonts && strictFailures.length > 0) {
1781
+ fail("resource_policy_failure", "font_loading", "Strict font policy rejected a non-exact, unverified, or incompletely covered font outcome.", "Supply exact verified faces with complete glyph coverage or disable strictFonts.", {
1782
+ detail: strictFailures.map(({ requestId, status }) => `${requestId}:${status}`).join(", "),
1783
+ });
1784
+ }
1785
+ }
1786
+ function rethrowBrowserFontError(error) {
1787
+ if (error instanceof BrowserFontError) {
1788
+ // A Node-backed resolver's own failure (a symlink in a configured directory, a resource
1789
+ // limit discovery hit) crosses with its real code/phase/remediation intact — see
1790
+ // reconstructDocxodusExportError, called by the resolver wrapper before it throws. Prefer
1791
+ // that over the generic "malformed resolver response" framing below, which is only
1792
+ // accurate for a resolver that replied but violated the wire contract.
1793
+ if (error.cause instanceof DocxodusExportError) {
1794
+ const original = error.cause;
1795
+ fail(original.code, original.phase, original.message, original.remediation, {
1796
+ detail: original.detail,
1797
+ partUri: original.partUri,
1798
+ anchorId: original.anchorId,
1799
+ resource: original.resource,
1800
+ cause: error,
1801
+ });
1802
+ }
1803
+ fail(error.kind === "resource_limit" ? "resource_limit" : "resource_policy_failure", "font_loading", error.message, error.kind === "resource_limit"
1804
+ ? "Lower the number or size of configured fonts or raise the versioned font limit."
1805
+ : "Return one canonical, digest-verified resolver outcome for every font request.", { detail: error.detail, cause: error });
1806
+ }
1807
+ throw error;
1808
+ }
1809
+ // A src can be a multi-megabyte data URL, so it is never the label. Anchors and alt text
1810
+ // are bounded, stable across renders, and enough to find the element in the source.
1811
+ function visualResourceLabel(element, kind, index) {
1812
+ const anchor = element.closest("[data-source-anchor-id]")?.getAttribute("data-source-anchor-id");
1813
+ if (anchor)
1814
+ return `${kind}:${boundedText(anchor)}`;
1815
+ const alt = element instanceof HTMLImageElement ? element.alt.trim() : "";
1816
+ return alt ? `${kind}:${boundedText(alt)}` : `${kind}-${index + 1}`;
1817
+ }
1818
+ function boundedText(value, maximum = 80) {
1819
+ // Control characters would travel into the report and out to a terminal.
1820
+ const clean = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
1821
+ return clean.length > maximum ? `${clean.slice(0, maximum - 1)}…` : clean;
1822
+ }
1823
+ function visualResourceFailure(element, kind, index) {
1824
+ const anchorId = element.closest("[data-source-anchor-id]")
1825
+ ?.getAttribute("data-source-anchor-id") ?? undefined;
1826
+ return { resource: visualResourceLabel(element, kind, index), ...(anchorId ? { anchorId } : {}) };
1827
+ }
1828
+ // Probes report; they do not decide. Materialization routes their findings through the
1829
+ // unsupportedContent policy, while the offline reopen check treats any finding as a
1830
+ // defect in the serialized output — the same resource decoded moments earlier.
1831
+ async function decodeImages(document) {
1832
+ const images = Array.from(document.images);
1833
+ // decode() rejects for a broken image; settle every one before judging any, so a
1834
+ // single failure cannot hide the others from the report.
1835
+ await Promise.all(images.map((image) => typeof image.decode === "function"
1836
+ ? image.decode().catch(() => undefined)
1837
+ : Promise.resolve()));
1838
+ return images.flatMap((image, index) => image.complete && image.naturalWidth > 0 && image.naturalHeight > 0
1839
+ ? []
1840
+ : [visualResourceFailure(image, "image", index)]);
1841
+ }
1842
+ function validateInlineSvg(document) {
1843
+ // Without a viewBox or intrinsic size the element has no resolvable geometry, so
1844
+ // pagination would measure it at zero and the printed page would silently lose it.
1845
+ return Array.from(document.querySelectorAll("svg")).flatMap((svg, index) => svg.hasAttribute("viewBox")
1846
+ || (Number.parseFloat(svg.getAttribute("width") ?? "") > 0
1847
+ && Number.parseFloat(svg.getAttribute("height") ?? "") > 0)
1848
+ ? []
1849
+ : [visualResourceFailure(svg, "svg", index)]);
1850
+ }
1851
+ function admitVisualResourceFailures(state, options, failures, kind, warning) {
1852
+ for (const failure of failures) {
1853
+ addResource(state, { kind, status: "omitted", resource: failure.resource });
1854
+ policyWarning(state, options, { ...warning, ...failure });
1855
+ }
1856
+ }
1857
+ /**
1858
+ * Fails the offline reopen check for a resource that worked during materialization and
1859
+ * then did not survive serialization.
1860
+ *
1861
+ * Resources that already failed under the unsupportedContent policy are excluded: they
1862
+ * were reported once against the source document and warned through, and the reopened
1863
+ * tree naturally reproduces the same failure. Re-raising it here would turn every warned
1864
+ * export into a hard output_verification_failure.
1865
+ */
1866
+ function assertVisualResourcesIntact(failures, alreadyOmitted, what) {
1867
+ const regressions = failures.filter((failure) => !alreadyOmitted.has(failure.resource));
1868
+ if (regressions.length === 0)
1869
+ return;
1870
+ throw new Error(`${regressions.length} ${what} that materialized successfully did not survive serialization: `
1871
+ + `${regressions.slice(0, 8).map((failure) => failure.resource).join(", ")}`);
1872
+ }
1873
+ function omittedVisualResources(state, kind) {
1874
+ return new Set(state.resources
1875
+ .filter((resource) => resource.kind === kind && resource.status === "omitted" && resource.resource)
1876
+ .map((resource) => resource.resource));
1877
+ }
1878
+ function normalizeFragmentTargets(document, pages, state, options) {
1879
+ const firstFootnote = new Set();
1880
+ for (const item of Array.from(document.querySelectorAll(".page-footnotes [data-footnote-id]"))) {
1881
+ const id = item.dataset.footnoteId;
1882
+ if (id && !firstFootnote.has(id)) {
1883
+ item.id = `fn-${id}`;
1884
+ firstFootnote.add(id);
1885
+ }
1886
+ }
1887
+ const globalTargets = new Map();
1888
+ const pageTargets = new Map();
1889
+ const occurrences = new Map();
1890
+ for (const page of pages) {
1891
+ const localTargets = new Map();
1892
+ pageTargets.set(page, localTargets);
1893
+ for (const element of Array.from(page.querySelectorAll("[id]"))) {
1894
+ const original = element.id;
1895
+ const occurrence = occurrences.get(original) ?? 0;
1896
+ occurrences.set(original, occurrence + 1);
1897
+ const resolved = occurrence === 0
1898
+ ? original
1899
+ : `${original}--page-${page.dataset.pageNumber ?? "0"}-${occurrence}`;
1900
+ element.id = resolved;
1901
+ if (!localTargets.has(original))
1902
+ localTargets.set(original, resolved);
1903
+ if (!globalTargets.has(original))
1904
+ globalTargets.set(original, resolved);
1905
+ }
1906
+ }
1907
+ for (const link of Array.from(document.querySelectorAll("a[href^='#']"))) {
1908
+ const original = link.getAttribute("href").slice(1);
1909
+ let page = link.closest(".page-box");
1910
+ const target = (page ? pageTargets.get(page)?.get(original) : undefined)
1911
+ ?? globalTargets.get(original);
1912
+ if (target) {
1913
+ link.setAttribute("href", `#${target}`);
1914
+ }
1915
+ else {
1916
+ link.removeAttribute("href");
1917
+ link.setAttribute("aria-disabled", "true");
1918
+ link.tabIndex = -1;
1919
+ policyWarning(state, options, {
1920
+ code: "fragment_target_unavailable",
1921
+ phase: "running_story_placement",
1922
+ message: `Fragment target #${original} is unavailable in the final page tree.`,
1923
+ remediation: "Repair the bookmark/note target or use warn policy to retain an inert label.",
1924
+ resource: `#${original}`,
1925
+ });
1926
+ }
1927
+ }
1928
+ const ids = Array.from(document.querySelectorAll("[id]"), (element) => element.id);
1929
+ if (new Set(ids).size !== ids.length) {
1930
+ fail("output_verification_failure", "running_story_placement", "The final standalone document contains duplicate fragment IDs.", "Report the source document and duplicate target to Docxodus.");
1931
+ }
1932
+ }
1933
+ function standaloneStyle(pages) {
1934
+ const namedPages = new Map();
1935
+ for (const page of pages) {
1936
+ const sectionIndex = Number.parseInt(page.dataset.sectionIndex ?? "0", 10);
1937
+ const width = Number.parseFloat(page.style.width);
1938
+ const height = Number.parseFloat(page.style.height);
1939
+ const name = `docxodus-section-${sectionIndex}`;
1940
+ if (!namedPages.has(name))
1941
+ namedPages.set(name, { width, height });
1942
+ page.style.setProperty("page", name);
1943
+ page.dataset.pageWidthPt = String(width);
1944
+ page.dataset.pageHeightPt = String(height);
1945
+ }
1946
+ const rules = Array.from(namedPages, ([name, dimensions]) => `@page ${name} { size: ${dimensions.width}pt ${dimensions.height}pt; margin: 0; }`).join("\n");
1947
+ return `
1948
+ @page { margin: 0; }
1949
+ ${rules}
1950
+ html, body { margin: 0; padding: 0; }
1951
+ #pagination-container.page-container { min-height: 0 !important; }
1952
+ @media screen {
1953
+ html, body { background: #e5e7eb; }
1954
+ #pagination-container.page-container {
1955
+ display: flex !important;
1956
+ flex-direction: column;
1957
+ align-items: center;
1958
+ gap: 20px !important;
1959
+ padding: 20px !important;
1960
+ background: transparent !important;
1961
+ }
1962
+ .page-box { box-shadow: 0 2px 8px rgba(0, 0, 0, .18); }
1963
+ }
1964
+ @media print {
1965
+ html, body { margin: 0 !important; padding: 0 !important; background: transparent !important; }
1966
+ #pagination-container.page-container {
1967
+ display: block !important;
1968
+ gap: 0 !important;
1969
+ padding: 0 !important;
1970
+ background: transparent !important;
1971
+ }
1972
+ .page-box {
1973
+ zoom: 1 !important;
1974
+ transform: none !important;
1975
+ margin: 0 !important;
1976
+ box-shadow: none !important;
1977
+ break-after: page;
1978
+ page-break-after: always;
1979
+ }
1980
+ .page-box:last-child { break-after: auto; page-break-after: auto; }
1981
+ }`;
1982
+ }
1983
+ function finalizePageTree(document, pages, state, options) {
1984
+ document.querySelector("#pagination-staging, .page-staging")?.remove();
1985
+ for (const element of Array.from(document.querySelectorAll("*"))) {
1986
+ element.removeAttribute("contenteditable");
1987
+ element.removeAttribute("data-anchor");
1988
+ element.removeAttribute("data-committed-text");
1989
+ element.removeAttribute("draggable");
1990
+ element.style.removeProperty("will-change");
1991
+ element.style.removeProperty("contain");
1992
+ }
1993
+ for (const page of pages) {
1994
+ for (const property of [
1995
+ "zoom", "transform", "transform-origin", "margin-right", "margin-bottom", "box-shadow",
1996
+ ])
1997
+ page.style.removeProperty(property);
1998
+ }
1999
+ normalizeFragmentTargets(document, pages, state, options);
2000
+ document.documentElement.dataset.docxodusStandalone = "v1";
2001
+ const policy = document.querySelector("meta[http-equiv='Content-Security-Policy' i]");
2002
+ if (!policy) {
2003
+ fail("output_verification_failure", "running_story_placement", "The standalone document lost its Content Security Policy.", "Report the materializer defect; finalization must retain the closed policy.");
2004
+ }
2005
+ policy.content = STANDALONE_CSP;
2006
+ const style = document.createElement("style");
2007
+ style.id = "docxodus-standalone-style";
2008
+ style.textContent = standaloneStyle(pages);
2009
+ document.head.appendChild(style);
2010
+ }
2011
+ function assertNoClippedContent(document) {
2012
+ for (const footnotes of Array.from(document.querySelectorAll(".page-footnotes"))) {
2013
+ if (footnotes.scrollHeight <= footnotes.clientHeight + 1)
2014
+ continue;
2015
+ const boundary = footnotes.getBoundingClientRect().bottom;
2016
+ const hasVisibleOverflow = Array.from(footnotes.querySelectorAll("*"))
2017
+ .some((element) => element.getBoundingClientRect().bottom > boundary + 1);
2018
+ if (hasVisibleOverflow) {
2019
+ fail("pagination_failure", "running_story_placement", "A footnote continuation is clipped in the final page tree.", "Report the unsupported note structure; eligible text paragraphs are continued losslessly.");
2020
+ }
2021
+ }
2022
+ for (const content of Array.from(document.querySelectorAll(".page-content"))) {
2023
+ if (content.scrollHeight > content.clientHeight + 1) {
2024
+ const pageNumber = content.closest(".page-box")?.dataset.pageNumber ?? "unknown";
2025
+ const boundary = content.getBoundingClientRect().bottom;
2026
+ const overflow = Array.from(content.querySelectorAll("*"))
2027
+ .map((element) => ({
2028
+ element,
2029
+ bottom: element.getBoundingClientRect().bottom,
2030
+ }))
2031
+ .filter(({ bottom }) => bottom > boundary + 1)
2032
+ .sort((left, right) => right.bottom - left.bottom)
2033
+ .slice(0, 3)
2034
+ .map(({ element, bottom }) => `${element.localName}${element.className ? `.${String(element.className).trim().replace(/\s+/g, ".")}` : ""} (+${(bottom - boundary).toFixed(1)}px)`);
2035
+ // Collapsed trailing margins contribute to scrollHeight even though no
2036
+ // descendant pixels are clipped (a common final blank Word paragraph).
2037
+ if (overflow.length === 0)
2038
+ continue;
2039
+ fail("pagination_failure", "running_story_placement", `Page ${pageNumber} body content is clipped (${content.scrollHeight}px scroll height in a ${content.clientHeight}px band; ${overflow.join(", ")}).`, "Split the oversized block or reduce its dimensions before export.");
2040
+ }
2041
+ }
2042
+ }
2043
+ function treeSignature(document, pages) {
2044
+ const geometry = pages.map((page) => {
2045
+ const rect = page.getBoundingClientRect();
2046
+ return [
2047
+ page.dataset.pageNumber,
2048
+ page.dataset.sectionIndex,
2049
+ rect.width.toFixed(3),
2050
+ rect.height.toFixed(3),
2051
+ page.scrollWidth,
2052
+ page.scrollHeight,
2053
+ ];
2054
+ });
2055
+ const fragments = Array.from(document.querySelectorAll(".page-box [data-source-anchor-id]"), (element) => {
2056
+ const rect = element.getBoundingClientRect();
2057
+ const style = document.defaultView.getComputedStyle(element);
2058
+ return [
2059
+ element.dataset.sourceAnchorId,
2060
+ element.dataset.pageNumber,
2061
+ element.dataset.fragmentIndex,
2062
+ rect.left.toFixed(3),
2063
+ rect.top.toFixed(3),
2064
+ rect.width.toFixed(3),
2065
+ rect.height.toFixed(3),
2066
+ style.display,
2067
+ style.visibility,
2068
+ ];
2069
+ });
2070
+ return canonicalJson({
2071
+ fragments,
2072
+ geometry,
2073
+ nodes: document.querySelectorAll("*").length,
2074
+ textLength: document.body.textContent?.length ?? 0,
2075
+ });
2076
+ }
2077
+ async function animationFrame(document) {
2078
+ const view = document.defaultView;
2079
+ if (!view)
2080
+ throw new Error("render document has no defaultView");
2081
+ await new Promise((resolve) => view.requestAnimationFrame(() => resolve()));
2082
+ }
2083
+ async function awaitStableTree(document, pages) {
2084
+ const view = document.defaultView;
2085
+ if (!view)
2086
+ throw new Error("render document has no defaultView");
2087
+ let mutations = 0;
2088
+ let resizes = 0;
2089
+ const mutationObserver = new view.MutationObserver((records) => {
2090
+ mutations += records.length;
2091
+ });
2092
+ const resizeObserver = typeof view.ResizeObserver === "function"
2093
+ ? new view.ResizeObserver((records) => {
2094
+ resizes += records.length;
2095
+ })
2096
+ : undefined;
2097
+ mutationObserver.observe(document.documentElement, {
2098
+ attributes: true,
2099
+ characterData: true,
2100
+ childList: true,
2101
+ subtree: true,
2102
+ });
2103
+ for (const page of pages)
2104
+ resizeObserver?.observe(page);
2105
+ try {
2106
+ // Let initial ResizeObserver delivery and style/layout work settle before
2107
+ // beginning the contractual quiet interval.
2108
+ await animationFrame(document);
2109
+ await animationFrame(document);
2110
+ const first = treeSignature(document, pages);
2111
+ mutations = 0;
2112
+ resizes = 0;
2113
+ // The render frame is intentionally script-disabled; use the caller realm's
2114
+ // timer while continuing to observe and measure only the render realm.
2115
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
2116
+ await animationFrame(document);
2117
+ await animationFrame(document);
2118
+ const second = treeSignature(document, pages);
2119
+ if (first !== second || mutations !== 0 || resizes !== 0) {
2120
+ throw new PageTreeInstabilityError(`Final page tree changed during the quiet interval (mutations=${mutations}, resizes=${resizes})`);
2121
+ }
2122
+ return second;
2123
+ }
2124
+ finally {
2125
+ mutationObserver.disconnect();
2126
+ resizeObserver?.disconnect();
2127
+ }
2128
+ }
2129
+ function checkpointAttemptState(state) {
2130
+ return {
2131
+ readiness: state.readiness.length,
2132
+ warnings: state.warnings.length,
2133
+ fonts: state.fonts.length,
2134
+ resources: state.resources.length,
2135
+ unsupportedContent: state.unsupportedContent.length,
2136
+ };
2137
+ }
2138
+ function restoreAttemptState(state, checkpoint) {
2139
+ state.readiness.length = checkpoint.readiness;
2140
+ state.warnings.length = checkpoint.warnings;
2141
+ state.fonts.length = checkpoint.fonts;
2142
+ state.resources.length = checkpoint.resources;
2143
+ state.unsupportedContent.length = checkpoint.unsupportedContent;
2144
+ }
2145
+ function serializeDocument(document) {
2146
+ return `<!doctype html>\n${document.documentElement.outerHTML}`;
2147
+ }
2148
+ function automaticResourceCount(document) {
2149
+ let count = 0;
2150
+ let bytes = 0;
2151
+ const add = (value) => {
2152
+ count++;
2153
+ bytes += estimateDataUrlBytes(value) ?? 0;
2154
+ };
2155
+ const addCssUrls = (value) => {
2156
+ for (const token of cssSecurityTokens(value)) {
2157
+ if (token.kind === "url" || token.kind === "substitution")
2158
+ add(token.value);
2159
+ }
2160
+ };
2161
+ for (const element of Array.from(document.querySelectorAll("*"))) {
2162
+ for (const name of ["src", "poster", "background", "data"]) {
2163
+ const value = element.getAttribute(name);
2164
+ if (value)
2165
+ add(value);
2166
+ }
2167
+ const srcset = element.getAttribute("srcset");
2168
+ if (srcset)
2169
+ add(srcset.replace(/\s+(?:\d+(?:\.\d+)?x|\d+w)\s*$/i, ""));
2170
+ const isSvg = element.namespaceURI === "http://www.w3.org/2000/svg";
2171
+ if (isSvg) {
2172
+ for (const name of ["href", "xlink:href"]) {
2173
+ const value = element.getAttribute(name);
2174
+ if (value)
2175
+ add(value);
2176
+ }
2177
+ for (const name of SVG_URL_PRESENTATION_ATTRIBUTES) {
2178
+ const value = element.getAttribute(name);
2179
+ if (value)
2180
+ addCssUrls(value);
2181
+ }
2182
+ }
2183
+ const inlineStyle = element.getAttribute("style");
2184
+ if (inlineStyle)
2185
+ addCssUrls(inlineStyle);
2186
+ }
2187
+ for (const style of Array.from(document.querySelectorAll("style"))) {
2188
+ addCssUrls(style.textContent ?? "");
2189
+ }
2190
+ return { count, bytes };
2191
+ }
2192
+ async function layoutDigestForOptions(options) {
2193
+ const layoutContract = {
2194
+ title: options.title,
2195
+ reviewProfile: options.reviewProfile,
2196
+ reviewProfileAlreadyApplied: options.reviewProfileAlreadyApplied,
2197
+ commentProfile: options.commentProfile,
2198
+ pagination: {
2199
+ mode: "paginated",
2200
+ scale: 1,
2201
+ pageGap: 0,
2202
+ showPageNumbers: false,
2203
+ fragmentParagraphs: true,
2204
+ cssPrefix: "page-",
2205
+ },
2206
+ };
2207
+ return canonicalMaterialDigest("docxodus:layout-options:v1", layoutContract);
2208
+ }
2209
+ async function runtimePolicyDigestForOptions(options, runtimeAssets) {
2210
+ return canonicalMaterialDigest("docxodus:runtime-policy:v1", {
2211
+ assetGraphDigest: runtimeAssets.graphDigest,
2212
+ assetPackageVersion: runtimeAssets.packageVersion,
2213
+ isolation: {
2214
+ attachedSameOriginFrame: true,
2215
+ scripts: "denied",
2216
+ automaticNetwork: "denied",
2217
+ sandbox: ["allow-same-origin"],
2218
+ },
2219
+ limits: options.limits,
2220
+ strictFonts: options.strictFonts,
2221
+ timeoutMs: options.timeoutMs,
2222
+ unsupportedContent: options.unsupportedContent,
2223
+ });
2224
+ }
2225
+ function observedRuntimeFacts(document) {
2226
+ const view = document.defaultView;
2227
+ if (!view) {
2228
+ fail("unsupported_runtime", "browser_launch", "The render document has no browser identity.", "Use an attached browser document.");
2229
+ }
2230
+ return {
2231
+ runtimeKind: "browser",
2232
+ locale: view.navigator.language || "und",
2233
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
2234
+ viewport: [view.innerWidth, view.innerHeight],
2235
+ deviceScaleFactor: view.devicePixelRatio,
2236
+ media: {
2237
+ colorScheme: view.matchMedia("(prefers-color-scheme: dark)").matches
2238
+ ? "dark"
2239
+ : view.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "no-preference",
2240
+ reducedMotion: view.matchMedia("(prefers-reduced-motion: reduce)").matches
2241
+ ? "reduce" : "no-preference",
2242
+ forcedColors: view.matchMedia("(forced-colors: active)").matches ? "active" : "none",
2243
+ printMedia: true,
2244
+ },
2245
+ networkIsolation: "contextRestricted",
2246
+ };
2247
+ }
2248
+ async function rendererIdentity(document, layoutDigest, runtimePolicyDigest, runtimeAssets, runtimeVersion, fonts, fontIdentity) {
2249
+ const view = document.defaultView;
2250
+ if (!view) {
2251
+ fail("unsupported_runtime", "browser_launch", "The render document has no browser identity.", "Use an attached browser document.");
2252
+ }
2253
+ const fontRecords = fonts.map((font) => ({ ...font }))
2254
+ .sort((left, right) => compareCodeUnits(canonicalJson(left), canonicalJson(right)));
2255
+ // The font runtime already produced the canonical configuration identity, binding the
2256
+ // substitution contract and every resolution. Re-deriving one here would let the
2257
+ // fingerprint disagree with the report about the same font environment.
2258
+ const fontDigest = fontIdentity.resolutionDigest;
2259
+ const observed = observedRuntimeFacts(document);
2260
+ const fingerprint = {
2261
+ contract: "docxodus-standalone-browser-v1",
2262
+ verification: "browserObserved",
2263
+ runtimeAssets,
2264
+ runtimeVersion,
2265
+ paginatorContractVersion: 1,
2266
+ pageMapSchemaVersion: 1,
2267
+ renderReportSchemaVersion: 2,
2268
+ layoutDigest,
2269
+ runtimePolicyDigest,
2270
+ fontConfigurationDigest: fontDigest,
2271
+ fonts: fontRecords,
2272
+ observed,
2273
+ };
2274
+ return {
2275
+ rendererFingerprint: await canonicalMaterialDigest("docxodus:renderer-fingerprint:v1", fingerprint),
2276
+ observed,
2277
+ fontIdentity,
2278
+ };
2279
+ }
2280
+ function reportPages(pageMap) {
2281
+ return pageMap.pages.map((page) => ({ ...page }));
2282
+ }
2283
+ function storyForAnchor(anchorId) {
2284
+ const first = anchorId.indexOf(":");
2285
+ const second = first < 0 ? -1 : anchorId.indexOf(":", first + 1);
2286
+ const scope = first >= 0 && second > first ? anchorId.slice(first + 1, second) : "body";
2287
+ if (scope.startsWith("hdr"))
2288
+ return "header";
2289
+ if (scope.startsWith("ftr"))
2290
+ return "footer";
2291
+ if (scope === "fn")
2292
+ return "footnote";
2293
+ if (scope === "en")
2294
+ return "endnote";
2295
+ if (scope === "cmt")
2296
+ return "comment";
2297
+ return "body";
2298
+ }
2299
+ function visibleRectWithinPage(document, element, page) {
2300
+ const pageRect = page.getBoundingClientRect();
2301
+ const rect = element.getBoundingClientRect();
2302
+ let left = Math.max(rect.left, pageRect.left);
2303
+ let top = Math.max(rect.top, pageRect.top);
2304
+ let right = Math.min(rect.right, pageRect.right);
2305
+ let bottom = Math.min(rect.bottom, pageRect.bottom);
2306
+ const clips = (value) => value === "hidden" || value === "clip" || value === "scroll" || value === "auto";
2307
+ for (let ancestor = element.parentElement; ancestor && ancestor !== page; ancestor = ancestor.parentElement) {
2308
+ const style = document.defaultView.getComputedStyle(ancestor);
2309
+ const ancestorRect = ancestor.getBoundingClientRect();
2310
+ if (clips(style.overflowX)) {
2311
+ left = Math.max(left, ancestorRect.left);
2312
+ right = Math.min(right, ancestorRect.right);
2313
+ }
2314
+ if (clips(style.overflowY)) {
2315
+ top = Math.max(top, ancestorRect.top);
2316
+ bottom = Math.min(bottom, ancestorRect.bottom);
2317
+ }
2318
+ }
2319
+ return { left, top, right, bottom };
2320
+ }
2321
+ function assertStandaloneResourceAudit(document) {
2322
+ if (document.querySelector("script, iframe, object, embed, video, audio, source, track, link, base, meta[http-equiv='refresh' i]")) {
2323
+ throw new Error("offline output contains active or external-loading content");
2324
+ }
2325
+ const meta = document.querySelector("meta[http-equiv='Content-Security-Policy' i]");
2326
+ if (!meta || meta.content !== STANDALONE_CSP) {
2327
+ throw new Error("offline output CSP is missing or changed");
2328
+ }
2329
+ for (const element of Array.from(document.querySelectorAll("*"))) {
2330
+ for (const attribute of Array.from(element.attributes)) {
2331
+ const name = attribute.name.toLowerCase();
2332
+ if (name.startsWith("on"))
2333
+ throw new Error(`offline output retained ${attribute.name}`);
2334
+ if (name === "style") {
2335
+ const unsafe = cssSecurityTokens(attribute.value).find((token) => token.kind !== "url" || !automaticUrlAllowed(token.value, true));
2336
+ if (unsafe)
2337
+ throw new Error(`offline inline style retained ${unsafe.kind}`);
2338
+ }
2339
+ if (name === "srcset" && !standaloneSrcsetAllowed(attribute.value)) {
2340
+ throw new Error("offline output retained an unsafe srcset");
2341
+ }
2342
+ if (name === "href" && element.localName === "a") {
2343
+ const href = attribute.value.trim();
2344
+ if (!href.startsWith("#") && !/^(?:https?|mailto|tel):/i.test(href)) {
2345
+ throw new Error(`offline output retained unsafe hyperlink ${href}`);
2346
+ }
2347
+ }
2348
+ else if (name === "ping" && element.localName === "a") {
2349
+ throw new Error("offline output retained a hyperlink ping target");
2350
+ }
2351
+ else if (["src", "poster", "data", "action", "formaction", "background", "xlink:href", "href"].includes(name)) {
2352
+ const allowFragment = element.namespaceURI === "http://www.w3.org/2000/svg";
2353
+ if (!automaticUrlAllowed(attribute.value, allowFragment)) {
2354
+ throw new Error(`offline output retained automatic URL ${attribute.value}`);
2355
+ }
2356
+ }
2357
+ }
2358
+ if (element.localName === "style") {
2359
+ const unsafe = cssSecurityTokens(element.textContent ?? "").find((token) => token.kind !== "url" || !automaticUrlAllowed(token.value, true));
2360
+ if (unsafe)
2361
+ throw new Error(`offline stylesheet retained ${unsafe.kind}`);
2362
+ }
2363
+ }
2364
+ }
2365
+ async function verifyOfflineReopen(hostDocument, html, expectedPageMap, state) {
2366
+ const frame = await createIsolatedFrame(hostDocument, state, html, "output_verification");
2367
+ try {
2368
+ const reopened = frame.contentDocument;
2369
+ const pages = Array.from(reopened.querySelectorAll(".page-box"));
2370
+ await awaitFonts(reopened);
2371
+ assertVisualResourcesIntact(await decodeImages(reopened), omittedVisualResources(state, "image"), "embedded images");
2372
+ assertVisualResourcesIntact(validateInlineSvg(reopened), omittedVisualResources(state, "svg"), "inline SVG elements");
2373
+ await awaitStableTree(reopened, pages);
2374
+ countDomNodes(reopened, state.limits.domNodes, "output_verification");
2375
+ const resources = automaticResourceCount(reopened);
2376
+ enforceLimit(resources.count, state.limits.automaticResources, "automaticResources", "output_verification");
2377
+ enforceLimit(resources.bytes, state.limits.automaticResourceBytes, "automaticResourceBytes", "output_verification");
2378
+ assertStandaloneResourceAudit(reopened);
2379
+ if (pages.length !== expectedPageMap.pages.length) {
2380
+ throw new Error(`offline page count changed (${pages.length} != ${expectedPageMap.pages.length})`);
2381
+ }
2382
+ for (let index = 0; index < pages.length; index++) {
2383
+ const page = pages[index];
2384
+ const expected = expectedPageMap.pages[index];
2385
+ const rect = page.getBoundingClientRect();
2386
+ const widthPt = rect.width * 72 / 96;
2387
+ const heightPt = rect.height * 72 / 96;
2388
+ if (Math.abs(widthPt - expected.width) > 0.1
2389
+ || Math.abs(heightPt - expected.height) > 0.1
2390
+ || Number.parseInt(page.dataset.pageNumber ?? "0", 10) !== expected.pageNumber
2391
+ || Number.parseInt(page.dataset.pageInSection ?? "0", 10) !== expected.pageInSection
2392
+ || Number.parseInt(page.dataset.sectionIndex ?? "0", 10) !== expected.sectionIndex
2393
+ || reopened.defaultView.getComputedStyle(page).page !== expected.pageName) {
2394
+ throw new Error(`offline geometry changed on page ${expected.pageNumber}`);
2395
+ }
2396
+ }
2397
+ const actualFragments = [];
2398
+ const expectedByPageNumber = new Map(expectedPageMap.pages.map((candidate) => [candidate.pageNumber, candidate]));
2399
+ for (const page of pages) {
2400
+ const pageNumber = Number.parseInt(page.dataset.pageNumber ?? "0", 10);
2401
+ const expectedPage = expectedByPageNumber.get(pageNumber);
2402
+ if (!expectedPage)
2403
+ throw new Error(`offline output added page ${pageNumber}`);
2404
+ const pageRect = page.getBoundingClientRect();
2405
+ const pointPerRenderedX = expectedPage.width / pageRect.width;
2406
+ const pointPerRenderedY = expectedPage.height / pageRect.height;
2407
+ for (const element of Array.from(page.querySelectorAll("[data-source-anchor-id][data-page-fragment-id]"))) {
2408
+ const style = reopened.defaultView.getComputedStyle(element);
2409
+ if (style.display === "none" || style.visibility === "hidden")
2410
+ continue;
2411
+ const rect = element.getBoundingClientRect();
2412
+ const visible = visibleRectWithinPage(reopened, element, page);
2413
+ if (rect.width <= 0 || rect.height <= 0
2414
+ || visible.right <= visible.left || visible.bottom <= visible.top)
2415
+ continue;
2416
+ const anchorId = element.dataset.sourceAnchorId;
2417
+ actualFragments.push({
2418
+ fragmentId: element.dataset.pageFragmentId,
2419
+ anchorId,
2420
+ fragmentIndex: Number.parseInt(element.dataset.fragmentIndex ?? "-1", 10),
2421
+ pageNumber,
2422
+ geometry: {
2423
+ x: (visible.left - pageRect.left) * pointPerRenderedX,
2424
+ y: (visible.top - pageRect.top) * pointPerRenderedY,
2425
+ width: (visible.right - visible.left) * pointPerRenderedX,
2426
+ height: (visible.bottom - visible.top) * pointPerRenderedY,
2427
+ },
2428
+ story: storyForAnchor(anchorId),
2429
+ inTableCell: element.matches("td,th") || element.closest("td,th") !== null,
2430
+ });
2431
+ }
2432
+ }
2433
+ if (actualFragments.length !== expectedPageMap.fragments.length) {
2434
+ throw new Error(`offline fragment inventory changed (${actualFragments.length} != ${expectedPageMap.fragments.length})`);
2435
+ }
2436
+ for (let index = 0; index < actualFragments.length; index++) {
2437
+ const actual = actualFragments[index];
2438
+ const expected = expectedPageMap.fragments[index];
2439
+ if (actual.fragmentId !== expected.fragmentId
2440
+ || actual.anchorId !== expected.anchorId
2441
+ || actual.fragmentIndex !== expected.fragmentIndex
2442
+ || actual.pageNumber !== expected.pageNumber
2443
+ || actual.story !== expected.story
2444
+ || actual.inTableCell !== expected.inTableCell
2445
+ || Math.abs(actual.geometry.x - expected.geometry.x) > 0.1
2446
+ || Math.abs(actual.geometry.y - expected.geometry.y) > 0.1
2447
+ || Math.abs(actual.geometry.width - expected.geometry.width) > 0.1
2448
+ || Math.abs(actual.geometry.height - expected.geometry.height) > 0.1) {
2449
+ throw new Error(`offline PageMap fragment changed at index ${index}`);
2450
+ }
2451
+ }
2452
+ }
2453
+ finally {
2454
+ frame.remove();
2455
+ }
2456
+ }
2457
+ function reportBase(manifest, sourceBytes, derivedManifest, derivedBytes, options, layoutDigest, runtimePolicyDigest, state, fontIdentity) {
2458
+ return {
2459
+ schema: REPORT_SCHEMA,
2460
+ schemaVersion: 2,
2461
+ source: {
2462
+ rawPackageBytesDigest: manifest.rawPackageBytesDigest.value.toLowerCase(),
2463
+ byteLength: sourceBytes.byteLength,
2464
+ documentVersion: options.documentVersion,
2465
+ },
2466
+ ...(derivedManifest && derivedBytes ? {
2467
+ derivedProfileSource: {
2468
+ rawPackageBytesDigest: derivedManifest.rawPackageBytesDigest.value,
2469
+ byteLength: derivedBytes.byteLength,
2470
+ },
2471
+ } : {}),
2472
+ options: {
2473
+ reviewProfile: options.reviewProfile,
2474
+ reviewProfileAlreadyApplied: options.reviewProfileAlreadyApplied,
2475
+ commentProfile: options.commentProfile,
2476
+ title: options.title,
2477
+ outputs: ["html"],
2478
+ layoutDigest,
2479
+ runtimePolicyDigest,
2480
+ policy: {
2481
+ unsupportedContent: options.unsupportedContent,
2482
+ strictFonts: options.strictFonts,
2483
+ timeoutMs: options.timeoutMs,
2484
+ limits: { ...options.limits },
2485
+ },
2486
+ },
2487
+ readiness: state.readiness.map((outcome) => ({ ...outcome, pending: [...outcome.pending] })),
2488
+ fonts: state.fonts.map((font) => ({ ...font })),
2489
+ resources: state.resources.map((resource) => ({ ...resource })),
2490
+ unsupportedContent: state.unsupportedContent.map((outcome) => ({ ...outcome })),
2491
+ warnings: state.warnings.map((warning) => ({ ...warning })),
2492
+ ...(fontIdentity ? { fontIdentity: { ...fontIdentity } } : {}),
2493
+ };
2494
+ }
2495
+ function failureReport(manifest, sourceBytes, derivedManifest, derivedBytes, options, layoutDigest, runtimePolicyDigest, rendererFingerprint, observed, fontIdentity, state, error, pageMapWasMaterialized, htmlWasMaterialized, pages) {
2496
+ const unavailable = [];
2497
+ if (!rendererFingerprint)
2498
+ unavailable.push({
2499
+ field: "environment.rendererFingerprint",
2500
+ reasonCode: "notReached",
2501
+ detail: `Failure occurred during ${error.phase} before renderer identity completed.`,
2502
+ });
2503
+ unavailable.push({
2504
+ field: "bindings.pageMapDigest",
2505
+ reasonCode: pageMapWasMaterialized ? "discardedOnFailure" : "notReached",
2506
+ detail: pageMapWasMaterialized
2507
+ ? "The materialized PageMap is discarded because the render did not complete."
2508
+ : "PageMap materialization was not reached.",
2509
+ }, {
2510
+ field: "bindings.htmlDigest",
2511
+ reasonCode: htmlWasMaterialized ? "discardedOnFailure" : "notReached",
2512
+ detail: htmlWasMaterialized
2513
+ ? "The selected HTML payload is discarded because the render did not complete."
2514
+ : "Standalone HTML materialization was not reached.",
2515
+ }, {
2516
+ field: "bindings.pdfDigest",
2517
+ reasonCode: "notRequested",
2518
+ detail: "PDF output was not selected by the browser materializer.",
2519
+ });
2520
+ return {
2521
+ ...reportBase(manifest, sourceBytes, derivedManifest, derivedBytes, options, layoutDigest, runtimePolicyDigest, state, fontIdentity),
2522
+ status: "failed",
2523
+ failure: {
2524
+ code: error.code,
2525
+ severity: "error",
2526
+ phase: error.phase,
2527
+ message: error.message,
2528
+ remediation: error.remediation,
2529
+ ...(error.detail ? { detail: error.detail } : {}),
2530
+ ...(error.pending ? { pending: [...error.pending] } : {}),
2531
+ ...(error.partUri ? { partUri: error.partUri } : {}),
2532
+ ...(error.anchorId ? { anchorId: error.anchorId } : {}),
2533
+ ...(error.resource ? { resource: error.resource } : {}),
2534
+ },
2535
+ ...(rendererFingerprint && observed
2536
+ ? {
2537
+ environment: {
2538
+ rendererFingerprint,
2539
+ verification: "browserObserved",
2540
+ fidelityTier: "unbaselined",
2541
+ observed,
2542
+ },
2543
+ }
2544
+ : {}),
2545
+ ...(pages ? { partial: { pages } } : {}),
2546
+ unavailable,
2547
+ };
2548
+ }
2549
+ function ensureTerminalReadiness(state, error) {
2550
+ const last = state.readiness.at(-1);
2551
+ if (last && last.status !== "complete")
2552
+ return;
2553
+ state.readiness.push({
2554
+ phase: error.phase,
2555
+ status: error.code === "operation_cancelled" ? "cancelled" : "failed",
2556
+ elapsedMs: 0,
2557
+ pending: error.pending ? [...error.pending] : [],
2558
+ });
2559
+ }
2560
+ function asExportError(error, phase) {
2561
+ if (error instanceof DocxodusExportError)
2562
+ return error;
2563
+ const message = error instanceof Error ? error.message : String(error);
2564
+ const code = phase === "pagination"
2565
+ || phase === "running_story_placement"
2566
+ || phase === "page_tree_stability"
2567
+ ? "pagination_failure"
2568
+ : phase === "output_verification"
2569
+ ? "output_verification_failure"
2570
+ : "conversion_failure";
2571
+ return new DocxodusExportError(code, phase, message, "Inspect the render report and source document, then retry with supported content.", { cause: error });
2572
+ }
2573
+ /**
2574
+ * Convert DOCX bytes into a complete offline HTML document containing only the
2575
+ * finalized fixed page tree and its inline resources.
2576
+ */
2577
+ export async function convertDocxToPaginatedHtml(document, requestedOptions) {
2578
+ // Both snapshots happen before the first await. Later caller mutations cannot
2579
+ // split the verified package identity from the bytes transferred to WASM.
2580
+ const options = normalizeOptions(requestedOptions);
2581
+ const startedAt = monotonicNow();
2582
+ const ownedOperationAbort = new AbortController();
2583
+ const sourcePromise = ownedBytes(document, options.limits.compressedDocxBytes, options.signal);
2584
+ const state = {
2585
+ startedAt,
2586
+ deadline: startedAt + options.timeoutMs,
2587
+ phase: "input_validation",
2588
+ readiness: [],
2589
+ warnings: [],
2590
+ fonts: [],
2591
+ resources: [],
2592
+ unsupportedContent: [],
2593
+ limits: options.limits,
2594
+ signal: options.signal,
2595
+ };
2596
+ let sourceBytes;
2597
+ let renderBytes;
2598
+ let derivedBytes;
2599
+ let worker;
2600
+ let frame;
2601
+ let manifest;
2602
+ let derivedManifest;
2603
+ let runtimeAssets;
2604
+ let runtimeVersion;
2605
+ let layoutDigest = "";
2606
+ let runtimePolicyDigest = "";
2607
+ let rendererFingerprint;
2608
+ let observed;
2609
+ let fontIdentity;
2610
+ let pageMapWasMaterialized = false;
2611
+ let htmlWasMaterialized = false;
2612
+ let pagesForFailure;
2613
+ try {
2614
+ sourceBytes = await runPhase(state, "input_validation", ["document bytes"], () => sourcePromise);
2615
+ if (sourceBytes.byteLength === 0) {
2616
+ fail("invalid_document", "input_validation", "The DOCX input is empty.", "Pass a non-empty OPC package.");
2617
+ }
2618
+ layoutDigest = await layoutDigestForOptions(options);
2619
+ runtimeAssets = await runPhase(state, "wasm_initialization", ["runtime asset graph"], () => loadRuntimeAssetIdentity(options.wasmBasePath, ownedOperationAbort.signal));
2620
+ runtimePolicyDigest = await runtimePolicyDigestForOptions(options, runtimeAssets);
2621
+ worker = await runPhase(state, "wasm_initialization", ["WASM worker"], async () => {
2622
+ const created = await createWorkerDocxodus({
2623
+ wasmBasePath: options.wasmBasePath,
2624
+ signal: ownedOperationAbort.signal,
2625
+ });
2626
+ // runPhase may already have returned a timeout/cancellation while asynchronous
2627
+ // worker initialization was still in flight. Never orphan that late worker.
2628
+ if (ownedOperationAbort.signal.aborted
2629
+ || options.signal?.aborted
2630
+ || monotonicNow() >= state.deadline) {
2631
+ created.terminate();
2632
+ }
2633
+ return created;
2634
+ });
2635
+ runtimeVersion = await runPhase(state, "wasm_initialization", ["WASM runtime identity"], () => worker.getVersion());
2636
+ const manifestJson = await runPhase(state, "package_preflight", ["source package manifest"], () => worker.generatePackageManifestJson(sourceBytes, inspectionLimits(options)));
2637
+ manifest = validatePackageManifestJson(manifestJson);
2638
+ await preflightManifest(manifest, sourceBytes, options, state, true);
2639
+ renderBytes = sourceBytes;
2640
+ if (options.reviewProfile !== "markup" && !options.reviewProfileAlreadyApplied) {
2641
+ derivedBytes = await runPhase(state, "package_preflight", [`${options.reviewProfile} review-profile projection`], async () => {
2642
+ try {
2643
+ return await worker.projectReviewProfile(sourceBytes, options.reviewProfile, options.limits.compressedDocxBytes);
2644
+ }
2645
+ catch (error) {
2646
+ if (workerErrorCode(error) === "resource_limit") {
2647
+ fail("resource_limit", "package_preflight", "The derived review-profile package exceeds compressedDocxBytes.", "Use a smaller document or remove revision history before export.");
2648
+ }
2649
+ throw error;
2650
+ }
2651
+ });
2652
+ enforceLimit(derivedBytes.byteLength, options.limits.compressedDocxBytes, "compressedDocxBytes", "package_preflight");
2653
+ const derivedManifestJson = await runPhase(state, "package_preflight", ["derived package manifest"], () => worker.generatePackageManifestJson(derivedBytes, inspectionLimits(options)));
2654
+ derivedManifest = validatePackageManifestJson(derivedManifestJson);
2655
+ await preflightManifest(derivedManifest, derivedBytes, options, state, false);
2656
+ if (derivedManifest.facts.revisions.total !== 0) {
2657
+ fail("conversion_failure", "package_preflight", `The ${options.reviewProfile} projection retained native tracked revisions.`, "Report the projection defect; derived bytes must be fully accepted or rejected exactly once.");
2658
+ }
2659
+ if (manifest.facts.revisions.total > 0
2660
+ && constantTimeDigestEqual(manifest.rawPackageBytesDigest.value, derivedManifest.rawPackageBytesDigest.value)) {
2661
+ fail("conversion_failure", "package_preflight", `The ${options.reviewProfile} projection did not change revision-bearing package bytes.`, "Report the projection defect; changed review state requires a distinct derived identity.");
2662
+ }
2663
+ renderBytes = derivedBytes;
2664
+ }
2665
+ const convertedHtml = await runPhase(state, "docx_conversion", ["WASM conversion"], async () => {
2666
+ try {
2667
+ return await worker.convertDocxToHtml(renderBytes, conversionOptions(options), options.limits.htmlOutputBytes);
2668
+ }
2669
+ catch (error) {
2670
+ if (workerErrorCode(error) === "resource_limit") {
2671
+ fail("resource_limit", "docx_conversion", "Converted HTML exceeds htmlOutputBytes before main-thread materialization.", "Use a smaller document or lower-complexity conversion profile.");
2672
+ }
2673
+ throw error;
2674
+ }
2675
+ });
2676
+ preflightConvertedHtml(convertedHtml, options);
2677
+ const attemptCheckpoint = checkpointAttemptState(state);
2678
+ let finalized;
2679
+ let firstAttemptSignature;
2680
+ let firstAttemptFontIdentity;
2681
+ for (let attempt = 1; attempt <= 2; attempt++) {
2682
+ try {
2683
+ frame = await createIsolatedFrame(globalThis.document, state, bootstrapHtml(options.title));
2684
+ const renderDocument = frame.contentDocument;
2685
+ sanitizeConvertedDocument(renderDocument, convertedHtml, state, options);
2686
+ inventoryConvertedContent(renderDocument, state, options);
2687
+ // The task samples every rendered face before the phase begins, so its pending
2688
+ // list names the outstanding font requests rather than a generic wait.
2689
+ const restoreStaging = revealMeasurementStaging(renderDocument);
2690
+ try {
2691
+ const fontTask = createBrowserFontTask(renderDocument, options.fontResolver, state.limits);
2692
+ await runPhase(state, "font_loading", fontTask.pending(), async () => {
2693
+ await awaitFonts(renderDocument);
2694
+ const resolved = await fontTask.wait(ownedOperationAbort.signal)
2695
+ .catch(rethrowBrowserFontError);
2696
+ recordFontResolution(resolved, state, options);
2697
+ // Both attempts put the identical question to the resolver. One that answers
2698
+ // differently the second time makes the page tree unreproducible and would bind
2699
+ // the fingerprint to a configuration the output was never laid out with, so it
2700
+ // is rejected here rather than surfacing later as page-tree instability.
2701
+ //
2702
+ // Compared on resolverDigest, not the broader resolutionDigest: the latter also
2703
+ // reflects browser-observed load outcomes (did this face happen to decode this
2704
+ // attempt), which is not a claim about the resolver's determinism and would
2705
+ // misattribute a flaky decode as "the resolver returned a different configuration."
2706
+ // resolverDigest is undefined with no resolver configured, in which case there is
2707
+ // no resolver-drift question to ask.
2708
+ if (resolved.identity.resolverDigest !== undefined) {
2709
+ if (attempt === 1) {
2710
+ firstAttemptFontIdentity = resolved.identity.resolverDigest;
2711
+ }
2712
+ else if (firstAttemptFontIdentity !== undefined
2713
+ && firstAttemptFontIdentity !== resolved.identity.resolverDigest) {
2714
+ fail("resource_policy_failure", "font_loading", "The font resolver returned a different configuration for the second pristine attempt.", "Return one deterministic resolution for an identical set of font requests.");
2715
+ }
2716
+ }
2717
+ });
2718
+ }
2719
+ finally {
2720
+ restoreStaging();
2721
+ }
2722
+ await runPhase(state, "image_decoding", ["embedded images"], async () => {
2723
+ admitVisualResourceFailures(state, options, await decodeImages(renderDocument), "image", {
2724
+ code: "image_decode_failed",
2725
+ phase: "image_decoding",
2726
+ message: "An embedded image did not decode and will print as empty space.",
2727
+ remediation: "Re-encode the image in a browser-supported format, or use unsupportedContent: strict to reject the document.",
2728
+ });
2729
+ });
2730
+ await runPhase(state, "chart_svg_materialization", ["inline SVG"], () => {
2731
+ admitVisualResourceFailures(state, options, validateInlineSvg(renderDocument), "svg", {
2732
+ code: "chart_svg_unmeasurable",
2733
+ phase: "chart_svg_materialization",
2734
+ message: "An inline SVG has neither a viewBox nor explicit dimensions and cannot be laid out.",
2735
+ remediation: "Give the SVG a viewBox or explicit width and height, or use unsupportedContent: strict to reject the document.",
2736
+ });
2737
+ });
2738
+ const staging = renderDocument.getElementById("pagination-staging");
2739
+ const container = renderDocument.getElementById("pagination-container");
2740
+ if (!staging || !container) {
2741
+ fail("conversion_failure", "docx_conversion", "Paginated conversion did not produce staging and page containers.", "Use the paginated converter contract and report the malformed conversion output.");
2742
+ }
2743
+ const engine = new PaginationEngine(staging, container, {
2744
+ scale: 1,
2745
+ cssPrefix: "page-",
2746
+ showPageNumbers: false,
2747
+ pageGap: 0,
2748
+ fragmentParagraphs: true,
2749
+ // Running-story placement below changes the visible fragment set, and this path always
2750
+ // restamps through normalizePageMapFragmentIdentities(); stamping inside paginate()
2751
+ // would be a second full forced-layout pass whose result is immediately overwritten.
2752
+ deferFragmentIdentities: true,
2753
+ checkCancellation: () => {
2754
+ if (state.signal?.aborted) {
2755
+ fail("operation_cancelled", state.phase, `Export was cancelled during ${state.phase}.`, "Retry with a non-aborted signal.", { pending: ["page layout"] });
2756
+ }
2757
+ if (monotonicNow() >= state.deadline) {
2758
+ fail("readiness_timeout", state.phase, `Export timed out during ${state.phase}.`, "Increase timeoutMs or reduce document layout complexity.", { detail: "cooperative pagination checkpoint", pending: ["page layout"] });
2759
+ }
2760
+ },
2761
+ checkPageCount: (prospectivePageCount) => {
2762
+ enforceLimit(prospectivePageCount, options.limits.finalPages, "finalPages", "pagination");
2763
+ },
2764
+ });
2765
+ const pagination = await runPhase(state, "pagination", ["page layout"], () => engine.paginate());
2766
+ enforceLimit(pagination.totalPages, options.limits.finalPages, "finalPages", "pagination");
2767
+ const pages = pagination.pages.map((page) => page.element);
2768
+ if (pages.length === 0) {
2769
+ fail("pagination_failure", "pagination", "Pagination produced no pages.", "Verify that the DOCX has a renderable main document body.");
2770
+ }
2771
+ await runPhase(state, "running_story_placement", ["headers, footers, and notes"], () => {
2772
+ finalizePageTree(renderDocument, pages, state, options);
2773
+ // Running-story placement changes the visible fragment set. This is
2774
+ // the sole identity-writing normalization pass; PageMap measurement
2775
+ // below remains read-only.
2776
+ engine.normalizePageMapFragmentIdentities();
2777
+ assertNoClippedContent(renderDocument);
2778
+ });
2779
+ countDomNodes(renderDocument, options.limits.domNodes, "running_story_placement");
2780
+ const automaticResources = automaticResourceCount(renderDocument);
2781
+ enforceLimit(automaticResources.count, options.limits.automaticResources, "automaticResources", "running_story_placement");
2782
+ enforceLimit(automaticResources.bytes, options.limits.automaticResourceBytes, "automaticResourceBytes", "running_story_placement");
2783
+ const stableSignature = await runPhase(state, "page_tree_stability", ["fixed page tree"], () => awaitStableTree(renderDocument, pages));
2784
+ if (attempt === 1) {
2785
+ firstAttemptSignature = stableSignature;
2786
+ frame.remove();
2787
+ frame = undefined;
2788
+ restoreAttemptState(state, attemptCheckpoint);
2789
+ continue;
2790
+ }
2791
+ if (!firstAttemptSignature || firstAttemptSignature !== stableSignature) {
2792
+ throw new PageTreeInstabilityError("Two layouts created from the same pristine converted HTML produced different final page trees");
2793
+ }
2794
+ finalized = { frame, document: renderDocument, engine, pages };
2795
+ break;
2796
+ }
2797
+ catch (error) {
2798
+ frame?.remove();
2799
+ frame = undefined;
2800
+ if (!(error instanceof PageTreeInstabilityError) || attempt === 2)
2801
+ throw error;
2802
+ restoreAttemptState(state, attemptCheckpoint);
2803
+ addWarning(state, {
2804
+ code: "page_tree_retry",
2805
+ severity: "warning",
2806
+ phase: "page_tree_stability",
2807
+ message: "The finalized page tree changed during its quiet interval and was rebuilt from pristine converted HTML.",
2808
+ remediation: "No action is required unless repeated exports fail page-tree stability.",
2809
+ });
2810
+ }
2811
+ }
2812
+ if (!finalized) {
2813
+ fail("pagination_failure", "page_tree_stability", "The final page tree did not stabilize within two attempts.", "Remove asynchronous layout inputs or report the source document to Docxodus.");
2814
+ }
2815
+ frame = finalized.frame;
2816
+ const { document: renderDocument, engine, pages } = finalized;
2817
+ const renderer = await runPhase(state, "output_verification", ["renderer identity"], () => rendererIdentity(renderDocument, layoutDigest, runtimePolicyDigest, runtimeAssets, runtimeVersion, state.fonts, state.fontIdentity));
2818
+ rendererFingerprint = renderer.rendererFingerprint;
2819
+ observed = renderer.observed;
2820
+ fontIdentity = renderer.fontIdentity;
2821
+ const pageMap = await runPhase(state, "output_verification", ["PageMap geometry"], () => engine.materializePageMap(options.documentVersion, rendererFingerprint));
2822
+ pageMapWasMaterialized = true;
2823
+ if (pageMap.documentVersion !== options.documentVersion
2824
+ || pageMap.rendererFingerprint !== rendererFingerprint
2825
+ || pageMap.pages.length !== pages.length) {
2826
+ fail("output_verification_failure", "output_verification", "PageMap identity does not match the finalized render.", "Report the materializer defect; artifact identity fields must agree exactly.");
2827
+ }
2828
+ const pageNumbers = new Set();
2829
+ for (const [index, page] of pageMap.pages.entries()) {
2830
+ if (page.pageNumber !== index + 1 || pageNumbers.has(page.pageNumber)
2831
+ || !Number.isInteger(page.pageInSection) || page.pageInSection < 1
2832
+ || page.sectionIndex === undefined
2833
+ || !Number.isInteger(page.sectionIndex) || page.sectionIndex < 0
2834
+ || typeof page.pageName !== "string" || page.pageName.length === 0
2835
+ || !Number.isFinite(page.width) || page.width <= 0
2836
+ || !Number.isFinite(page.height) || page.height <= 0) {
2837
+ fail("output_verification_failure", "output_verification", `PageMap page ${index} violates the finalized page invariants.`, "Report the materializer defect; page identities and geometry must be finite and contiguous.");
2838
+ }
2839
+ pageNumbers.add(page.pageNumber);
2840
+ }
2841
+ const fragmentIds = new Set();
2842
+ const nextFragmentIndex = new Map();
2843
+ for (const [index, fragment] of pageMap.fragments.entries()) {
2844
+ const page = pageMap.pages[fragment.pageNumber - 1];
2845
+ const expectedIndex = nextFragmentIndex.get(fragment.anchorId) ?? 0;
2846
+ const geometry = fragment.geometry;
2847
+ if (!page || fragmentIds.has(fragment.fragmentId)
2848
+ || fragment.fragmentId !==
2849
+ `p${fragment.pageNumber}-f${fragment.fragmentIndex}-${fragment.anchorId}`
2850
+ || fragment.fragmentIndex !== expectedIndex
2851
+ || !Object.values(geometry).every((value) => Number.isFinite(value) && value >= 0)
2852
+ || geometry.x + geometry.width > page.width + 0.1
2853
+ || geometry.y + geometry.height > page.height + 0.1) {
2854
+ fail("output_verification_failure", "output_verification", `PageMap fragment ${index} violates the visible-fragment invariants.`, "Report the materializer defect; fragments must be unique, ordered, visible, and page-bounded.");
2855
+ }
2856
+ fragmentIds.add(fragment.fragmentId);
2857
+ nextFragmentIndex.set(fragment.anchorId, expectedIndex + 1);
2858
+ }
2859
+ pagesForFailure = reportPages(pageMap);
2860
+ const pageMapJson = canonicalJson(pageMap);
2861
+ enforceLimit(utf8ByteLength(pageMapJson), options.limits.pageMapOutputBytes, "pageMapOutputBytes", "output_verification");
2862
+ const pageMapDigest = await runPhase(state, "output_verification", ["PageMap digest"], () => sha256(utf8Bytes(pageMapJson)));
2863
+ const html = serializeDocument(renderDocument);
2864
+ htmlWasMaterialized = true;
2865
+ enforceLimit(utf8ByteLength(html), options.limits.htmlOutputBytes, "htmlOutputBytes", "output_verification");
2866
+ await runPhase(state, "output_verification", ["offline reopen"], () => verifyOfflineReopen(globalThis.document, html, pageMap, state));
2867
+ const htmlDigest = await runPhase(state, "output_verification", ["HTML digest"], () => sha256(utf8Bytes(html)));
2868
+ const report = {
2869
+ ...reportBase(manifest, sourceBytes, derivedManifest, derivedBytes, options, layoutDigest, runtimePolicyDigest, state, fontIdentity),
2870
+ status: "complete",
2871
+ fontIdentity,
2872
+ environment: {
2873
+ rendererFingerprint,
2874
+ verification: "browserObserved",
2875
+ fidelityTier: "unbaselined",
2876
+ observed,
2877
+ },
2878
+ pages: pagesForFailure,
2879
+ bindings: {
2880
+ pageMapDigest,
2881
+ htmlDigest,
2882
+ artifactRequestIds: [],
2883
+ },
2884
+ };
2885
+ enforceLimit(utf8ByteLength(canonicalJson(report)), options.limits.renderReportOutputBytes, "renderReportOutputBytes", "output_verification");
2886
+ return {
2887
+ html,
2888
+ pageCount: pages.length,
2889
+ pageMap,
2890
+ renderReport: report,
2891
+ warnings: report.warnings,
2892
+ rendererFingerprint,
2893
+ };
2894
+ }
2895
+ catch (error) {
2896
+ const resolved = asExportError(error, state.phase);
2897
+ ensureTerminalReadiness(state, resolved);
2898
+ if (manifest && sourceBytes) {
2899
+ resolved.report = failureReport(manifest, sourceBytes, derivedManifest, derivedBytes, options, layoutDigest, runtimePolicyDigest, rendererFingerprint, observed, fontIdentity, state, resolved, pageMapWasMaterialized, htmlWasMaterialized, pagesForFailure);
2900
+ if (utf8ByteLength(canonicalJson(resolved.report)) > options.limits.renderReportOutputBytes) {
2901
+ // Preserve the primary structured error while withholding an artifact
2902
+ // that would itself exceed the versioned report ceiling.
2903
+ resolved.report = undefined;
2904
+ }
2905
+ }
2906
+ throw resolved;
2907
+ }
2908
+ finally {
2909
+ state.phase = "cleanup";
2910
+ ownedOperationAbort.abort();
2911
+ frame?.remove();
2912
+ worker?.terminate();
2913
+ }
2914
+ }
2915
+ //# sourceMappingURL=export-browser.js.map