@stll/folio-core 0.64.1 → 0.66.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 (62) hide show
  1. package/dist/ai-edits/apply.js +31 -9
  2. package/dist/ai-edits/headless.js +3 -3
  3. package/dist/compare/reproducible-package.d.ts +5 -0
  4. package/dist/compare/reproducible-package.js +10 -23
  5. package/dist/docx/archiveInflation.d.ts +8 -103
  6. package/dist/docx/archiveInflation.js +8 -145
  7. package/dist/docx/attributeRemainder.js +1 -1
  8. package/dist/docx/canonicalStoryRepack.js +2 -2
  9. package/dist/docx/documentSectionFacts.js +1 -1
  10. package/dist/docx/ensureParaIds.js +9 -24
  11. package/dist/docx/importedIdCollisions.js +1 -1
  12. package/dist/docx/mathToMathml.js +1 -1
  13. package/dist/docx/metadataPrivacy.js +38 -48
  14. package/dist/docx/numericIdNormalization.js +2 -2
  15. package/dist/docx/packageParts.d.ts +4 -4
  16. package/dist/docx/packageParts.js +17 -24
  17. package/dist/docx/parser.js +5 -6
  18. package/dist/docx/removeHeaderFooterParts.d.ts +2 -2
  19. package/dist/docx/removeHeaderFooterParts.js +19 -36
  20. package/dist/docx/revisionIdNormalization.js +2 -2
  21. package/dist/docx/rezip.d.ts +7 -7
  22. package/dist/docx/rezip.js +144 -532
  23. package/dist/docx/sdtProperties.js +1 -2
  24. package/dist/docx/selectedXmlIdentityScan.d.ts +1 -1
  25. package/dist/docx/selectedXmlIdentityScan.js +1 -1
  26. package/dist/docx/selectiveSave.js +37 -41
  27. package/dist/docx/selectiveSaveFlags.d.ts +1 -1
  28. package/dist/docx/selectiveSaveTripwire.js +5 -11
  29. package/dist/docx/selectiveXmlPatch.js +2 -2
  30. package/dist/docx/server/applyDocxXmlPatchProposal.js +20 -41
  31. package/dist/docx/server/boundedArchive.d.ts +2 -3
  32. package/dist/docx/server/boundedArchive.js +78 -99
  33. package/dist/docx/server/inspectDocxPackage.d.ts +1 -1
  34. package/dist/docx/server/inspectDocxPackage.js +1 -1
  35. package/dist/docx/server/validateDocxConformance.js +1 -1
  36. package/dist/docx/shapeAlternateContent.js +1 -1
  37. package/dist/docx/storyBlockReplay.js +1 -1
  38. package/dist/docx/unzip.d.ts +2 -3
  39. package/dist/docx/unzip.js +127 -155
  40. package/dist/docx/verbatimCapture.js +1 -1
  41. package/dist/docx/xmlParser.d.ts +5 -36
  42. package/dist/docx/xmlParser.js +14 -78
  43. package/dist/docx/xmlSafety.js +5 -5
  44. package/dist/layout-painter/renderPage.js +4 -2
  45. package/dist/markdown/countParagraphList.d.ts +18 -0
  46. package/dist/markdown/countParagraphList.js +90 -0
  47. package/dist/markdown/renderParagraph.js +2 -62
  48. package/dist/markdown/renderRuns.js +4 -1
  49. package/dist/prosemirror/conversion/fromProseDoc.js +5 -3
  50. package/dist/prosemirror/conversion/toProseDoc.js +4 -1
  51. package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +4 -1
  52. package/dist/prosemirror/replacedAnnotations.d.ts +6 -0
  53. package/dist/prosemirror/replacedAnnotations.js +30 -8
  54. package/dist/prosemirror/schema/nodes.d.ts +7 -0
  55. package/dist/prosemirror/textBoxAnchorAttrs.js +9 -1
  56. package/package.json +3 -5
  57. package/dist/docx/streamingXmlParser.d.ts +0 -60
  58. package/dist/docx/streamingXmlParser.js +0 -406
  59. package/dist/docx/xmlNamespaceContext.d.ts +0 -6
  60. package/dist/docx/xmlNamespaceContext.js +0 -53
  61. package/dist/docx/xmlResourceLimits.d.ts +0 -107
  62. package/dist/docx/xmlResourceLimits.js +0 -194
@@ -1,13 +1,12 @@
1
1
  import { bytesToDataUrl } from "../utils/base64.js";
2
- import { encodeUtf8 } from "../utils/textEncoding.js";
3
- import { compressionRatioLimitFor, countCentralDirectoryRecords, createInflationBudget, exceedsCompressionRatio, getZipEntrySizes, inflateEntryWithinLimits, isStoredZipEntry } from "./archiveInflation.js";
2
+ import { DOCX_COMPRESSION_RATIO_GRACE_BYTES, isRatioBoundedPart } from "./archiveInflation.js";
4
3
  import { DOCX_CONTAINER_TYPES, detectDocxContainerType } from "./encryption/containerFormat.js";
5
4
  import { openDocxBuffer } from "./encryption/openEncryptedDocx.js";
6
5
  import { RASTER_MIME_TYPES, detectRasterMimeType } from "./rasterMime.js";
