@stll/folio-core 0.49.0 → 0.51.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 (107) hide show
  1. package/dist/ai-edits/headless.d.ts +37 -1
  2. package/dist/ai-edits/headless.js +47 -16
  3. package/dist/ai-edits/word-diff.js +134 -1
  4. package/dist/compare/content-alignment.js +220 -5
  5. package/dist/compare/content.d.ts +1 -0
  6. package/dist/compare/content.js +51 -20
  7. package/dist/display-list/dom/renderDisplayListToDom.d.ts +10 -1
  8. package/dist/display-list/dom/renderDisplayListToDom.js +8 -7
  9. package/dist/display-list/html/renderDisplayListToHtml.d.ts +21 -0
  10. package/dist/display-list/html/renderDisplayListToHtml.js +126 -0
  11. package/dist/display-list/selectDisplayPages.d.ts +6 -0
  12. package/dist/display-list/selectDisplayPages.js +44 -0
  13. package/dist/docx/selectiveSave.d.ts +1 -1
  14. package/dist/docx/selectiveSave.js +16 -6
  15. package/dist/docx/structuralXmlPatch.d.ts +14 -0
  16. package/dist/docx/structuralXmlPatch.js +209 -0
  17. package/dist/export-pdf.d.ts +35 -2
  18. package/dist/export-pdf.js +51 -20
  19. package/dist/fonts/fontsourceFaces.d.ts +47 -0
  20. package/dist/fonts/fontsourceFaces.js +230 -0
  21. package/dist/layout-bridge/convert/flowBlockPostprocessing.d.ts +8 -0
  22. package/dist/layout-bridge/convert/flowBlockPostprocessing.js +106 -0
  23. package/dist/layout-bridge/convert/flowBorders.d.ts +17 -0
  24. package/dist/layout-bridge/convert/flowBorders.js +31 -0
  25. package/dist/layout-bridge/convert/flowConversionShared.d.ts +111 -0
  26. package/dist/layout-bridge/convert/flowConversionShared.js +23 -0
  27. package/dist/layout-bridge/convert/footnoteLayout.d.ts +2 -1
  28. package/dist/layout-bridge/convert/footnoteLayout.js +2 -1
  29. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +2 -1
  30. package/dist/layout-bridge/convert/imageConversion.d.ts +33 -0
  31. package/dist/layout-bridge/convert/imageConversion.js +118 -0
  32. package/dist/layout-bridge/convert/listMarkers.d.ts +28 -0
  33. package/dist/layout-bridge/convert/listMarkers.js +98 -0
  34. package/dist/layout-bridge/convert/noteReferences.d.ts +14 -0
  35. package/dist/layout-bridge/convert/noteReferences.js +29 -0
  36. package/dist/layout-bridge/convert/pageBreakSplitting.d.ts +16 -0
  37. package/dist/layout-bridge/convert/pageBreakSplitting.js +140 -0
  38. package/dist/layout-bridge/convert/paragraphAttrs.d.ts +20 -0
  39. package/dist/layout-bridge/convert/paragraphAttrs.js +234 -0
  40. package/dist/layout-bridge/convert/paragraphConversion.d.ts +12 -0
  41. package/dist/layout-bridge/convert/paragraphConversion.js +112 -0
  42. package/dist/layout-bridge/convert/paragraphRuns.d.ts +15 -0
  43. package/dist/layout-bridge/convert/paragraphRuns.js +281 -0
  44. package/dist/layout-bridge/convert/runFormattingMerge.d.ts +19 -0
  45. package/dist/layout-bridge/convert/runFormattingMerge.js +167 -0
  46. package/dist/layout-bridge/convert/runMarkFormatting.d.ts +11 -0
  47. package/dist/layout-bridge/convert/runMarkFormatting.js +291 -0
  48. package/dist/layout-bridge/convert/sectionBoundaries.d.ts +35 -0
  49. package/dist/layout-bridge/convert/sectionBoundaries.js +114 -0
  50. package/dist/layout-bridge/convert/tableConversion.d.ts +12 -0
  51. package/dist/layout-bridge/convert/tableConversion.js +303 -0
  52. package/dist/layout-bridge/convert/textBoxFill.d.ts +20 -0
  53. package/dist/layout-bridge/convert/textBoxFill.js +42 -0
  54. package/dist/layout-bridge/convert/textFormattingConversion.d.ts +22 -0
  55. package/dist/layout-bridge/convert/textFormattingConversion.js +120 -0
  56. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +5 -113
  57. package/dist/layout-bridge/convert/toFlowBlocks.js +11 -2082
  58. package/dist/layout-bridge/engine/hitTest.js +1 -1
  59. package/dist/layout-bridge/engine/measuring/index.d.ts +2 -1
  60. package/dist/layout-bridge/engine/measuring/index.js +2 -1
  61. package/dist/layout-bridge/engine/selectionRects.js +1 -1
  62. package/dist/layout-engine/imageLayout.d.ts +9 -0
  63. package/dist/layout-engine/imageLayout.js +48 -0
  64. package/dist/layout-engine/index.d.ts +3 -18
  65. package/dist/layout-engine/index.js +12 -937
  66. package/dist/layout-engine/layoutFlowShared.d.ts +6 -0
  67. package/dist/layout-engine/layoutFlowShared.js +8 -0
  68. package/dist/layout-engine/measure/crossRunWords.d.ts +45 -0
  69. package/dist/layout-engine/measure/crossRunWords.js +172 -0
  70. package/dist/layout-engine/measure/floatingLineClearance.d.ts +24 -0
  71. package/dist/layout-engine/measure/floatingLineClearance.js +44 -0
  72. package/dist/layout-engine/measure/inlineObjectMetrics.d.ts +6 -0
  73. package/dist/layout-engine/measure/inlineObjectMetrics.js +52 -0
  74. package/dist/layout-engine/measure/justification.d.ts +32 -0
  75. package/dist/layout-engine/measure/justification.js +83 -0
  76. package/dist/layout-engine/measure/lineFitting.d.ts +48 -0
  77. package/dist/layout-engine/measure/lineFitting.js +145 -0
  78. package/dist/layout-engine/measure/lineTypography.d.ts +44 -0
  79. package/dist/layout-engine/measure/lineTypography.js +124 -0
  80. package/dist/layout-engine/measure/measureBlocks.js +2 -1
  81. package/dist/layout-engine/measure/measureParagraph.d.ts +1 -20
  82. package/dist/layout-engine/measure/measureParagraph.js +32 -734
  83. package/dist/layout-engine/measure/paragraphMeasureShared.d.ts +109 -0
  84. package/dist/layout-engine/measure/paragraphMeasureShared.js +82 -0
  85. package/dist/layout-engine/measure/paragraphTabs.d.ts +18 -0
  86. package/dist/layout-engine/measure/paragraphTabs.js +78 -0
  87. package/dist/layout-engine/paragraphLayout.d.ts +23 -0
  88. package/dist/layout-engine/paragraphLayout.js +204 -0
  89. package/dist/layout-engine/sectionLayout.d.ts +45 -0
  90. package/dist/layout-engine/sectionLayout.js +216 -0
  91. package/dist/layout-engine/tableLayout.d.ts +17 -0
  92. package/dist/layout-engine/tableLayout.js +369 -0
  93. package/dist/layout-engine/textBoxLayout.d.ts +15 -0
  94. package/dist/layout-engine/textBoxLayout.js +137 -0
  95. package/dist/layout-painter/renderPage.js +1 -0
  96. package/dist/managers/editorShortcuts.d.ts +16 -3
  97. package/dist/managers/editorShortcuts.js +6 -4
  98. package/dist/paged-layout/sectionBlockWidths.d.ts +2 -1
  99. package/dist/paged-layout/sectionBlockWidths.js +1 -1
  100. package/dist/prosemirror/extensions/StarterKit.d.ts +3 -0
  101. package/dist/prosemirror/extensions/StarterKit.js +2 -1
  102. package/dist/prosemirror/extensions/core/HistoryExtension.d.ts +9 -1
  103. package/dist/prosemirror/extensions/core/HistoryExtension.js +4 -3
  104. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.d.ts +12 -5
  105. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +91 -33
  106. package/dist/server.d.ts +3 -2
  107. package/package.json +2 -2