7
6
  import { decodeXmlBytes } from "./xmlEncoding.js";
8
- import { FOLIO_XML_RESOURCE_LIMITS, assertXmlResourceLimits, createXmlPackageBudget } from "./xmlResourceLimits.js";
9
- import JSZip from "jszip";
10
- import { writeZipPart } from "@stll/docx-core/zip";
7
+ import { Result } from "better-result";
8
+ import { FOLIO_XML_RESOURCE_LIMITS, assertXmlResourceLimits, createXmlPackageBudget } from "@stll/docx-core/xml/limits";
9
+ import { ZipArchive, ZipError } from "@stll/docx-core/zip";
11
10
  // src/docx/unzip.ts
12
11
  /**
13
12
  * DOCX Unzipper
@@ -116,12 +115,10 @@ async function unzipDocx(buffer, options = {}, { verifyUnreadEntries = true } =
116
115
  const zipBuffer = containerType === DOCX_CONTAINER_TYPES.ZIP ? buffer : await openDocxBuffer(buffer, { password: options.password });
117
116
  if (zipBuffer.byteLength > limits.maxInputBytes) throw new DocxSecurityError("DOCX file exceeds the maximum allowed size");
118
117
  const wasEncrypted = containerType === DOCX_CONTAINER_TYPES.CFB;
119
- const maxRecords = limits.maxFiles * 2;
120
- if (countCentralDirectoryRecords(new Uint8Array(zipBuffer), maxRecords) > maxRecords) throw new DocxSecurityError("DOCX file contains too many entries");
121
- const loaded = await loadDocxZip(zipBuffer, limits.maxFiles);
118
+ const loaded = await loadDocxZip(zipBuffer, limits);
122
119
  if (loaded.buffer.byteLength > limits.maxInputBytes) throw new DocxSecurityError("DOCX file exceeds the maximum allowed size");
123
120
  const { zip } = loaded;
124
- const entries = Object.entries(zip.files).filter(([, file]) => !file.dir);
121
+ const entries = zip.entries();
125
122
  if (entries.length > limits.maxFiles) throw new DocxSecurityError("DOCX file contains too many entries");
126
123
  const content = {
127
124
  documentXml: null,
@@ -152,79 +149,46 @@ async function unzipDocx(buffer, options = {}, { verifyUnreadEntries = true } =
152
149
  warnings: [],
153
150
  wasEncrypted
154
151
  };
155
- let totalUncompressedBytes = 0;
156
- const inflationBudget = createInflationBudget(limits.maxTotalUncompressedBytes);
157
152
  const extractionTasks = [];
158
- const verifyEntry = (file, path) => {
159
- if (!verifyUnreadEntries || isStoredZipEntry(file)) return;
153
+ const verifyEntry = (file) => {
154
+ if (!verifyUnreadEntries) return;
160
155
  extractionTasks.push(async () => {
161
- await verifyEntryWithinLimits({
162
- file,
163
- path,
164
- limits,
165
- budget: inflationBudget
166
- });
156
+ await verifyEntryData(file);
167
157
  return null;
168
158
  });
169
159
  };
170
- const inflate = async (file, maxEntryBytes) => await inflateEntryWithinLimits({
171
- entry: file,
172
- maxEntryBytes,
173
- maxCompressionRatio: compressionRatioLimitFor(file.name, limits.maxCompressionRatio),
174
- budget: inflationBudget
175
- });
176
- for (const [path, file] of entries) {
177
- if (!isSafeDocxPath(path)) throw new DocxSecurityError("DOCX file contains an unsafe entry path");
178
- const { compressedBytes, uncompressedBytes: declaredSize } = getZipEntrySizes(file);
179
- if (declaredSize !== null) {
180
- totalUncompressedBytes += declaredSize;
181
- if (totalUncompressedBytes > limits.maxTotalUncompressedBytes) throw new DocxSecurityError("DOCX file expands beyond the maximum allowed size");
182
- if (exceedsCompressionRatio({
183
- inflatedBytes: declaredSize,
184
- compressedBytes,
185
- maxRatio: compressionRatioLimitFor(path, limits.maxCompressionRatio)
186
- })) throw new DocxSecurityError(`DOCX entry exceeds the maximum compression ratio: ${path}`);
187
- }
160
+ for (const file of entries) {
161
+ const { path, size: declaredSize } = file;
188
162
  if (!isParsedDocxEntry(path)) {
189
- verifyEntry(file, path);
163
+ verifyEntry(file);
190
164
  continue;
191
165
  }
192
166
  const lowerPath = path.toLowerCase();
193
167
  if (lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels")) {
194
168
  assertEntrySize(path, declaredSize, limits.maxXmlBytes);
195
169
  if (options.extractAllXml === false && !shouldExtractXmlPart(lowerPath)) {
196
- verifyEntry(file, path);
170
+ verifyEntry(file);
197
171
  continue;
198
172
  }
199
- extractionTasks.push(async () => {
200
- const result = await inflate(file, limits.maxXmlBytes);
201
- if (!result.ok) throw inflationLimitError(result.limit, path);
202
- return {
203
- type: "xml",
204
- path,
205
- lowerPath,
206
- content: decodeXmlBytes(result.bytes)
207
- };
208
- });
173
+ extractionTasks.push(async () => ({
174
+ type: "xml",
175
+ path,
176
+ lowerPath,
177
+ content: decodeXmlBytes(await readEntry(file))
178
+ }));
209
179
  } else if (lowerPath.startsWith("word/media/")) {
210
180
  const mimeType = getMediaMimeType(path);
211
181
  if (!limits.allowedMediaMimeTypes.has(mimeType)) {
212
- verifyEntry(file, path);
182
+ verifyEntry(file);
213
183
  continue;
214
184
  }
215
185
  if (isEntryTooLarge(declaredSize, limits.maxMediaBytes)) {
216
186
  content.warnings.push(`Skipped oversized media file: ${path}; original entry preserved for round-trip.`);
217
- verifyEntry(file, path);
187
+ verifyEntry(file);
218
188
  continue;
219
189
  }
220
190
  extractionTasks.push(async () => {
221
- const result = await inflate(file, limits.maxMediaBytes);
222
- if (!result.ok) {
223
- if (result.limit !== "entry") throw inflationLimitError(result.limit, path);
224
- content.warnings.push(`Skipped oversized media file: ${path}; original entry preserved for round-trip.`);
225
- return null;
226
- }
227
- const binaryContent = result.bytes.buffer;
191
+ const binaryContent = (await readEntry(file)).buffer;
228
192
  const effectiveMimeType = RASTER_MIME_TYPES.has(mimeType) ? detectRasterMimeType(binaryContent) : mimeType;
229
193
  if (!effectiveMimeType || !limits.allowedMediaMimeTypes.has(effectiveMimeType) || !isMediaContentAllowed(binaryContent, effectiveMimeType)) return null;
230
194
  return {
@@ -236,30 +200,18 @@ async function unzipDocx(buffer, options = {}, { verifyUnreadEntries = true } =
236
200
  });
237
201
  } else if (lowerPath.startsWith("word/fonts/")) {
238
202
  if (isEntryTooLarge(declaredSize, limits.maxFontBytes)) {
239
- verifyEntry(file, path);
203
+ verifyEntry(file);
240
204
  continue;
241
205
  }
242
- extractionTasks.push(async () => {
243
- const result = await inflate(file, limits.maxFontBytes);
244
- if (!result.ok) {
245
- if (result.limit !== "entry") throw inflationLimitError(result.limit, path);
246
- return null;
247
- }
248
- return {
249
- type: "font",
250
- path,
251
- content: result.bytes.buffer
252
- };
253
- });
254
- } else verifyEntry(file, path);
206
+ extractionTasks.push(async () => ({
207
+ type: "font",
208
+ path,
209
+ content: (await readEntry(file)).buffer
210
+ }));
211
+ } else verifyEntry(file);
255
212
  }
256
- if (exceedsCompressionRatio({
257
- inflatedBytes: totalUncompressedBytes,
258
- compressedBytes: loaded.buffer.byteLength,
259
- maxRatio: limits.maxCompressionRatio
260
- })) throw new DocxSecurityError("DOCX file exceeds the maximum compression ratio");
261
213
  const xmlBudget = createXmlPackageBudget();
262
- for (const extracted of await runExtractionTasks(extractionTasks, inflationBudget)) {
214
+ for (const extracted of await runExtractionTasks(extractionTasks)) {
263
215
  if (!extracted) continue;
264
216
  if (extracted.type === "xml") {
265
217
  assignXmlContent(content, extracted, limits, xmlBudget);
@@ -284,18 +236,10 @@ const replaceRawDocxXmlParts = async (content, parts) => {
284
236
  lowerPath: path.toLowerCase(),
285
237
  content: xmlContent
286
238
  });
287
- writeZipPart({
288
- zip: content.originalZip,
289
- path,
290
- data: encodeUtf8(xmlContent)
291
- });
239
+ content.originalZip.set(path, xmlContent);
292
240
  identitiesChanged = true;
293
241
  }
294
- if (identitiesChanged) content.originalBuffer = await content.originalZip.generateAsync({
295
- type: "arraybuffer",
296
- compression: "DEFLATE",
297
- compressionOptions: { level: 1 }
298
- });
242
+ if (identitiesChanged) content.originalBuffer = (await content.originalZip.generate({ level: 1 })).buffer;
299
243
  };
300
244
  /**
301
245
  * Preflight every XML part the unzip retains, not a named few.
@@ -349,80 +293,111 @@ const indexXmlContent = (content, { path, lowerPath, content: xmlContent }) => {
349
293
  content.footers.set(filename, xmlContent);
350
294
  }
351
295
  };
352
- function inflationLimitError(limit, path) {
353
- switch (limit) {
354
- case "declared-size": return new DocxSecurityError(`DOCX entry inflates past its declared size: ${path}`);
355
- case "compression-ratio": return new DocxSecurityError(`DOCX entry exceeds the maximum compression ratio: ${path}`);
356
- case "entry": return new DocxSecurityError(`DOCX entry exceeds maximum size: ${path}`);
357
- case "total": return new DocxSecurityError("DOCX file expands beyond the maximum allowed size");
358
- case "aborted": return new DocxSecurityError(`DOCX entry was not read after another entry failed: ${path}`);
296
+ /** The error the unzip reports for an archive or entry the reader refused. */
297
+ function docxErrorFor(error) {
298
+ const path = error.path ?? "";
299
+ switch (error.reason) {
300
+ case "declared-size-exceeded": return new DocxSecurityError(`DOCX entry inflates past its declared size: ${path}`);
301
+ case "compression-ratio": return new DocxSecurityError(error.path === void 0 ? "DOCX file exceeds the maximum compression ratio" : `DOCX entry exceeds the maximum compression ratio: ${path}`);
302
+ case "too-many-entries": return new DocxSecurityError("DOCX file contains too many entries");
303
+ case "entry-too-large":
304
+ case "total-too-large": return new DocxSecurityError("DOCX file expands beyond the maximum allowed size");
305
+ case "invalid-entry-name": return new DocxSecurityError("DOCX file contains an unsafe entry path");
306
+ case "malformed":
307
+ case "unsupported":
308
+ case "duplicate-entry":
309
+ case "corrupt-entry": return error;
359
310
  }
360
311
  }
312
+ const rethrowRead = (error) => {
313
+ throw error instanceof ZipError ? docxErrorFor(error) : error;
314
+ };
315
+ /** An extracted entry's bytes; inflation stops at its declared size. */
316
+ async function readEntry(file) {
317
+ const read = await Result.tryPromise({
318
+ try: async () => await file.bytes(),
319
+ catch: (cause) => cause
320
+ });
321
+ return Result.isOk(read) ? read.value : rethrowRead(read.error);
322
+ }
323
+ /**
324
+ * Inflate an entry without keeping it, to bound what a later read of it can
325
+ * produce. Data past the declared size refuses the package. A corrupt entry
326
+ * does not: it was never going to be read here, a later read fails the same
327
+ * way, and refusing the whole document over a part folio does not model
328
+ * would be a regression.
329
+ */
330
+ async function verifyEntryData(file) {
331
+ const read = await Result.tryPromise({
332
+ try: async () => await file.bytes(),
333
+ catch: (cause) => cause
334
+ });
335
+ if (Result.isOk(read)) return;
336
+ const error = read.error;
337
+ if (!(error instanceof ZipError) || error.reason === "declared-size-exceeded") rethrowRead(error);
338
+ }
361
339
  /**
362
340
  * Run extraction tasks a few at a time, in order, keeping their results in
363
- * task order. The first failure marks the shared budget aborted, which stops
364
- * the streams still running at their next chunk and leaves the queued tasks
365
- * unstarted.
341
+ * task order. The first failure leaves the queued tasks unstarted.
366
342
  */