@@ -431,30 +431,61 @@ const separatorBetween = (whole, head, tail) => {
431
431
  const separator = whole.slice(head.length, whole.length - tail.length);
432
432
  return separator.length === 0 || /^\s+$/u.test(separator) ? separator : null;
433
433
  };
434
+ const splitPlan = ({ baseBlock, head, tail }) => {
435
+ const separator = separatorBetween(baseBlock.text, head.text, tail.text);
436
+ return separator !== null && contentBlocksShareContainer(head, tail) ? {
437
+ type: "split",
438
+ baseBlock,
439
+ revisedBlocks: [head, tail],
440
+ offset: head.text.length,
441
+ separator
442
+ } : null;
443
+ };
444
+ const mergePlan = ({ revisedBlock, head, tail }) => {
445
+ const separator = separatorBetween(revisedBlock.text, head.text, tail.text);
446
+ return separator !== null && contentBlocksShareContainer(head, tail) ? {
447
+ type: "merge",
448
+ baseBlocks: [head, tail],
449
+ revisedBlock,
450
+ separator
451
+ } : null;
452
+ };
453
+ /**
454
+ * A pair next to a one-sided block its text spells together with the pair's
455
+ * other side. Alignment pairs a split paragraph with whichever half reads more
456
+ * like it, so the unpaired half may stand before the pair or after it.
457
+ */
458
+ const paragraphMarkPlan = (step, next) => {
459
+ if (step.type === "pair" && next.type === "revisedOnly") return splitPlan({
460
+ baseBlock: step.baseBlock,
461
+ head: step.revisedBlock,
462
+ tail: next.block
463
+ });
464
+ if (step.type === "pair" && next.type === "baseOnly") return mergePlan({
465
+ revisedBlock: step.revisedBlock,
466
+ head: step.baseBlock,
467
+ tail: next.block
468
+ });
469
+ if (step.type === "revisedOnly" && next.type === "pair") return splitPlan({
470
+ baseBlock: next.baseBlock,
471
+ head: step.block,
472
+ tail: next.revisedBlock
473
+ });
474
+ if (step.type === "baseOnly" && next.type === "pair") return mergePlan({
475
+ revisedBlock: next.revisedBlock,
476
+ head: step.block,
477
+ tail: next.baseBlock
478
+ });
479
+ return null;
480
+ };
481
+ /** Plans keyed by their first step; each consumes the step after it. */
434
482
  const detectFolioContentParagraphMarkPlans = (steps) => {
435
483
  const plans = /* @__PURE__ */ new Map();
436
484
  for (const [index, step] of steps.entries()) {
437
485
  const next = steps[index + 1];
438
- if (step.type !== "pair" || next === void 0) continue;
439
- if (next.type === "revisedOnly") {
440
- const separator = separatorBetween(step.baseBlock.text, step.revisedBlock.text, next.block.text);
441
- if (separator !== null && contentBlocksShareContainer(step.revisedBlock, next.block)) plans.set(index, {
442
- type: "split",
443
- baseBlock: step.baseBlock,
444
- revisedBlocks: [step.revisedBlock, next.block],
445
- offset: step.revisedBlock.text.length,
446
- separator
447
- });
448
- continue;
449
- }
450
- if (next.type !== "baseOnly") continue;
451
- const separator = separatorBetween(step.revisedBlock.text, step.baseBlock.text, next.block.text);
452
- if (separator !== null && contentBlocksShareContainer(step.baseBlock, next.block)) plans.set(index, {
453
- type: "merge",
454
- baseBlocks: [step.baseBlock, next.block],
455
- revisedBlock: step.revisedBlock,
456
- separator
457
- });
486
+ if (next === void 0 || plans.has(index - 1)) continue;
487
+ const plan = paragraphMarkPlan(step, next);
488
+ if (plan !== null) plans.set(index, plan);
458
489
  }
459
490
  return plans;
460
491
  };