367
- async function runExtractionTasks(tasks, budget) {
343
+ async function runExtractionTasks(tasks) {
368
344
  const results = Array.from({ length: tasks.length }, () => null);
369
345
  let next = 0;
346
+ let aborted = false;
370
347
  const worker = async () => {
371
- while (next < tasks.length && !budget.aborted) {
348
+ while (next < tasks.length && !aborted) {
372
349
  const index = next;
373
350
  next += 1;
374
351
  const task = tasks[index];
375
352
  if (!task) return;
376
- try {
377
- results[index] = await task();
378
- } catch (error) {
379
- budget.aborted = true;
380
- throw error;
353
+ const result = await Result.tryPromise({
354
+ try: async () => await task(),
355
+ catch: (cause) => cause
356
+ });
357
+ if (Result.isError(result)) {
358
+ aborted = true;
359
+ throw result.error;
381
360
  }
361
+ results[index] = result.value;
382
362
  }
383
363
  };
384
364
  await Promise.all(Array.from({ length: Math.min(EXTRACTION_CONCURRENCY, tasks.length) }, async () => await worker()));
385
365
  return results;
386
366
  }
387
- /**
388
- * Inflate an entry without keeping it, to bound what a later read of it can
389
- * produce. A limit refuses the package. A corrupt entry does not: it was never
390
- * going to be read here, a later read stops at the same byte, and refusing
391
- * the whole document over a part folio does not model would be a regression.
392
- */
393
- async function verifyEntryWithinLimits({ file, path, limits, budget }) {
394
- let result;
395
- try {
396
- result = await inflateEntryWithinLimits({
397
- entry: file,
398
- maxEntryBytes: Number.POSITIVE_INFINITY,
399
- maxCompressionRatio: compressionRatioLimitFor(path, limits.maxCompressionRatio),
400
- budget,
401
- retain: false
402
- });
403
- } catch {
404
- return;
405
- }
406
- if (!result.ok) throw inflationLimitError(result.limit, path);
407
- }
408
- async function loadDocxZip(buffer, maxFiles) {
409
- try {
410
- return {
411
- zip: await JSZip.loadAsync(buffer),
412
- buffer
413
- };
414
- } catch (error) {
415
- const repairedBuffer = repairTruncatedEndOfCentralDirectory(buffer, maxFiles);
416
- if (!repairedBuffer) throw error;
417
- try {
418
- return {
419
- zip: await JSZip.loadAsync(repairedBuffer),
420
- buffer: repairedBuffer
421
- };
422
- } catch {
423
- throw error;
424
- }
425
- }
367
+ /** Package limits as the archive reader enforces them. */
368
+ const zipReadOptionsFor = (limits) => ({
369
+ limits: {
370
+ maxEntries: limits.maxFiles * 2,
371
+ maxEntryBytes: limits.maxTotalUncompressedBytes,
372
+ maxTotalBytes: limits.maxTotalUncompressedBytes,
373
+ maxCompressionRatio: limits.maxCompressionRatio,
374
+ compressionRatioGraceBytes: DOCX_COMPRESSION_RATIO_GRACE_BYTES
375
+ },
376
+ isCompressionRatioExempt: (path) => !isRatioBoundedPart(path)
377
+ });
378
+ async function loadDocxZip(buffer, limits) {
379
+ const options = zipReadOptionsFor(limits);
380
+ const read = await Result.tryPromise({
381
+ try: async () => await ZipArchive.read(buffer, options),
382
+ catch: (cause) => cause
383
+ });
384
+ if (Result.isOk(read)) return {
385
+ zip: read.value,
386
+ buffer
387
+ };
388
+ const error = read.error;
389
+ if (!(error instanceof ZipError) || error.reason !== "malformed") return rethrowRead(error);
390
+ const repairedBuffer = repairTruncatedEndOfCentralDirectory(buffer, limits.maxFiles);
391
+ if (!repairedBuffer) throw error;
392
+ const repaired = await Result.tryPromise({
393
+ try: async () => await ZipArchive.read(repairedBuffer, options),
394
+ catch: (cause) => cause
395
+ });
396
+ if (Result.isOk(repaired)) return {
397
+ zip: repaired.value,
398
+ buffer: repairedBuffer
399
+ };
400
+ throw error;
426
401
  }
427
402
  function repairTruncatedEndOfCentralDirectory(buffer, maxFiles) {
428
403
  const bytes = new Uint8Array(buffer);
@@ -520,14 +495,11 @@ function isParsedDocxEntry(path) {
520
495
  if (!(lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels"))) return false;
521
496
  return lowerPath === "[content_types].xml" || lowerPath.startsWith("_rels/") || lowerPath.startsWith("docprops/") || lowerPath.startsWith("word/") || lowerPath.startsWith("customxml/");
522
497
  }
523
- function getEntryUncompressedSize(file) {
524
- return getZipEntrySizes(file).uncompressedBytes;
525
- }
526
498
  function assertEntrySize(path, declaredSize, maxBytes) {
527
499
  if (isEntryTooLarge(declaredSize, maxBytes)) throw new DocxSecurityError(`DOCX entry exceeds maximum size: ${path}`);
528
500
  }
529
501
  function isEntryTooLarge(declaredSize, maxBytes) {
530
- return declaredSize !== null && declaredSize > maxBytes;
502
+ return declaredSize > maxBytes;
531
503
  }
532
504
  function isMediaContentAllowed(data, mimeType) {
533
505
  if (RASTER_MIME_TYPES.has(mimeType)) return detectRasterMimeType(data) === mimeType;
@@ -548,7 +520,7 @@ function isMediaContentAllowed(data, mimeType) {
548
520
  */
549
521
  function getFileList(content) {
550
522
  const files = [];
551
- for (const path of Object.keys(content.originalZip.files)) if (!content.originalZip.files[path]?.dir && isParsedDocxEntry(path)) files.push(path);
523
+ for (const path of content.originalZip.paths()) if (isParsedDocxEntry(path)) files.push(path);
552
524
  return files.toSorted();
553
525
  }
554
526
  /**
@@ -591,11 +563,11 @@ function mediaToDataUrl(data, mimeType) {
591
563
  * @returns File content as string or ArrayBuffer, or null if not found
592
564
  */
593
565
  function extractFile(content, path) {
594
- const file = content.originalZip.file(path);
566
+ const file = content.originalZip.entry(path);
595
567
  if (!file || !isParsedDocxEntry(path)) return Promise.resolve(null);
596
568
  const lowerPath = path.toLowerCase();
597
- if (lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels")) return file.async("uint8array").then(decodeXmlBytes);
598
- return file.async("arraybuffer");
569
+ if (lowerPath.endsWith(".xml") || lowerPath.endsWith(".rels")) return file.bytes().then(decodeXmlBytes);
570
+ return file.arrayBuffer();
599
571
  }
600
572
  /**
601
573
  * Check if a file exists in the DOCX
@@ -605,7 +577,7 @@ function extractFile(content, path) {
605
577
  * @returns true if file exists
606
578
  */
607
579
  function hasFile(content, path) {
608
- return content.originalZip.file(path) !== null;
580
+ return content.originalZip.has(path);
609
581
  }
610
582
  /**
611
583
  * Get summary of DOCX content
@@ -627,7 +599,7 @@ function getContentSummary(content) {
627
599
  footerCount: content.footers.size,
628
600
  mediaCount: content.media.size,
629
601
  fontCount: content.fonts.size,
630
- totalFiles: Object.keys(content.originalZip.files).filter((p) => !content.originalZip.files[p]?.dir).length
602
+ totalFiles: content.originalZip.paths().length
631
603
  };
632
604
  }
633
- export { DocxSecurityError, extractFile, getContentSummary, getEntryUncompressedSize, getFileList, getMediaMimeType, hasFile, mediaToDataUrl, replaceRawDocxXmlParts, unzipDocx };
605
+ export { DocxSecurityError, extractFile, getContentSummary, getFileList, getMediaMimeType, hasFile, mediaToDataUrl, replaceRawDocxXmlParts, unzipDocx };
@@ -2,10 +2,10 @@ import { TRANSITIONAL_NAMESPACE_BY_STRICT_URI } from "./strictValueEncodings.gen
2
2
  import { NUMBERS_PER_PERCENT, isStrictNamespaceUri, percentageSpelling, toTransitionalNamespaceUri, transitionalSlotEncoding } from "./transitionalSpelling.js";
3
3
  import { roundHalfAwayFromZero, universalMeasureAs } from "./universalMeasure.js";
4
4
  import { NAMESPACES, OOXML_NAMESPACE_SCOPE, WORDPROCESSINGML_NAMESPACE_URIS, cloneElement, elementToXml, getAttributeByNamespaceUri, getChildElements, getLocalName, getNamespacePrefix, parseXml, resolveAttributeNamespaceUri } from "./xmlParser.js";
5
- import { assertXmlResourceLimits } from "./xmlResourceLimits.js";
6
5
  import { getDocxXmlSafetyIssue } from "./xmlSafety.js";
7
6
  import { TaggedError, panic } from "better-result";
8
7
  import { DOCX_CONFORMANCE_CLASSES } from "@stll/docx-core/model";
8
+ import { assertXmlResourceLimits } from "@stll/docx-core/xml/limits";
9
9
  // src/docx/verbatimCapture.ts
10
10
  /**
11
11
  * The one way to turn parsed markup into a string a rebuilt part replays.
@@ -1,32 +1,5 @@
1
1
  import { ParseContext } from "./parseContext.js";
2
- /**
3
- * XML element tree node — drop-in replacement for the `Element` type
4
- * previously imported from `xml-js`. Every consumer imports this from
5
- * `xmlParser.ts`, so the shape must stay identical.
6
- */
7
- export type XmlElement = {
8
- declaration?: {
9
- attributes?: Record<string, string | number>;
10
- };
11
- instruction?: string;
12
- attributes?: Record<string, string | number | undefined>;
13
- cdata?: string;
14
- doctype?: string;
15
- comment?: string;
16
- text?: string | number | boolean;
17
- type?: string;
18
- name?: string;
19
- /** Resolved namespace URI; non-enumerable so legacy structural comparisons stay stable. */
20
- namespaceUri?: string;
21
- /** In-scope namespace declarations; non-enumerable so legacy comparisons stay stable. */
22
- namespaceScope?: XmlNamespaceScope;
23
- elements?: XmlElement[];
24
- };
25
- /** Parent-linked XML namespace declarations; each scope stores only local overrides. */
26
- export type XmlNamespaceScope = {
27
- bindings: ReadonlyMap<string, string>;
28
- parent?: XmlNamespaceScope;
29
- };
2
+ import { XmlElement, XmlNamespaceScope } from "@stll/docx-core/xml";
30
3
  /**
31
4
  * Common OOXML namespace URIs — re-exported from @stll/docx-utils.
32
5
  */
@@ -67,16 +40,11 @@ export declare const NAMESPACES: {
67
40
  */
68
41
  export declare const OOXML_NAMESPACE_SCOPE: XmlNamespaceScope;
69
42
  export declare function parseXml(xml: string, inheritedNamespaceScope?: XmlNamespaceScope): XmlElement;
70
- /**
71
- * The general-purpose reader {@link parseXml} falls back to, and the reference
72
- * the streaming reader's equivalence tests compare against.
73
- */
74
- export declare function parseXmlWithFastXmlParser(xml: string, inheritedNamespaceScope?: XmlNamespaceScope): XmlElement;
75
43
  /**
76
44
  * Serialize an XmlElement back to an XML string.
77
45
  *
78
- * Written here rather than handed to `fast-xml-parser`'s builder for two
79
- * reasons. The builder writes a tab or a newline inside an attribute value
46
+ * Written here rather than handed to a general-purpose XML builder (the one
47
+ * folio used before) for two reasons. The builder writes a tab or a newline inside an attribute value
80
48
  * literally, and XML 1.0 §3.3.3 has every conformant reader normalise those to
81
49
  * a space: a `descr="two lines&#xA;"` a source file wrote came back as
82
50
  * `descr="two lines "`. `escapeXmlAttribute` writes the character references
@@ -405,4 +373,5 @@ export declare function cloneWithXmlnsDeclarations(element: XmlElement, xmlnsDec
405
373
  * original's scope leaves resolution unchanged whether or not the clone
406
374
  * carries `xmlns` attributes of its own.
407
375
  */
408
- export declare function cloneElement(element: XmlElement, overrides: Partial<XmlElement>): XmlElement;
376
+ export declare function cloneElement(element: XmlElement, overrides: Partial<XmlElement>): XmlElement;
377
+ export type { XmlElement, XmlNamespaceScope };
@@ -1,10 +1,9 @@
1
- import { parseStreamingXml } from "./streamingXmlParser.js";
2
1
  import { NUMBERS_PER_PERCENT, percentageSpelling, transitionalSlotEncoding } from "./transitionalSpelling.js";
3
2
  import { universalMeasureAs } from "./universalMeasure.js";
4
- import { EMPTY_NAMESPACE_SCOPE, attachXmlNamespaceContext, resolveNamespaceUri } from "./xmlNamespaceContext.js";
5
3
  import { escapeXmlAttribute, escapeXmlText } from "@stll/docx-core";
6
4
  import { PARSE_WARNING_CODES } from "@stll/docx-core/model";
7
- import { XMLParser } from "fast-xml-parser";
5
+ import { XmlReadError, readXml } from "@stll/docx-core/xml";
6
+ import { EMPTY_NAMESPACE_SCOPE, attachXmlNamespaceContext, resolveNamespaceUri } from "@stll/docx-core/xml/namespaces";
8
7
  import { OOXML_NS } from "@stll/docx-utils";
9
8
  // src/docx/xmlParser.ts
10
9
  /**
@@ -27,69 +26,6 @@ import { OOXML_NS } from "@stll/docx-utils";
27
26
  * - o: Office (extensions)
28
27
  * - pic: Pictures
29
28
  */
30
- const fxpParserOptions = {
31
- preserveOrder: true,
32
- ignoreAttributes: false,
33
- attributeNamePrefix: "",
34
- trimValues: false,
35
- textNodeName: "#text",
36
- parseTagValue: false,
37
- parseAttributeValue: false,
38
- ignoreDeclaration: true,
39
- ignorePiTags: true,
40
- jPath: false,
41
- processEntities: true,
42
- htmlEntities: true
43
- };
44
- const fxpParserOptionsWithStopNodes = {
45
- ...fxpParserOptions,
46
- stopNodes: ["*.w:binData"]
47
- };
48
- const fxpParser = new XMLParser(fxpParserOptions);
49
- const fxpParserWithStopNodes = new XMLParser(fxpParserOptionsWithStopNodes);
50
- /** Text node key used by fast-xml-parser in preserveOrder mode. */
51
- const TEXT_KEY = "#text";
52
- /** Attribute group key used by fast-xml-parser in preserveOrder mode. */
53
- const ATTR_KEY = ":@";
54
- /**
55
- * Convert a fast-xml-parser preserveOrder node into an XmlElement.
56
- *
57
- * In preserveOrder mode every node is an object with exactly one "real" key
58
- * (the tag name or `#text`) whose value is the children array, plus an
59
- * optional `:@` key holding the attributes object.
60
- */
61
- function fxpNodeToElement(node, inheritedNamespaceScope = EMPTY_NAMESPACE_SCOPE) {
62
- if (TEXT_KEY in node) return {
63
- type: "text",
64
- text: node[TEXT_KEY]
65
- };
66
- const attrs = node[ATTR_KEY];
67
- for (const key in node) {
68
- if (key === ATTR_KEY) continue;
69
- const children = node[key];
70
- const element = {
71
- type: "element",
72
- name: key
73
- };
74
- if (attrs) element.attributes = attrs;
75
- const namespaceScope = attachXmlNamespaceContext(element, inheritedNamespaceScope);
76
- if (children.length > 0) {
77
- for (let index = 0; index < children.length; index += 1) children[index] = fxpNodeToElement(children[index], namespaceScope);
78
- element.elements = children;
79
- }
80
- return element;
81
- }
82
- return { type: "element" };
83
- }
84
- /**
85
- * Convert the top-level fast-xml-parser preserveOrder array into an
86
- * XmlElement that matches xml-js's non-compact root structure:
87
- * `{ elements: [...] }`.
88
- */
89
- function fxpToRootElement(nodes, inheritedNamespaceScope = EMPTY_NAMESPACE_SCOPE) {
90
- for (let index = 0; index < nodes.length; index += 1) nodes[index] = fxpNodeToElement(nodes[index], inheritedNamespaceScope);
91
- return { elements: nodes };
92
- }
93
29
  /**
94
30
  * Common OOXML namespace URIs — re-exported from @stll/docx-utils.
95
31
  */
@@ -112,21 +48,21 @@ const NAMESPACES = OOXML_NS;
112
48
  */
113
49
  const OOXML_NAMESPACE_SCOPE = { bindings: new Map(Object.entries(OOXML_NS)) };
114
50
  function parseXml(xml, inheritedNamespaceScope = EMPTY_NAMESPACE_SCOPE) {
115
- const streamed = parseStreamingXml(xml, inheritedNamespaceScope);
116
- return streamed.status === "parsed" ? streamed.value : parseXmlWithFastXmlParser(xml, inheritedNamespaceScope);
117
- }
118
- /**
119
- * The general-purpose reader {@link parseXml} falls back to, and the reference
120
- * the streaming reader's equivalence tests compare against.
121
- */
122
- function parseXmlWithFastXmlParser(xml, inheritedNamespaceScope = EMPTY_NAMESPACE_SCOPE) {
123
- return fxpToRootElement((xml.includes("binData") ? fxpParserWithStopNodes : fxpParser).parse(xml), inheritedNamespaceScope);
51
+ const read = readXml(xml, {
52
+ conformance: "tolerant",
53
+ inheritedNamespaceScope
54
+ });
55
+ if (read.status === "rejected") throw new XmlReadError({
56
+ message: `XML part could not be read: ${read.reason}`,
57
+ reason: read.reason
58
+ });
59
+ return read.value;
124
60
  }
125
61
  /**
126
62
  * Serialize an XmlElement back to an XML string.
127
63
  *
128
- * Written here rather than handed to `fast-xml-parser`'s builder for two
129
- * reasons. The builder writes a tab or a newline inside an attribute value
64
+ * Written here rather than handed to a general-purpose XML builder (the one
65
+ * folio used before) for two reasons. The builder writes a tab or a newline inside an attribute value
130
66
  * literally, and XML 1.0 §3.3.3 has every conformant reader normalise those to
131
67
  * a space: a `descr="two lines&#xA;"` a source file wrote came back as
132
68
  * `descr="two lines "`. `escapeXmlAttribute` writes the character references
@@ -853,4 +789,4 @@ function cloneElement(element, overrides) {
853
789
  attachXmlNamespaceContext(clone, element.namespaceScope);
854
790
  return clone;
855
791
  }
856
- export { NAMESPACES, OFFICE_RELATIONSHIP_NAMESPACE_URIS, OOXML_NAMESPACE_SCOPE, WORDPROCESSINGML_NAMESPACE_URIS, cloneElement, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findAttributeByNamespaceUri, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findChildrenByNamespaceUri, findDeep, findWordprocessingChild, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffAttribute, parseOnOffChild, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument, parseXmlWithFastXmlParser, resolveAttributeNamespaceUri, selectAlternateContentBranch };
792
+ export { NAMESPACES, OFFICE_RELATIONSHIP_NAMESPACE_URIS, OOXML_NAMESPACE_SCOPE, WORDPROCESSINGML_NAMESPACE_URIS, cloneElement, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findAttributeByNamespaceUri, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findChildrenByNamespaceUri, findDeep, findWordprocessingChild, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffAttribute, parseOnOffChild, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument, resolveAttributeNamespaceUri, selectAlternateContentBranch };
@@ -1,4 +1,4 @@
1
- import { XMLValidator } from "fast-xml-parser";
1
+ import { wellFormedXmlIssue } from "@stll/docx-core/xml";
2
2
  // src/docx/xmlSafety.ts
3
3
  const DOCTYPE_PATTERN = /<!DOCTYPE(?:\s|>)/iu;
4
4
  const BUILT_IN_ENTITIES = /* @__PURE__ */ new Set([
@@ -75,9 +75,9 @@ const scanName = (xml, start) => {
75
75
  } : null;
76
76
  };
77
77
  /**
78
- * Reject lexical forms that fast-xml-parser's permissive validator repairs.
79
- * This is deliberately a small XML lexer, not a second tree parser: structural
80
- * nesting remains XMLValidator's job.
78
+ * Reject lexical forms with a DOCX-specific verdict before the reader's
79
+ * well-formedness check. This is deliberately a small XML lexer, not a second
80
+ * tree parser: structural nesting remains the reader's job.
81
81
  */
82
82
  const hasInvalidXmlLexicalForm = (xml) => {
83
83
  if (!containsOnlyXmlCharacters(xml)) return true;
@@ -160,6 +160,6 @@ const getDocxXmlSafetyIssue = (xml) => {
160
160
  if (DOCTYPE_PATTERN.test(xml)) return "doctype-forbidden";
161
161
  if (hasForbiddenEntity(xml)) return "entity-forbidden";
162
162
  if (hasInvalidXmlLexicalForm(xml)) return "not-well-formed";
163
- return XMLValidator.validate(xml) === true ? null : "not-well-formed";
163
+ return wellFormedXmlIssue(xml) === null ? null : "not-well-formed";
164
164
  };
165
165
  export { getDocxXmlSafetyIssue };
@@ -1124,7 +1124,8 @@ function renderPage(page, context, options = {}) {
1124
1124
  }
1125
1125
  if (shouldClipHeader) {
1126
1126
  headerEl.style.maxHeight = `${availableHeaderHeight}px`;
1127
- headerEl.style.overflow = "clip";
1127
+ headerEl.style.overflowX = "visible";
1128
+ headerEl.style.overflowY = "clip";
1128
1129
  headerEl.style.overflowClipMargin = `${HEADER_FOOTER_RULE_CLIP_MARGIN}px`;
1129
1130
  }
1130
1131
  pageEl.append(headerEl);
@@ -1177,7 +1178,8 @@ function renderPage(page, context, options = {}) {
1177
1178
  }
1178
1179
  if (shouldClipFooter) {
1179
1180
  footerEl.style.maxHeight = `${availableFooterHeight}px`;
1180
- footerEl.style.overflow = "clip";
1181
+ footerEl.style.overflowX = "visible";
1182
+ footerEl.style.overflowY = "clip";
1181
1183
  footerEl.style.overflowClipMargin = `${HEADER_FOOTER_RULE_CLIP_MARGIN}px`;
1182
1184
  }
1183
1185
  pageEl.append(footerEl);