@@ -1,10 +1,19 @@
1
1
  import { DisplayColor, DisplayFontFace, DisplayImageSource, DisplayList, DisplayPage } from "../types.js";
2
2
  //#region src/display-list/dom/renderDisplayListToDom.d.ts
3
+ /**
4
+ * How the backend addresses image and embedded-font bytes: `objectUrl` mints
5
+ * a `blob:` URL where the runtime can (a live page, which revokes them), and
6
+ * `dataUrl` always inlines the bytes (markup that leaves the process, such as
7
+ * a serialized page, where a `blob:` URL would resolve to nothing).
8
+ */
9
+ type DisplayBinaryUrls = "objectUrl" | "dataUrl";
3
10
  type RenderDisplayListOptions = {
4
11
  /** Document to create elements in. */
5
12
  readonly doc: Document;
6
13
  /** Page background, painted before any primitive. */
7
14
  readonly pageBackground?: DisplayColor;
15
+ /** Defaults to `objectUrl`. */
16
+ readonly binaryUrls?: DisplayBinaryUrls;
8
17
  };
9
18
  /**
10
19
  * A page's glyph runs and images index tables that live on the list, and an
@@ -23,4 +32,4 @@ declare const renderDisplayPageToDom: (page: DisplayPage, options: RenderDisplay
23
32
  /** One `div.layout-page` per display page, in page order. */
24
33
  declare const renderDisplayListToDom: (list: DisplayList, options: RenderDisplayListOptions) => HTMLElement[];
25
34
  //#endregion
26
- export { RenderDisplayListOptions, RenderDisplayPageOptions, renderDisplayListToDom, renderDisplayPageToDom };
35
+ export { DisplayBinaryUrls, RenderDisplayListOptions, RenderDisplayPageOptions, renderDisplayListToDom, renderDisplayPageToDom };
@@ -103,8 +103,8 @@ const resolveImage = (ref, images) => {
103
103
  };
104
104
  /** A `Blob` part cannot be backed by shared memory, so prove it is not. */
105
105
  const isBlobPart = (bytes) => bytes.buffer instanceof ArrayBuffer;
106
- const binarySrc = (bytes, mimeType) => {
107
- if (isBlobPart(bytes) && typeof Blob === "function" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function") return URL.createObjectURL(new Blob([bytes], { type: mimeType }));
106
+ const binarySrc = (bytes, mimeType, urls) => {
107
+ if (urls === "objectUrl" && isBlobPart(bytes) && typeof Blob === "function" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function") return URL.createObjectURL(new Blob([bytes], { type: mimeType }));
108
108
  return bytesToDataUrl(bytes, mimeType);
109
109
  };
110
110
  const fontFaceRule = (face, embedded, src) => `@font-face { font-family: ${quoteFontFamily(embedded.id)}; src: url("${src}"); font-weight: ${face.weight}; font-style: ${face.italic ? "italic" : "normal"}; }`;
@@ -113,14 +113,14 @@ const fontFaceRule = (face, embedded, src) => `@font-face { font-family: ${quote
113
113
  * once per render and shared by every page: an id maps to one blob, so the
114
114
  * bytes are held once and one revoke releases them.
115
115
  */
116
- const embeddedFontFaceCss = (fonts) => {
116
+ const embeddedFontFaceCss = (fonts, urls) => {
117
117
  const srcById = /* @__PURE__ */ new Map();
118
118
  const rules = [];
119
119
  for (const face of fonts) {
120
120
  const embedded = face.embedded;
121
121
  if (embedded === void 0) continue;
122
122
  const cached = srcById.get(embedded.id);
123
- const src = cached ?? binarySrc(embedded.bytes, FONT_MIME_TYPE);
123
+ const src = cached ?? binarySrc(embedded.bytes, FONT_MIME_TYPE, urls);
124
124
  if (cached === void 0) srcById.set(embedded.id, src);
125
125
  rules.push(fontFaceRule(face, embedded, src));
126
126
  }
@@ -399,7 +399,7 @@ const paintImage = ({ image, rect, crop, opacity, brightness, contrast }, contex
399
399
  element.style.height = px(fullHeight);
400
400
  }
401
401
  element.alt = "";
402
- element.src = binarySrc(source.bytes, IMAGE_MIME_TYPES[source.format]);
402
+ element.src = binarySrc(source.bytes, IMAGE_MIME_TYPES[source.format], context.binaryUrls);
403
403
  clip.append(element);
404
404
  context.parent.append(clip);
405
405
  };
@@ -497,6 +497,7 @@ const renderPage = (page, options) => {
497
497
  }
498
498
  const context = {
499
499
  doc: options.doc,
500
+ binaryUrls: options.binaryUrls ?? "objectUrl",
500
501
  fonts: options.fonts,
501
502
  images: options.images,
502
503
  parent: element,
@@ -595,11 +596,11 @@ const paintRegionTree = ({ primitives, regions }, context) => {
595
596
  };
596
597
  const renderDisplayPageToDom = (page, options) => renderPage(page, {
597
598
  ...options,
598
- fontFaceCss: embeddedFontFaceCss(options.fonts)
599
+ fontFaceCss: embeddedFontFaceCss(options.fonts, options.binaryUrls ?? "objectUrl")
599
600
  });
600
601
  /** One `div.layout-page` per display page, in page order. */
601
602
  const renderDisplayListToDom = (list, options) => {
602
- const fontFaceCss = embeddedFontFaceCss(list.fonts);
603
+ const fontFaceCss = embeddedFontFaceCss(list.fonts, options.binaryUrls ?? "objectUrl");
603
604
  return list.pages.map((page, pageIndex) => renderPage(page, {
604
605
  ...options,
605
606
  fonts: list.fonts,
@@ -0,0 +1,21 @@
1
+ import { DisplayColor, DisplayList } from "../types.js";
2
+ //#region src/display-list/html/renderDisplayListToHtml.d.ts
3
+ declare const escapeHtmlText: (value: string) => string;
4
+ type RenderDisplayListToHtmlOptions = {
5
+ /** `@font-face` rules for the families the list names, placed in the head. */
6
+ readonly fontFaceCss?: string;
7
+ /** The document title. */
8
+ readonly title?: string;
9
+ /** Space between pages. Defaults to 0: pages stack flush. */
10
+ readonly pageGapPx?: number;
11
+ /** CSS color behind the pages. Defaults to white. */
12
+ readonly canvasColor?: string;
13
+ /** Page background, painted before any primitive. */
14
+ readonly pageBackground?: DisplayColor;
15
+ };
16
+ /** The pages' markup, one slot per display page, in page order. */
17
+ declare const renderDisplayListPagesToHtml: (list: DisplayList, options?: Pick<RenderDisplayListToHtmlOptions, "pageBackground">) => string[];
18
+ /** A complete HTML document showing every page of the list. */
19
+ declare const renderDisplayListToHtml: (list: DisplayList, options?: RenderDisplayListToHtmlOptions) => string;
20
+ //#endregion
21
+ export { RenderDisplayListToHtmlOptions, escapeHtmlText, renderDisplayListPagesToHtml, renderDisplayListToHtml };
@@ -0,0 +1,126 @@
1
+ import { renderDisplayListToDom } from "../dom/renderDisplayListToDom.js";
2
+ //#region src/display-list/html/renderDisplayListToHtml.ts
3
+ /**
4
+ * A display list as one HTML document, with no browser and no DOM.
5
+ *
6
+ * The DOM backend paints into whatever `Document` it is handed. This module
7
+ * hands it a document with only what the backend reaches for and serializes
8
+ * the result, so a server, a CLI or a harness can produce the page a browser
9
+ * would show from the same painter the editor uses. Binary data (images,
10
+ * embedded faces) is inlined as `data:` URLs, because a `blob:` URL minted in
11
+ * this process resolves to nothing once the markup leaves it.
12
+ */
13
+ const VOID_TAGS = /* @__PURE__ */ new Set([
14
+ "img",
15
+ "br",
16
+ "hr"
17
+ ]);
18
+ const createStubElement = (tagName) => {
19
+ const children = [];
20
+ const attributes = {};
21
+ return {
22
+ tagName,
23
+ style: {},
24
+ dataset: {},
25
+ attributes,
26
+ children,
27
+ className: "",
28
+ id: "",
29
+ textContent: "",
30
+ alt: "",
31
+ src: "",
32
+ href: "",
33
+ title: "",
34
+ append: (...nodes) => {
35
+ children.push(...nodes);
36
+ },
37
+ appendChild: (node) => {
38
+ children.push(node);
39
+ return node;
40
+ },
41
+ setAttribute: (name, value) => {
42
+ attributes[name] = value;
43
+ }
44
+ };
45
+ };
46
+ /**
47
+ * A `Document` with only what the DOM backend reaches for. A backend that
48
+ * grows a new requirement throws on the missing property here rather than
49
+ * silently serializing less than it painted.
50
+ */
51
+ const stubDocument = () => {
52
+ return { createElement: createStubElement };
53
+ };
54
+ const asStub = (element) => element;
55
+ /** A vendor property is camelCase with no leading capital, so its leading dash is explicit. */
56
+ const VENDOR_PREFIXES = [
57
+ "webkit-",
58
+ "moz-",
59
+ "ms-",
60
+ "o-"
61
+ ];
62
+ const toKebabCase = (name) => {
63
+ const hyphenated = name.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);
64
+ return VENDOR_PREFIXES.some((prefix) => hyphenated.startsWith(prefix)) ? `-${hyphenated}` : hyphenated;
65
+ };
66
+ const escapeHtmlText = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
67
+ const escapeAttribute = (value) => escapeHtmlText(value).replaceAll("\"", "&quot;");
68
+ const serializeStyle = (style) => Object.entries(style).map(([property, value]) => `${toKebabCase(property)}: ${value}`).join("; ");
69
+ /**
70
+ * Serialized without one character of added whitespace: the backend sets
71
+ * `white-space: pre` on every run, so an indentation newline would be painted.
72
+ */
73
+ const RAW_TEXT_TAGS = /* @__PURE__ */ new Set(["style", "script"]);
74
+ /** Text placed inside a `<style>` or `<script>`: a `</` cannot close the element early. */
75
+ const rawElementText = (text) => text.replaceAll("</", "<\\/");
76
+ const serializeElement = (element) => {
77
+ const attributes = [];
78
+ if (element.className !== "") attributes.push(`class="${escapeAttribute(element.className)}"`);
79
+ if (element.id !== "") attributes.push(`id="${escapeAttribute(element.id)}"`);
80
+ if (element.href !== "") attributes.push(`href="${escapeAttribute(element.href)}"`);
81
+ if (element.src !== "") attributes.push(`src="${escapeAttribute(element.src)}"`);
82
+ if (element.title !== "") attributes.push(`title="${escapeAttribute(element.title)}"`);
83
+ if (element.tagName === "img") attributes.push(`alt="${escapeAttribute(element.alt)}"`);
84
+ for (const [name, value] of Object.entries(element.attributes)) attributes.push(`${name}="${escapeAttribute(value)}"`);
85
+ for (const [key, value] of Object.entries(element.dataset)) attributes.push(`data-${toKebabCase(key)}="${escapeAttribute(value)}"`);
86
+ const style = serializeStyle(element.style);
87
+ if (style !== "") attributes.push(`style="${escapeAttribute(style)}"`);
88
+ const open = [element.tagName, ...attributes].join(" ");
89
+ if (VOID_TAGS.has(element.tagName)) return `<${open} />`;
90
+ if (element.children.length > 0) return `<${open}>${element.children.map(serializeElement).join("")}</${element.tagName}>`;
91
+ return `<${open}>${RAW_TEXT_TAGS.has(element.tagName) ? rawElementText(element.textContent) : escapeHtmlText(element.textContent)}</${element.tagName}>`;
92
+ };
93
+ /**
94
+ * Each page sits in a slot of whole pixels. A page height is fractional (A4 is
95
+ * 1122.52 px), so pages stacked in normal flow would start at fractional
96
+ * offsets; the slot rounds the offset while the page keeps its exact size.
97
+ */
98
+ const pageSlot = (markup, page) => `<div class="layout-page-slot" style="position: relative; overflow: hidden; width: ${String(Math.ceil(page.widthPx))}px; height: ${String(Math.ceil(page.heightPx))}px">${markup}</div>`;
99
+ /** The pages' markup, one slot per display page, in page order. */
100
+ const renderDisplayListPagesToHtml = (list, options = {}) => renderDisplayListToDom(list, {
101
+ doc: stubDocument(),
102
+ binaryUrls: "dataUrl",
103
+ ...options.pageBackground !== void 0 && { pageBackground: options.pageBackground }
104
+ }).map((element, index) => {
105
+ const markup = serializeElement(asStub(element));
106
+ const page = list.pages.at(index);
107
+ return page === void 0 ? markup : pageSlot(markup, page);
108
+ });
109
+ /** A complete HTML document showing every page of the list. */
110
+ const renderDisplayListToHtml = (list, options = {}) => {
111
+ const gap = options.pageGapPx ?? 0;
112
+ const canvas = rawElementText(options.canvasColor ?? "#fff");
113
+ const layout = gap > 0 ? `body { display: flex; flex-direction: column; align-items: center; gap: ${String(gap)}px; padding: ${String(gap)}px 0; }` : "";
114
+ return [
115
+ "<!doctype html>",
116
+ `<html><head><meta charset="utf-8"><title>${escapeHtmlText(options.title ?? "")}</title><style>`,
117
+ rawElementText(options.fontFaceCss ?? ""),
118
+ `html, body { margin: 0; padding: 0; background: ${canvas}; }`,
119
+ layout,
120
+ "</style></head><body>",
121
+ renderDisplayListPagesToHtml(list, options).join(""),
122
+ "</body></html>"
123
+ ].join("\n");
124
+ };
125
+ //#endregion
126
+ export { escapeHtmlText, renderDisplayListPagesToHtml, renderDisplayListToHtml };
@@ -0,0 +1,6 @@
1
+ import { DisplayList } from "./types.js";
2
+ //#region src/display-list/selectDisplayPages.d.ts
3
+ /** Keep `pageIndices` (0-based, in the order given; duplicates are dropped). */
4
+ declare const selectDisplayPages: (list: DisplayList, pageIndices: readonly number[]) => DisplayList;
5
+ //#endregion
6
+ export { selectDisplayPages };
@@ -0,0 +1,44 @@
1
+ //#region src/display-list/selectDisplayPages.ts
2
+ /** Keep `pageIndices` (0-based, in the order given; duplicates are dropped). */
3
+ const selectDisplayPages = (list, pageIndices) => {
4
+ const kept = [...new Set(pageIndices)].filter((index) => Number.isInteger(index) && index >= 0 && index < list.pages.length);
5
+ const remap = new Map(kept.map((original, next) => [original, next]));
6
+ const relink = (link) => {
7
+ if (link.target.kind !== "page") return [link];
8
+ const pageIndex = remap.get(link.target.pageIndex);
9
+ return pageIndex === void 0 ? [] : [{
10
+ ...link,
11
+ target: {
12
+ ...link.target,
13
+ pageIndex
14
+ }
15
+ }];
16
+ };
17
+ const pages = kept.flatMap((index) => {
18
+ const page = list.pages[index];
19
+ return page === void 0 ? [] : [{
20
+ ...page,
21
+ links: page.links.flatMap(relink)
22
+ }];
23
+ });
24
+ return {
25
+ ...list,
26
+ pages,
27
+ outline: list.outline.flatMap((entry) => {
28
+ const pageIndex = remap.get(entry.pageIndex);
29
+ return pageIndex === void 0 ? [] : [{
30
+ ...entry,
31
+ pageIndex
32
+ }];
33
+ }),
34
+ unsupported: list.unsupported.flatMap((entry) => {
35
+ const pageIndex = remap.get(entry.pageIndex);
36
+ return pageIndex === void 0 ? [] : [{
37
+ ...entry,
38
+ pageIndex
39
+ }];
40
+ })
41
+ };
42
+ };
43
+ //#endregion
44
+ export { selectDisplayPages };
@@ -3,7 +3,7 @@ import { document_d_exports } from "../types/document.js";
3
3
  type SelectiveSaveOptions = {
4
4
  /** Changed paragraph IDs to selectively patch */
5
5
  changedParaIds: Set<string>;
6
- /** Whether structural changes occurred (paragraph add/delete) */
6
+ /** Whether paragraph membership, order, or block structure changed. */
7
7
  structuralChange: boolean;
8
8
  /** Whether any changes affected paragraphs without paraId */
9
9
  hasUntrackedChanges: boolean;
@@ -8,12 +8,13 @@ import { isUnsafePackagePath } from "./packageParts.js";
8
8
  import { RELATIONSHIP_TYPES } from "./relsParser.js";
9
9
  import { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_PART_LOWER, addCommentsExtendedOverride, addCommentsExtendedRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, hasUnmaterializedInlineResources, updateCoreProperties, withoutAttachedTemplate } from "./rezip.js";
10
10
  import "./selectiveSaveFlags.js";
11
- import { buildPatchedDocumentXml, buildPatchedNoteXml, collectParaIds, patchNumberingDefinitions } from "./selectiveXmlPatch.js";
11
+ import { buildPatchedDocumentXml, buildPatchedNoteXml, collectAddedNumberingDefs, collectParaIds, patchNumberingDefinitions } from "./selectiveXmlPatch.js";
12
12
  import { planCommentParts, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
13
13
  import { serializeDocument } from "./serializer/documentSerializer.js";
14
14
  import { serializeEndnotes, serializeFootnotes } from "./serializer/noteSerializer.js";
15
15
  import { serializeNumberingXml } from "./serializer/numberingSerializer.js";
16
16
  import { readRootNamespaceBindings } from "./serializer/partNamespaces.js";
17
+ import { buildStructuralDocumentPatch } from "./structuralXmlPatch.js";
17
18
  import { DOCX_CONFORMANCE_CLASSES } from "@stll/docx-core/model";
18
19
  //#region src/docx/selectiveSave.ts
19
20
  /**
@@ -174,7 +175,6 @@ const queueSettingsUpdates = async (zip, updates) => {
174
175
  async function attemptSelectiveSave(doc, originalBuffer, options) {
175
176
  const { changedParaIds, structuralChange, hasUntrackedChanges } = options;
176
177
  const maxBytes = options.maxBytes ?? 104857600;
177
- if (structuralChange) return null;
178
178
  if (hasUntrackedChanges) return null;
179
179
  if (originalBuffer.byteLength > maxBytes) return null;
180
180
  const content = doc.package.document.content;
@@ -197,22 +197,32 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
197
197
  const zip = await (await import("jszip")).default.loadAsync(originalBuffer);
198
198
  for (const [path, file] of Object.entries(zip.files)) if (!file.dir && isUnsafePackagePath(path)) return null;
199
199
  const updates = /* @__PURE__ */ new Map();
200
- if (changedParaIds.size > 0) {
200
+ if (changedParaIds.size > 0 || structuralChange) {
201
201
  const docXmlFile = zip.file("word/document.xml");
202
202
  if (!docXmlFile) return null;
203
203
  const originalDocXml = await docXmlFile.async("text");
204
204
  const serializedDocXml = serializeDocument(doc, readRootNamespaceBindings(originalDocXml));
205
205
  const bodyParaIds = collectParaIds(serializedDocXml);
206
+ const originalBodyParaIds = structuralChange ? collectParaIds(originalDocXml) : void 0;
207
+ if (structuralChange && doc.package.numbering) {
208
+ const numberingFile = findZipEntryCaseInsensitive(zip, "word/numbering.xml");
209
+ const added = collectAddedNumberingDefs(numberingFile ? serializeNumberingXml(parseNumbering(await numberingFile.async("text")).definitions) : "", serializeNumberingXml(doc.package.numbering));
210
+ if (added.abstractNums.size > 0 || added.nums.size > 0) return null;
211
+ }
206
212
  const bodyChangedIds = /* @__PURE__ */ new Set();
207
213
  const noteCandidateIds = /* @__PURE__ */ new Set();
208
- for (const id of changedParaIds) if (bodyParaIds.has(id)) bodyChangedIds.add(id);
214
+ for (const id of changedParaIds) if (bodyParaIds.has(id) || originalBodyParaIds?.has(id)) bodyChangedIds.add(id);
209
215
  else noteCandidateIds.add(id);
210
216
  if (noteCandidateIds.size > 0) {
211
217
  const unrouted = await patchNoteParts(zip, doc, noteCandidateIds, updates);
212
218
  if (unrouted === null || unrouted.size > 0) return null;
213
219
  }
214
- if (bodyChangedIds.size > 0) {
215
- const patchedDocXml = buildPatchedDocumentXml(originalDocXml, serializedDocXml, bodyChangedIds);
220
+ if (bodyChangedIds.size > 0 || structuralChange) {
221
+ const patchedDocXml = structuralChange ? buildStructuralDocumentPatch({
222
+ originalXml: originalDocXml,
223
+ serializedXml: serializedDocXml,
224
+ changedIds: bodyChangedIds
225
+ }) : buildPatchedDocumentXml(originalDocXml, serializedDocXml, bodyChangedIds);
216
226
  if (!patchedDocXml) return null;
217
227
  updates.set("word/document.xml", patchedDocXml);
218
228
  }
@@ -0,0 +1,14 @@
1
+ //#region src/docx/structuralXmlPatch.d.ts
2
+ type StructuralPatchOptions = {
3
+ originalXml: string;
4
+ serializedXml: string;
5
+ changedIds: ReadonlySet<string>;
6
+ };
7
+ /**
8
+ * Insert/delete direct body paragraphs between surviving paragraph/table anchors.
9
+ * Tables are opaque barriers: their modeled content and order must stay identical.
10
+ * Id-less sources need an explicit ensureParaIds ingest before structural editing.
11
+ */
12
+ declare const buildStructuralDocumentPatch: ({ originalXml, serializedXml, changedIds }: StructuralPatchOptions) => string | null;
13
+ //#endregion
14
+ export { buildStructuralDocumentPatch };
@@ -0,0 +1,209 @@
1
+ import { canonicalJson } from "../utils/canonicalJson.js";
2
+ import { parseDocumentBody } from "./documentParser.js";
3
+ import { paraIdAttribute } from "./paraIdAttribute.js";
4
+ import { spliceXml } from "./selectiveXmlPatch.js";
5
+ import { readRootNamespaceBindings, serializePartElement } from "./serializer/partNamespaces.js";
6
+ import { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, getChildElements, getLocalName, getNamespaceUri, parseXmlDocument } from "./xmlParser.js";
7
+ //#region src/docx/structuralXmlPatch.ts
8
+ /** Conservative body-paragraph splices; source offsets never come from reserialization. */
9
+ const isWordElement = (element, name) => getLocalName(element.name) === name && WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "");
10
+ /** Match XML tokens, skipping quoted delimiters, comments, CDATA and processing instructions. */
11
+ const XML_TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<(?:"[^"]*"|'[^']*'|[^'">])*>/gu;
12
+ const readBody = (xml) => {
13
+ const root = parseXmlDocument(xml);
14
+ if (!root || !isWordElement(root, "document")) return null;
15
+ if (getNamespaceUri(root) !== NAMESPACES.w) return null;
16
+ const body = getChildElements(root).find((child) => isWordElement(child, "body"));
17
+ if (!body) return null;
18
+ const children = getChildElements(body);
19
+ const blocks = [];
20
+ const stack = [];
21
+ let bodyDepth = -1;
22
+ let bodyEnd = -1;
23
+ let start = -1;
24
+ for (const token of xml.matchAll(XML_TOKEN)) {
25
+ const tag = token[0];
26
+ if (tag.startsWith("<!") || tag.startsWith("<?")) continue;
27
+ const closing = tag.startsWith("</");
28
+ const name = tag.slice(closing ? 2 : 1).split(/[\s/>]/u).at(0);
29
+ if (!name) return null;
30
+ if (closing) {
31
+ if (stack.pop() !== name) return null;
32
+ if (stack.length === bodyDepth) {
33
+ const element = children[blocks.length];
34
+ if (!element || start < 0) return null;
35
+ blocks.push({
36
+ element,
37
+ start,
38
+ end: token.index + tag.length
39
+ });
40
+ start = -1;
41
+ }
42
+ if (name === body.name && stack.length === bodyDepth - 1) bodyEnd = token.index;
43
+ continue;
44
+ }
45
+ if (name === body.name && stack.length === 1) bodyDepth = 2;
46
+ if (stack.length === bodyDepth) {
47
+ if (children[blocks.length]?.name !== name) return null;
48
+ start = token.index;
49
+ if (tag.endsWith("/>")) {
50
+ const element = children[blocks.length];
51
+ if (!element) return null;
52
+ blocks.push({
53
+ element,
54
+ start,
55
+ end: token.index + tag.length
56
+ });
57
+ start = -1;
58
+ }
59
+ }
60
+ if (!tag.endsWith("/>")) stack.push(name);
61
+ }
62
+ if (stack.length > 0 || blocks.length !== children.length || bodyEnd < 0) return null;
63
+ return {
64
+ root,
65
+ blocks,
66
+ bodyEnd
67
+ };
68
+ };
69
+ function* descendants(element) {
70
+ yield element;
71
+ for (const child of getChildElements(element)) yield* descendants(child);
72
+ }
73
+ /** Cross-paragraph ranges require a wider edit contract than paragraph identity. */
74
+ const RANGE_ELEMENTS = /* @__PURE__ */ new Set([
75
+ "commentRangeStart",
76
+ "commentRangeEnd",
77
+ "commentReference",
78
+ "bookmarkStart",
79
+ "bookmarkEnd",
80
+ "permStart",
81
+ "permEnd",
82
+ "fldChar",
83
+ "moveFromRangeStart",
84
+ "moveFromRangeEnd",
85
+ "moveToRangeStart",
86
+ "moveToRangeEnd"
87
+ ]);
88
+ const DEPENDENT_ELEMENTS = /* @__PURE__ */ new Set([
89
+ "sectPr",
90
+ "footnoteReference",
91
+ "endnoteReference",
92
+ "drawing",
93
+ "pict",
94
+ "object"
95
+ ]);
96
+ const paragraphIsSafe = (element, touched) => {
97
+ for (const child of descendants(element)) {
98
+ if (!WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(child) ?? "")) continue;
99
+ const name = getLocalName(child.name);
100
+ if (touched && DEPENDENT_ELEMENTS.has(name)) return false;
101
+ }
102
+ return true;
103
+ };
104
+ const paragraphIds = (root) => {
105
+ const ids = /* @__PURE__ */ new Set();
106
+ for (const element of descendants(root)) {
107
+ if (!isWordElement(element, "p")) continue;
108
+ const id = paraIdAttribute(element);
109
+ if (!id || !/^[0-9a-f]{8}$/iu.test(id) || /^0+$/u.test(id) || ids.has(id.toUpperCase())) return null;
110
+ ids.add(id.toUpperCase());
111
+ }
112
+ return ids;
113
+ };
114
+ /** Bind fragment prefixes locally: the source root may use entirely different aliases. */
115
+ const paragraphFragment = ({ xml, block, bindings }) => {
116
+ const fragment = xml.slice(block.start, block.end);
117
+ const openEnd = fragment.indexOf(">");
118
+ const name = block.element.name ?? "w:p";
119
+ const selfClosing = fragment[openEnd - 1] === "/";
120
+ return serializePartElement({
121
+ partPath: "word/document.xml",
122
+ rootName: name,
123
+ rootAttributes: fragment.slice(name.length + 1, selfClosing ? openEnd - 1 : openEnd).trim(),
124
+ baselinePrefixes: [],
125
+ sourceBindings: bindings,
126
+ body: selfClosing ? "" : fragment.slice(openEnd + 1, fragment.lastIndexOf("</"))
127
+ });
128
+ };
129
+ /**
130
+ * Insert/delete direct body paragraphs between surviving paragraph/table anchors.
131
+ * Tables are opaque barriers: their modeled content and order must stay identical.
132
+ * Id-less sources need an explicit ensureParaIds ingest before structural editing.
133
+ */
134
+ const buildStructuralDocumentPatch = ({ originalXml, serializedXml, changedIds }) => {
135
+ const source = readBody(originalXml);
136
+ const current = readBody(serializedXml);
137
+ if (!source || !current) return null;
138
+ for (const root of [source.root, current.root]) for (const element of descendants(root)) if (WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "") && RANGE_ELEMENTS.has(getLocalName(element.name))) return null;
139
+ const allSourceIds = paragraphIds(source.root);
140
+ const allCurrentIds = paragraphIds(current.root);
141
+ if (!allSourceIds || !allCurrentIds) return null;
142
+ const changed = new Set([...changedIds].map((id) => id.toUpperCase()));
143
+ for (const { element } of [...source.blocks, ...current.blocks]) if (isWordElement(element, "p")) {
144
+ const id = paraIdAttribute(element)?.toUpperCase();
145
+ if (!id) return null;
146
+ const touched = changed.has(id) || !allSourceIds.has(id) || !allCurrentIds.has(id);
147
+ if (!paragraphIsSafe(element, touched)) return null;
148
+ } else if (!isWordElement(element, "tbl") && !isWordElement(element, "sectPr")) return null;
149
+ const sourceBody = parseDocumentBody(originalXml);
150
+ const currentBody = parseDocumentBody(serializedXml);
151
+ const barriers = (body) => ({
152
+ blocks: body.content.filter((block) => block.type !== "paragraph"),
153
+ finalSectionProperties: body.finalSectionProperties,
154
+ background: body.background
155
+ });
156
+ if (canonicalJson(barriers(sourceBody)) !== canonicalJson(barriers(currentBody))) return null;
157
+ const indexBlocks = (blocks) => {
158
+ let barrier = 0;
159
+ const indexed = /* @__PURE__ */ new Map();
160
+ for (const block of blocks) {
161
+ const key = isWordElement(block.element, "p") ? `p:${paraIdAttribute(block.element)?.toUpperCase()}` : `barrier:${barrier++}`;
162
+ indexed.set(key, block);
163
+ }
164
+ return indexed;
165
+ };
166
+ const before = indexBlocks(source.blocks);
167
+ const after = indexBlocks(current.blocks);
168
+ const survivingBefore = [...before.keys()].filter((key) => after.has(key));
169
+ const survivingAfter = [...after.keys()].filter((key) => before.has(key));
170
+ if (canonicalJson(survivingBefore) !== canonicalJson(survivingAfter)) return null;
171
+ const bindings = readRootNamespaceBindings(serializedXml);
172
+ const fragmentFor = (block) => paragraphFragment({
173
+ xml: serializedXml,
174
+ block,
175
+ bindings
176
+ });
177
+ const splices = [];
178
+ for (const [key, block] of before) if (!after.has(key)) splices.push({
179
+ start: block.start,
180
+ end: block.end,
181
+ newXml: ""
182
+ });
183
+ let pending = [];
184
+ for (const [key, block] of after) {
185
+ const original = before.get(key);
186
+ if (!original) {
187
+ const id = paraIdAttribute(block.element)?.toUpperCase();
188
+ if (!id || allSourceIds.has(id)) return null;
189
+ pending.push(fragmentFor(block));
190
+ continue;
191
+ }
192
+ const id = paraIdAttribute(block.element)?.toUpperCase();
193
+ const replacement = id !== void 0 && changed.has(id) ? fragmentFor(block) : originalXml.slice(original.start, original.end);
194
+ if (pending.length > 0 || replacement !== originalXml.slice(original.start, original.end)) splices.push({
195
+ start: original.start,
196
+ end: original.end,
197
+ newXml: pending.join("") + replacement
198
+ });
199
+ pending = [];
200
+ }
201
+ if (pending.length > 0) splices.push({
202
+ start: source.bodyEnd,
203
+ end: source.bodyEnd,
204
+ newXml: pending.join("")
205
+ });
206
+ return spliceXml(originalXml, splices);
207
+ };
208
+ //#endregion
209
+ export { buildStructuralDocumentPatch };