@stll/folio-core 0.33.2 → 0.34.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 (44) hide show
  1. package/dist/ai-edits/apply.js +137 -23
  2. package/dist/ai-edits/snapshot.js +24 -2
  3. package/dist/ai-edits/types.d.ts +6 -6
  4. package/dist/compare/formatting.d.ts +1 -1
  5. package/dist/compare/formatting.js +38 -9
  6. package/dist/compare/verification.js +18 -1
  7. package/dist/controller/headerFooterEditorManager.js +11 -9
  8. package/dist/controller/layoutPipeline.js +24 -3
  9. package/dist/display-list/build/watermarkPrimitives.js +15 -3
  10. package/dist/document-operations.js +30 -3
  11. package/dist/docx/headerFooterParser.js +8 -14
  12. package/dist/docx/paragraphParser.js +45 -0
  13. package/dist/docx/serializer/headerFooterSerializer.js +21 -2
  14. package/dist/docx/serializer/paragraphSerializer.d.ts +1 -1
  15. package/dist/docx/serializer/paragraphSerializer.js +19 -9
  16. package/dist/docx/settingsParser.js +3 -0
  17. package/dist/docx/watermarkParser.d.ts +2 -4
  18. package/dist/docx/watermarkParser.js +4 -6
  19. package/dist/headless-layout.js +14 -2
  20. package/dist/layout-bridge/convert/footnoteLayout.d.ts +2 -2
  21. package/dist/layout-bridge/convert/footnoteLayout.js +23 -10
  22. package/dist/layout-bridge/convert/headerFooterLayout.js +13 -2
  23. package/dist/layout-bridge/convert/toFlowBlocks.js +96 -13
  24. package/dist/layout-engine/index.js +11 -4
  25. package/dist/layout-engine/justifiedLineFit.d.ts +4 -4
  26. package/dist/layout-engine/justifiedLineFit.js +4 -4
  27. package/dist/layout-engine/measure/lineBreakProvider.js +1 -0
  28. package/dist/layout-engine/measure/measureBlocks.js +1 -1
  29. package/dist/layout-engine/measure/measureParagraph.js +29 -29
  30. package/dist/layout-painter/renderPage.js +6 -1
  31. package/dist/layout-painter/renderParagraph.js +18 -8
  32. package/dist/layout-painter/renderWatermark.js +11 -4
  33. package/dist/prosemirror/attrs/index.js +11 -0
  34. package/dist/prosemirror/commands/comments.js +22 -2
  35. package/dist/prosemirror/conversion/fromProseDoc.js +32 -5
  36. package/dist/prosemirror/conversion/toProseDoc.js +49 -11
  37. package/dist/prosemirror/extensions/core/DocExtension.js +7 -1
  38. package/dist/prosemirror/extensions/core/ParagraphExtension.js +1 -0
  39. package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.js +1 -0
  40. package/dist/prosemirror/schema/marks.d.ts +2 -8
  41. package/dist/utils/fontResolver.js +51 -0
  42. package/dist/utils/formatToStyle.js +41 -1
  43. package/dist/watermark/index.js +7 -0
  44. package/package.json +2 -2
@@ -763,6 +763,47 @@ function parseSimpleField(node, styles, theme, rels, media, rootXmlns = {}) {
763
763
  function hasRunPayloadElement(runElement) {
764
764
  return getChildElements(runElement).some((child) => !matchesName(child, "w", "rPr"));
765
765
  }
766
+ const LEGACY_FORM_CHECKBOX_GLYPHS = {
767
+ checked: "☒",
768
+ unchecked: "☐"
769
+ };
770
+ const LEGACY_FORM_CHECKBOX_INSTRUCTION = "FORMCHECKBOX";
771
+ function getLegacyFormCheckboxDisplay(runElement) {
772
+ const fieldChar = findChild(runElement, "w", "fldChar");
773
+ const fieldData = fieldChar ? findChild(fieldChar, "w", "ffData") : null;
774
+ const checkBox = fieldData ? findChild(fieldData, "w", "checkBox") : null;
775
+ if (!checkBox) return;
776
+ const checked = findChild(checkBox, "w", "checked");
777
+ const defaultChecked = findChild(checkBox, "w", "default");
778
+ let isChecked = defaultChecked ? parseBooleanElement(defaultChecked) : false;
779
+ if (checked) isChecked = parseBooleanElement(checked);
780
+ const explicitSize = parseNumericAttribute(findChild(checkBox, "w", "size"), "w", "val");
781
+ const text = isChecked ? LEGACY_FORM_CHECKBOX_GLYPHS.checked : LEGACY_FORM_CHECKBOX_GLYPHS.unchecked;
782
+ if (explicitSize === void 0) return { text };
783
+ return {
784
+ text,
785
+ fontSize: explicitSize
786
+ };
787
+ }
788
+ function createLegacyFormCheckboxResultRun(display, inheritedFormatting) {
789
+ const run = {
790
+ type: "run",
791
+ content: [{
792
+ type: "text",
793
+ text: display.text
794
+ }]
795
+ };
796
+ const hasInheritedFormatting = inheritedFormatting !== void 0 && Object.keys(inheritedFormatting).length > 0;
797
+ if (display.fontSize !== void 0) run.formatting = hasInheritedFormatting ? {
798
+ ...inheritedFormatting,
799
+ fontSize: display.fontSize
800
+ } : { fontSize: display.fontSize };
801
+ else if (hasInheritedFormatting) run.formatting = inheritedFormatting;
802
+ return run;
803
+ }
804
+ function isLegacyFormCheckboxInstruction(instruction) {
805
+ return instruction.trim().split(/\s+/u).at(0)?.toUpperCase() === LEGACY_FORM_CHECKBOX_INSTRUCTION;
806
+ }
766
807
  /**
767
808
  * Parse all content within a paragraph
768
809
  *
@@ -779,6 +820,7 @@ function parseParagraphContents(paraElement, styles, theme, _numbering, rels, me
779
820
  let afterSeparator = false;
780
821
  let complexFieldLock = false;
781
822
  let complexFieldDirty = false;
823
+ let complexFieldFallbackDisplay;
782
824
  let complexFieldFormatting;
783
825
  for (const child of children) {
784
826
  const localName = getLocalName(child.name);
@@ -790,6 +832,7 @@ function parseParagraphContents(paraElement, styles, theme, _numbering, rels, me
790
832
  let hasFieldBegin = false;
791
833
  let beginFldLock = false;
792
834
  let beginDirty = false;
835
+ const beginFallbackDisplay = getLegacyFormCheckboxDisplay(runElement);
793
836
  let hasFieldSeparate = false;
794
837
  let hasFieldEnd = false;
795
838
  let endOriginalValue;
@@ -816,6 +859,7 @@ function parseParagraphContents(paraElement, styles, theme, _numbering, rels, me
816
859
  complexFieldResultRuns = [];
817
860
  complexFieldLock = beginFldLock;
818
861
  complexFieldDirty = beginDirty;
862
+ complexFieldFallbackDisplay = beginFallbackDisplay;
819
863
  complexFieldFormatting = run.formatting;
820
864
  }
821
865
  if (inComplexField) {
@@ -840,6 +884,7 @@ function parseParagraphContents(paraElement, styles, theme, _numbering, rels, me
840
884
  }
841
885
  if (hasFieldEnd) {
842
886
  let resultRuns = complexFieldResultRuns;
887
+ if (resultRuns.length === 0 && complexFieldFallbackDisplay !== void 0 && isLegacyFormCheckboxInstruction(complexFieldInstr)) resultRuns = [createLegacyFormCheckboxResultRun(complexFieldFallbackDisplay, complexFieldFormatting)];
843
888
  if (resultRuns.length === 0 && !afterSeparator && endOriginalValue !== void 0) resultRuns = [{
844
889
  type: "run",
845
890
  content: [{
@@ -1,4 +1,7 @@
1
1
  import { canReplayHeaderFooterVerbatim, getHeaderFooterVerbatimXml } from "../headerFooterVerbatim.js";
2
+ import { isEmptyParagraph } from "../paragraphParser.js";
3
+ import { captureVerbatimXml } from "../verbatimCapture.js";
4
+ import { getLocalName, parseXmlDocument } from "../xmlParser.js";
2
5
  import { serializeBlockSdt } from "./blockSdtSerializer.js";
3
6
  import { serializeParagraph } from "./paragraphSerializer.js";
4
7
  import { serializePartElement } from "./partNamespaces.js";
@@ -53,8 +56,16 @@ function serializeHeaderFooter(hf, source) {
53
56
  const watermarkInsertIndex = hf.watermarkBlockIndex !== void 0 ? Math.max(0, Math.min(hf.watermarkBlockIndex, hf.content.length)) : 0;
54
57
  const blocksXml = hf.content.map((block) => serializeBlock(block));
55
58
  let contentXml;
56
- if (watermarkXml) contentXml = blocksXml.slice(0, watermarkInsertIndex).join("") + watermarkXml + blocksXml.slice(watermarkInsertIndex).join("");
57
- else contentXml = blocksXml.join("");
59
+ if (watermarkXml) {
60
+ const watermarkHost = hf.content.at(watermarkInsertIndex);
61
+ const rawWatermarkXml = hf.rawWatermarkXml;
62
+ const mergesIntoRetainedHost = rawWatermarkXml !== void 0 && watermarkHost?.type === "paragraph" && isEmptyParagraph(watermarkHost);
63
+ if (mergesIntoRetainedHost) blocksXml[watermarkInsertIndex] = serializeRawWatermarkIntoHost({
64
+ hostXml: blocksXml[watermarkInsertIndex] ?? serializeParagraph(watermarkHost),
65
+ rawWatermarkXml
66
+ });
67
+ contentXml = blocksXml.slice(0, watermarkInsertIndex).join("") + (mergesIntoRetainedHost ? "" : watermarkXml) + blocksXml.slice(watermarkInsertIndex).join("");
68
+ } else contentXml = blocksXml.join("");
58
69
  if (!contentXml) contentXml = "<w:p><w:pPr/></w:p>";
59
70
  return "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n" + serializePartElement({
60
71
  partPath: source?.path ?? `word/${hf.type}.xml`,
@@ -64,6 +75,14 @@ function serializeHeaderFooter(hf, source) {
64
75
  body: contentXml
65
76
  });
66
77
  }
78
+ function serializeRawWatermarkIntoHost({ hostXml, rawWatermarkXml }) {
79
+ const rawHost = parseXmlDocument(rawWatermarkXml);
80
+ if (!rawHost || getLocalName(rawHost.name ?? "") !== "p") return rawWatermarkXml;
81
+ const watermarkContent = (rawHost.elements ?? []).filter((child) => child.type === "element" && getLocalName(child.name ?? "") !== "pPr").map((child) => captureVerbatimXml(child)).join("");
82
+ if (!watermarkContent) return rawWatermarkXml;
83
+ const namespaceAttributes = Object.entries(rawHost.attributes ?? {}).filter(([name, value]) => value !== void 0 && (name === "xmlns" || name.startsWith("xmlns:"))).map(([name, value]) => ` ${name}="${escapeXml(String(value))}"`).join("");
84
+ return hostXml.replace(/^<w:p(?=[\s>])/u, `<w:p${namespaceAttributes}`).replace("</w:p>", `${watermarkContent}</w:p>`);
85
+ }
67
86
  function serializeWatermarkParagraph(hf) {
68
87
  if (hf.rawWatermarkXml) return hf.rawWatermarkXml;
69
88
  if (hf.watermark) return synthesizeWatermarkParagraph(hf.watermark);
@@ -1,6 +1,6 @@
1
1
  import { document_d_exports } from "../../types/document.js";
2
2
  //#region src/docx/serializer/paragraphSerializer.d.ts
3
- declare function serializeParagraphFormatting(formatting: document_d_exports.ParagraphFormatting | undefined, propertyChanges?: document_d_exports.ParagraphPropertyChange[], pPrMark?: document_d_exports.ParagraphMarkChange): string;
3
+ declare function serializeParagraphFormatting(formatting: document_d_exports.ParagraphFormatting | undefined, propertyChanges?: document_d_exports.ParagraphPropertyChange[], paragraphMarkChange?: document_d_exports.ParagraphMarkChange): string;
4
4
  /**
5
5
  * Serialize a paragraph to OOXML XML (w:p)
6
6
  *
@@ -140,7 +140,7 @@ function serializeParagraphMarkChange(mark) {
140
140
  const attrs = serializeTrackedChangeAttrs(mark.info);
141
141
  return `<w:${mark.kind} ${attrs}/>`;
142
142
  }
143
- function serializeParagraphFormatting(formatting, propertyChanges, pPrMark) {
143
+ const serializeParagraphFormattingWithOptions = (formatting, { propertyChanges, paragraphMarkChange, sectionProperties } = {}) => {
144
144
  const parts = [];
145
145
  const pushToggle = (name, value) => {
146
146
  if (value === true) parts.push(`<w:${name}/>`);
@@ -175,14 +175,22 @@ function serializeParagraphFormatting(formatting, propertyChanges, pPrMark) {
175
175
  pushToggle("contextualSpacing", formatting.contextualSpacing);
176
176
  if (formatting.alignment) parts.push(`<w:jc w:val="${formatting.alignment}"/>`);
177
177
  if (formatting.outlineLevel !== void 0) parts.push(`<w:outlineLvl w:val="${formatting.outlineLevel}"/>`);
178
- if (pPrMark || formatting.runProperties || formatting.runInWithNext) {
179
- const fullInner = `${pPrMark ? serializeParagraphMarkChange(pPrMark) : ""}${formatting.runProperties ? extractRPrInner(serializeTextFormatting(formatting.runProperties)) : ""}${formatting.runInWithNext ? "<w:specVanish/>" : ""}`;
178
+ if (paragraphMarkChange || formatting.runProperties || formatting.runInWithNext) {
179
+ const fullInner = `${paragraphMarkChange ? serializeParagraphMarkChange(paragraphMarkChange) : ""}${formatting.runProperties ? extractRPrInner(serializeTextFormatting(formatting.runProperties)) : ""}${formatting.runInWithNext ? "<w:specVanish/>" : ""}`;
180
180
  if (fullInner.length > 0) parts.push(`<w:rPr>${fullInner}</w:rPr>`);
181
181
  }
182
- } else if (pPrMark) parts.push(`<w:rPr>${serializeParagraphMarkChange(pPrMark)}</w:rPr>`);
182
+ } else if (paragraphMarkChange) parts.push(`<w:rPr>${serializeParagraphMarkChange(paragraphMarkChange)}</w:rPr>`);
183
+ parts.push(serializeSectionProperties(sectionProperties));
183
184
  if (propertyChanges && propertyChanges.length > 0) parts.push(...propertyChanges.map((change) => serializeParagraphPropertyChange(change)));
184
- if (parts.length === 0) return "";
185
- return `<w:pPr>${parts.join("")}</w:pPr>`;
185
+ const inner = parts.join("");
186
+ if (inner.length === 0) return "";
187
+ return `<w:pPr>${inner}</w:pPr>`;
188
+ };
189
+ function serializeParagraphFormatting(formatting, propertyChanges, paragraphMarkChange) {
190
+ return serializeParagraphFormattingWithOptions(formatting, {
191
+ propertyChanges,
192
+ paragraphMarkChange
193
+ });
186
194
  }
187
195
  function extractPPrInner(pPrXml) {
188
196
  if (!pPrXml.startsWith("<w:pPr>") || !pPrXml.endsWith("</w:pPr>")) return "";
@@ -486,9 +494,11 @@ function serializeParagraph(paragraph) {
486
494
  if (paragraph.paraId) attrs.push(`w14:paraId="${escapeXml(paragraph.paraId)}"`);
487
495
  if (paragraph.textId) attrs.push(`w14:textId="${escapeXml(paragraph.textId)}"`);
488
496
  const attrsStr = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
489
- const pPrXml = serializeParagraphFormatting(paragraph.formatting, paragraph.propertyChanges, paragraph.pPrMark);
490
- const sectionPropertiesXml = serializeSectionProperties(paragraph.sectionProperties);
491
- if (pPrXml || sectionPropertiesXml) parts.push(`<w:pPr>${extractPPrInner(pPrXml)}${sectionPropertiesXml}</w:pPr>`);
497
+ parts.push(serializeParagraphFormattingWithOptions(paragraph.formatting, {
498
+ propertyChanges: paragraph.propertyChanges,
499
+ paragraphMarkChange: paragraph.pPrMark,
500
+ sectionProperties: paragraph.sectionProperties
501
+ }));
492
502
  const explicitCommentReferenceIds = /* @__PURE__ */ new Set();
493
503
  for (const content of paragraph.content) if (content.type === "commentReference") explicitCommentReferenceIds.add(content.id);
494
504
  let pendingRenderedPageBreak = paragraph.renderedPageBreakBefore === true;
@@ -55,6 +55,9 @@ function parseSettings(xml) {
55
55
  if (compatibilityMode !== void 0) settings.compatibilityMode = compatibilityMode;
56
56
  const splitPageBreakAndParagraphMark = compat ? findChild(compat, "w", "splitPgBreakAndParaMark") : null;
57
57
  if (splitPageBreakAndParagraphMark && parseBooleanElement(splitPageBreakAndParagraphMark)) settings.splitPageBreakAndParagraphMark = true;
58
+ const wordprocessingCompat = root ? findChildByNamespaceUri(root, WORDPROCESSINGML_NAMESPACE_URIS, "compat") : null;
59
+ const adjustLineHeightInTable = wordprocessingCompat ? findChildByNamespaceUri(wordprocessingCompat, WORDPROCESSINGML_NAMESPACE_URIS, "adjustLineHeightInTable") : null;
60
+ if (adjustLineHeightInTable && parseBooleanElement(adjustLineHeightInTable)) settings.adjustLineHeightInTable = true;
58
61
  const applyBreakingRules = compat ? findChild(compat, "w", "applyBreakingRules") : null;
59
62
  const useLegacyEthiopicAmharicRules = applyBreakingRules !== null && parseBooleanElement(applyBreakingRules);
60
63
  if (noLineBreaksBefore || noLineBreaksAfter || useLegacyEthiopicAmharicRules) settings.lineBreakRules = {
@@ -14,10 +14,8 @@ type ParsedWatermark = {
14
14
  hostingParagraph: XmlElement;
15
15
  /**
16
16
  * Index where the watermark paragraph sat among block-level siblings
17
- * (`w:p` / `w:tbl`) in the source header. After the host paragraph is
18
- * filtered out of `content`, the serializer inserts the watermark
19
- * back at this index so a header that originally placed the
20
- * watermark after visible text rounds-trips with the same flow.
17
+ * (`w:p` / `w:tbl` / `w:sdt`) in the source header. The serializer uses
18
+ * this index to replace the retained host paragraph with watermark XML.
21
19
  */
22
20
  blockIndex: number;
23
21
  };
@@ -59,11 +59,9 @@ function parseWatermark(header) {
59
59
  }
60
60
  }
61
61
  /**
62
- * Index of `target` among block-level children of `header` (only
63
- * `w:p` and `w:tbl` count as blocks). Since the hosting paragraph
64
- * is filtered out of `content` after parse, this is the index the
65
- * serializer needs to splice the watermark XML back into so the
66
- * header rounds-trips with the same flow as the source.
62
+ * Index of `target` among block-level children of `header`. Keep this list
63
+ * aligned with `parseBlockContent`, which emits paragraphs, tables, and
64
+ * block-level content controls as one content item each.
67
65
  */
68
66
  function blockIndexOf(header, target) {
69
67
  let blockIdx = 0;
@@ -71,7 +69,7 @@ function blockIndexOf(header, target) {
71
69
  if (child === target) return blockIdx;
72
70
  if (child.type !== "element") continue;
73
71
  const local = getLocalName(child.name ?? "");
74
- if (local === "p" || local === "tbl") blockIdx++;
72
+ if (local === "p" || local === "tbl" || local === "sdt") blockIdx++;
75
73
  }
76
74
  return 0;
77
75
  }
@@ -1,3 +1,5 @@
1
+ import { resolveDocumentGridLinePitch } from "./docx/documentGrid.js";
2
+ import { formatOoxmlCounter } from "./docx/ooxmlCounterFormatter.js";
1
3
  import { parseDocx } from "./docx/parser.js";
2
4
  import { buildHeaderFooterFieldValues } from "./fields/resolveFieldValues.js";
3
5
  import { extractEmbeddedFonts } from "./fonts/embeddedFonts.js";
@@ -50,6 +52,11 @@ import { createHash } from "node:crypto";
50
52
  * backend in a browser); this module refuses to guess, because a silently
51
53
  * wrong provider produces a plausible layout that is wrong everywhere.
52
54
  */
55
+ const formatEndnoteTexts = (numbers, formatNumber) => {
56
+ const texts = /* @__PURE__ */ new Map();
57
+ for (const [id, displayNumber] of numbers) texts.set(id, formatNumber(displayNumber));
58
+ return texts;
59
+ };
53
60
  var HeadlessLayoutError = class extends TaggedError("HeadlessLayoutError") {};
54
61
  /**
55
62
  * Stories this entry still does not lay out, keyed by story so the map is total
@@ -93,6 +100,8 @@ const buildFlowOptions = (document, pageContentHeight) => {
93
100
  ...settings.consecutiveHyphenLimit === void 0 ? {} : { consecutiveLineLimit: settings.consecutiveHyphenLimit },
94
101
  ...settings.hyphenationZoneTwips === void 0 ? {} : { hyphenationZoneTwips: settings.hyphenationZoneTwips }
95
102
  };
103
+ const finalSectionDocumentGridLinePitchTwips = resolveDocumentGridLinePitch(document.package.document.sections?.at(-1)?.properties.docGrid);
104
+ if (finalSectionDocumentGridLinePitchTwips !== void 0) options.finalSectionDocumentGridLinePitchTwips = finalSectionDocumentGridLinePitchTwips;
96
105
  return options;
97
106
  };
98
107
  /**
@@ -237,9 +246,12 @@ const layoutDocxHeadless = async (input, options = {}) => {
237
246
  const footnotes = document.package.footnotes ?? [];
238
247
  const endnotes = document.package.endnotes ?? [];
239
248
  const footnoteRefs = collectFootnoteRefs(authored);
249
+ const footnoteNumbers = computeNoteDisplayNumbers(footnotes, footnoteRefs.map((ref) => ref.footnoteId));
250
+ const endnoteNumbers = computeNoteDisplayNumbers(endnotes, collectEndnoteRefs(authored).map((ref) => ref.endnoteId));
251
+ const endnoteNumberFormat = finalSection?.endnotePr?.numFmt ?? "lowerRoman";
240
252
  const blocks = remapNoteMarkerText(authored, {
241
- footnoteNumbers: computeNoteDisplayNumbers(footnotes, footnoteRefs.map((ref) => ref.footnoteId)),
242
- endnoteNumbers: computeNoteDisplayNumbers(endnotes, collectEndnoteRefs(authored).map((ref) => ref.endnoteId))
253
+ footnoteNumbers,
254
+ endnoteTexts: formatEndnoteTexts(endnoteNumbers, (displayNumber) => formatOoxmlCounter(displayNumber, endnoteNumberFormat))
243
255
  });
244
256
  const measures = measureBlocks(blocks, contentWidth);
245
257
  const footnoteContentById = footnoteRefs.length === 0 ? void 0 : buildFootnoteContentMap(footnotes, footnoteRefs, contentWidth, {
@@ -52,8 +52,8 @@ declare function computeNoteDisplayNumbers(notes: readonly NumberableNote[], ref
52
52
  type NoteDisplayNumberMaps = {
53
53
  /** footnote `w:id` → sequential display number */
54
54
  footnoteNumbers?: ReadonlyMap<number, number>;
55
- /** endnote `w:id` → sequential display number */
56
- endnoteNumbers?: ReadonlyMap<number, number>;
55
+ /** endnote `w:id` → formatted display text */
56
+ endnoteTexts?: ReadonlyMap<number, string>;
57
57
  };
58
58
  /**
59
59
  * Rewrite body reference-marker run text from the raw `w:id` (which the PM
@@ -80,7 +80,7 @@ function computeNoteDisplayNumbers(notes, refNoteIds) {
80
80
  * template preview substitution).
81
81
  */
82
82
  function remapNoteMarkerText(blocks, maps) {
83
- if ((maps.footnoteNumbers?.size ?? 0) === 0 && (maps.endnoteNumbers?.size ?? 0) === 0) return blocks;
83
+ if ((maps.footnoteNumbers?.size ?? 0) === 0 && (maps.endnoteTexts?.size ?? 0) === 0) return blocks;
84
84
  let changed = false;
85
85
  const next = blocks.map((block) => {
86
86
  const remapped = remapNoteMarkerBlock(block, maps);
@@ -147,17 +147,19 @@ function remapNoteMarkerParagraph(block, maps) {
147
147
  }
148
148
  function remapNoteMarkerRun(run, maps) {
149
149
  if (run.kind !== "text") return run;
150
- const displayNumber = getRunDisplayNumber(run, maps);
151
- if (displayNumber === void 0) return run;
152
- const text = String(displayNumber);
153
- return run.text === text ? run : {
150
+ const displayText = getRunDisplayText(run, maps);
151
+ if (displayText === void 0) return run;
152
+ return run.text === displayText ? run : {
154
153
  ...run,
155
- text
154
+ text: displayText
156
155
  };
157
156
  }
158
- function getRunDisplayNumber(run, maps) {
159
- if (run.footnoteRefId !== void 0) return maps.footnoteNumbers?.get(run.footnoteRefId);
160
- if (run.endnoteRefId !== void 0) return maps.endnoteNumbers?.get(run.endnoteRefId);
157
+ function getRunDisplayText(run, maps) {
158
+ if (run.footnoteRefId !== void 0) {
159
+ const displayNumber = maps.footnoteNumbers?.get(run.footnoteRefId);
160
+ return displayNumber === void 0 ? void 0 : String(displayNumber);
161
+ }
162
+ if (run.endnoteRefId !== void 0) return maps.endnoteTexts?.get(run.endnoteRefId);
161
163
  }
162
164
  /**
163
165
  * After layout, determine which footnotes appear on which pages.
@@ -202,7 +204,7 @@ function convertFootnoteToContent(footnote, displayNumber, contentWidth, options
202
204
  if (options.justificationCompatibility) flowOptions.justificationCompatibility = options.justificationCompatibility;
203
205
  if (options.tableIndentCompatibility) flowOptions.tableIndentCompatibility = options.tableIndentCompatibility;
204
206
  if (options.automaticHyphenation) flowOptions.automaticHyphenation = options.automaticHyphenation;
205
- const blocks = applyFootnotePresentation(toFlowBlocks(pmDoc, flowOptions), displayNumber);
207
+ const blocks = applyFootnotePresentation(preserveAuthoredFootnoteTerminalParagraph(toFlowBlocks(pmDoc, flowOptions)), displayNumber);
206
208
  const measures = options.measureBlocks ? options.measureBlocks(blocks, contentWidth) : measureFootnoteBlocks(blocks, contentWidth);
207
209
  let totalHeight = 0;
208
210
  for (const measure of measures) if (measure.kind === "paragraph") totalHeight += measure.totalHeight;
@@ -216,6 +218,17 @@ function convertFootnoteToContent(footnote, displayNumber, contentWidth, options
216
218
  height: totalHeight
217
219
  };
218
220
  }
221
+ /** Restore an authored footnote line that the body-only terminal-table policy collapsed. */
222
+ function preserveAuthoredFootnoteTerminalParagraph(blocks) {
223
+ const finalBlock = blocks.at(-1);
224
+ if (blocks.at(-2)?.kind !== "table" || finalBlock?.kind !== "paragraph" || finalBlock.attrs?.suppressEmptyParagraphHeight !== true) return blocks;
225
+ const attrs = { ...finalBlock.attrs };
226
+ delete attrs.suppressEmptyParagraphHeight;
227
+ return [...blocks.slice(0, -1), {
228
+ ...finalBlock,
229
+ attrs
230
+ }];
231
+ }
219
232
  function measureFootnoteBlocks(blocks, contentWidth) {
220
233
  return blocks.map((block) => measureFootnoteBlock(block, contentWidth));
221
234
  }
@@ -3,6 +3,16 @@ import { headerFooterToProseDoc } from "../../prosemirror/conversion/toProseDoc.
3
3
  import { emuToPixels } from "../../utils/units.js";
4
4
  import { toFlowBlocks } from "./toFlowBlocks.js";
5
5
  //#region src/layout-bridge/convert/headerFooterLayout.ts
6
+ const DETACHED_WATERMARK_HOST = Symbol.for("stll.detachedWatermarkHost");
7
+ const headerFooterToProseDocWithDetachedWatermarkHost = (headerFooter, options) => {
8
+ return headerFooterToProseDoc(headerFooter.content.map((block, blockIndex) => {
9
+ if (blockIndex !== headerFooter.watermarkBlockIndex || block.type !== "paragraph") return block;
10
+ return {
11
+ ...block,
12
+ [DETACHED_WATERMARK_HOST]: true
13
+ };
14
+ }), options);
15
+ };
6
16
  function isAnchoredImageRun(run) {
7
17
  if (run.kind !== "image") return false;
8
18
  if (run.position) return true;
@@ -241,7 +251,7 @@ function calculateHeaderFooterVisualBounds(blocks, measures, flowHeight, metrics
241
251
  * these bounds separately describe the in-flow portion for API consumers.
242
252
  */
243
253
  function calculateHeaderFooterMarginPushBounds(blocks, measures, flowHeight, metrics) {
244
- if (blocks.length > 0 && blocks.every((block) => isPaintlessParagraph(block) && !hasAuthoredVisualContent(block))) return {
254
+ if (blocks.length > 0 && blocks.every((block) => block.kind === "paragraph" && isPaintlessParagraph(block) && !hasAuthoredVisualContent(block) && block.attrs?.suppressEmptyParagraphHeight !== false && !preservesInheritedSpacing(block))) return {
245
255
  top: 0,
246
256
  bottom: 0
247
257
  };
@@ -330,7 +340,7 @@ function convertHeaderFooterToContent(headerFooter, contentWidth, metrics, optio
330
340
  const proseDocOptions = {};
331
341
  if (options.styles) proseDocOptions.styles = options.styles;
332
342
  if (options.theme !== void 0) proseDocOptions.theme = options.theme;
333
- const pmDoc = headerFooterToProseDoc(headerFooter.content, proseDocOptions);
343
+ const pmDoc = headerFooter.watermarkBlockIndex === void 0 ? headerFooterToProseDoc(headerFooter.content, proseDocOptions) : headerFooterToProseDocWithDetachedWatermarkHost(headerFooter, proseDocOptions);
334
344
  const flowOptions = {};
335
345
  if (options.theme !== void 0) flowOptions.theme = options.theme;
336
346
  if (options.fontAlternates !== void 0) flowOptions.fontAlternates = options.fontAlternates;
@@ -458,6 +468,7 @@ function serializeParagraphAttrs(attrs) {
458
468
  const keys = [
459
469
  "alignment",
460
470
  "bidi",
471
+ "suppressEmptyParagraphHeight",
461
472
  "indent",
462
473
  "spacing",
463
474
  "styleId",
@@ -13,6 +13,7 @@ import { advanceListMarker, advanceVisibleListMarker, cloneListCounterState, for
13
13
  import { resolveNumberedRefFields } from "../../prosemirror/numberedRefFields.js";
14
14
  import { directionToBidi } from "../../prosemirror/paragraphDirection.js";
15
15
  import { cascadeStyleTextFormatting } from "../../prosemirror/styles/styleToggleCascade.js";
16
+ import { expectTextBoxAnchorAttrs } from "../../prosemirror/textBoxAnchorAttrs.js";
16
17
  import { assertValidProseMirrorDocument } from "../../prosemirror/validation.js";
17
18
  import { normalizeShapeTextAnchor } from "../../types/documentEnumValues.js";
18
19
  import { resolveColor, resolveHighlightToCss } from "../../utils/colorResolver.js";
@@ -20,12 +21,22 @@ import { resolveThemeFont } from "../../utils/fontResolver.js";
20
21
  import { resolveShadingFill } from "../../utils/formatToStyle.js";
21
22
  import { normalizeHorizontalScalePercent } from "../../utils/horizontalScale.js";
22
23
  import { decodeOoxmlSymbolCharacter } from "../../utils/ooxmlSymbol.js";
24
+ import { sanitizeImageSrc } from "../../utils/sanitizeImageSrc.js";
23
25
  import { tableOfContentsStyleLevel } from "../../utils/tableOfContentsStyle.js";
24
26
  import { AUTO_PARAGRAPH_SPACING_PX, halfPointsToPixels, halfPointsToPoints, pointsToPixels } from "../../utils/units.js";
25
27
  import { getColumns } from "../sectionColumns.js";
26
28
  import { groupParagraphFrames } from "./paragraphFrames.js";
29
+ import { panic } from "better-result";
27
30
  //#region src/layout-bridge/convert/toFlowBlocks.ts
31
+ const DETACHED_WATERMARK_HOST_ATTR = "_detachedWatermarkHost";
32
+ const expectDetachedWatermarkHostAttr = (attrs) => {
33
+ const value = Reflect.get(attrs, DETACHED_WATERMARK_HOST_ATTR);
34
+ if (value === null || value === void 0) return false;
35
+ if (typeof value !== "boolean") panic("Invalid ProseMirror detached watermark host attrs:\nparagraph.attrs._detachedWatermarkHost: Expected a boolean.");
36
+ return value;
37
+ };
28
38
  const DEFAULT_FONT = "Calibri";
39
+ const TEXT_BOX_ANCHOR_BLOCK_ID = Symbol.for("stll.textBoxAnchorBlockId");
29
40
  const DEFAULT_TABLE_CELL_MARGIN_TWIPS = {
30
41
  top: 0,
31
42
  right: 108,
@@ -454,6 +465,9 @@ function buildImageRun(attrs, constrained, pmStart, pmEnd, trackedChange) {
454
465
  if (trackedChange?.changeRevisionId !== void 0) run.changeRevisionId = trackedChange.changeRevisionId;
455
466
  return run;
456
467
  }
468
+ /** A package image whose bytes cannot paint still owns its authored line box. */
469
+ const hasRelationshipBackedImageBox = (attrs) => typeof attrs.rId === "string" && attrs.rId.trim().length > 0 && typeof attrs.width === "number" && Number.isFinite(attrs.width) && attrs.width >= 0 && typeof attrs.height === "number" && Number.isFinite(attrs.height) && attrs.height >= 0;
470
+ const hasPaintableImageSource = (src) => sanitizeImageSrc(src) !== void 0;
457
471
  /**
458
472
  * In TOC paragraphs, strip the resolved Hyperlink character-style colour and
459
473
  * underline so the painter's link fallback doesn't fire. The PM doc keeps the
@@ -564,7 +578,7 @@ function paragraphToRuns(node, startPos, _options) {
564
578
  }
565
579
  if (child.type.name === "image") {
566
580
  const attrs = expectImageAttrs(child);
567
- if (!attrs.src) return;
581
+ if (!hasPaintableImageSource(attrs.src) && !hasRelationshipBackedImageBox(attrs)) return;
568
582
  const constrained = constrainImageToPage(attrs.width ?? 100, attrs.height ?? 100, _options.pageContentHeight);
569
583
  const trackedFmt = extractRunFormatting(child.marks, theme, fontAlternates);
570
584
  const run = buildImageRun(attrs, constrained, childPos, childPos + child.nodeSize, trackedFmt);
@@ -878,6 +892,9 @@ function mapTabAlignment(align) {
878
892
  function hasOnlyVisuallyEmptyTextRuns(runs) {
879
893
  return runs.length > 0 && runs.every((run) => run.kind === "text" && run.text.replace(/\u00a0/gu, " ").trim().length === 0);
880
894
  }
895
+ function hasOnlyHiddenTextRuns(runs) {
896
+ return runs.length > 0 && runs.every((run) => run.kind === "text" && run.hidden === true);
897
+ }
881
898
  function convertParagraph(node, startPos, options) {
882
899
  const pmAttrs = expectParagraphAttrs(node);
883
900
  const runs = paragraphToRuns(node, startPos, options);
@@ -903,10 +920,10 @@ function convertParagraph(node, startPos, options) {
903
920
  if (alternate) attrs.defaultAlternateFontFamily = alternate;
904
921
  }
905
922
  }
906
- if (runs.length === 0 && defaultTextFormatting?.hidden === true) {
907
- attrs.suppressEmptyParagraphHeight = true;
908
- if (attrs.listMarker !== void 0) attrs.listMarkerHidden = true;
909
- }
923
+ const isFullyHiddenParagraph = defaultTextFormatting?.hidden === true && (runs.length === 0 || hasOnlyHiddenTextRuns(runs));
924
+ if (isFullyHiddenParagraph && attrs.listMarker !== void 0) attrs.listMarkerHidden = true;
925
+ if (isFullyHiddenParagraph) attrs.suppressEmptyParagraphHeight = true;
926
+ if (runs.length === 0 && expectDetachedWatermarkHostAttr(node.attrs)) attrs.suppressEmptyParagraphHeight = false;
910
927
  const hasVisibleParagraphPayload = attrs.listMarker !== void 0 && !attrs.listMarkerHidden || attrs.borders?.top !== void 0 || attrs.borders?.bottom !== void 0 || attrs.borders?.left !== void 0 || attrs.borders?.right !== void 0 || attrs.borders?.between !== void 0 || attrs.borders?.bar !== void 0 || attrs.shading !== void 0;
911
928
  if (runs.length === 0 && pmAttrs._pageBreakCarrier === true && !hasVisibleParagraphPayload) attrs.suppressEmptyParagraphHeight = true;
912
929
  const bookmarkNames = pmAttrs.bookmarks?.map((b) => b.name);
@@ -934,6 +951,11 @@ function convertParagraph(node, startPos, options) {
934
951
  ...frame.yAlign !== void 0 ? { yAlign: frame.yAlign } : {},
935
952
  ...frame.wrap !== void 0 ? { wrap: frame.wrap } : {}
936
953
  });
954
+ node.descendants((child) => {
955
+ if (child.type.name !== "textBoxAnchor") return true;
956
+ options.textBoxAnchorBlockIds.set(expectTextBoxAnchorAttrs(child).anchorId, block.id);
957
+ return false;
958
+ });
937
959
  return block;
938
960
  }
939
961
  /**
@@ -945,6 +967,7 @@ function convertParagraph(node, startPos, options) {
945
967
  function isPaintlessTerminalParagraph(block) {
946
968
  if (block?.kind !== "paragraph" || block.runs.length !== 0) return false;
947
969
  const attrs = block.attrs;
970
+ if (attrs?.suppressEmptyParagraphHeight === false) return false;
948
971
  return !(attrs?.listMarker !== void 0 && !attrs.listMarkerHidden || attrs?.borders?.top || attrs?.borders?.bottom || attrs?.borders?.left || attrs?.borders?.right || attrs?.borders?.between || attrs?.borders?.bar || attrs?.shading || attrs?.spacingExplicit?.before || attrs?.spacingExplicit?.after || attrs?.pageBreakBefore || attrs?.renderedPageBreakBefore);
949
972
  }
950
973
  function suppressFinalEmptyParagraphAfterTable(blocks) {
@@ -1188,6 +1211,7 @@ function convertTable(node, startPos, options) {
1188
1211
  */
1189
1212
  function convertImage(node, startPos, pageContentHeight) {
1190
1213
  const attrs = expectImageAttrs(node);
1214
+ if (!hasPaintableImageSource(attrs.src) && !hasRelationshipBackedImageBox(attrs)) return;
1191
1215
  const wrapType = attrs.wrapType;
1192
1216
  const shouldAnchor = wrapType === "behind" || wrapType === "inFront";
1193
1217
  const constrained = constrainImageToPage(attrs.width ?? 100, attrs.height ?? 100, pageContentHeight);
@@ -1269,6 +1293,8 @@ function convertTextBoxNode(node, startPos, opts) {
1269
1293
  if (attrs.distRight !== void 0) textBox.distRight = attrs.distRight;
1270
1294
  if (attrs.position !== void 0) textBox.position = attrs.position;
1271
1295
  if (attrs._docxGroupId !== void 0) setTextBoxGroupId(textBox, attrs._docxGroupId);
1296
+ const anchorBlockId = attrs._docxAnchorId ? opts.textBoxAnchorBlockIds.get(attrs._docxAnchorId) : void 0;
1297
+ if (anchorBlockId !== void 0) Reflect.set(textBox, TEXT_BOX_ANCHOR_BLOCK_ID, anchorBlockId);
1272
1298
  return textBox;
1273
1299
  }
1274
1300
  function getLastMapKey(map) {
@@ -1277,6 +1303,41 @@ function getLastMapKey(map) {
1277
1303
  return lastKey;
1278
1304
  }
1279
1305
  /**
1306
+ * Translate section-owned start modes into the boundary-owned values consumed
1307
+ * by the paginator. Each flow section break carries the properties for the
1308
+ * section it ends, so its start mode belongs on the preceding boundary.
1309
+ */
1310
+ function applySectionStartsToBoundaries(blocks, finalSectionStart) {
1311
+ const breakIndexes = [];
1312
+ for (let index = 0; index < blocks.length; index += 1) if (blocks[index]?.kind === "sectionBreak") breakIndexes.push(index);
1313
+ if (breakIndexes.length === 0) return [...blocks];
1314
+ const result = [...blocks];
1315
+ for (let index = 0; index < breakIndexes.length; index += 1) {
1316
+ const boundaryIndex = breakIndexes[index];
1317
+ if (boundaryIndex === void 0) continue;
1318
+ const boundary = blocks[boundaryIndex];
1319
+ if (boundary?.kind !== "sectionBreak") continue;
1320
+ const nextBoundaryIndex = breakIndexes[index + 1];
1321
+ const nextBoundary = nextBoundaryIndex === void 0 ? void 0 : blocks[nextBoundaryIndex];
1322
+ const nextStart = nextBoundary?.kind === "sectionBreak" ? nextBoundary.type : finalSectionStart;
1323
+ const translated = { ...boundary };
1324
+ if (nextStart === void 0) delete translated.type;
1325
+ else translated.type = nextStart;
1326
+ result[boundaryIndex] = translated;
1327
+ }
1328
+ return result;
1329
+ }
1330
+ function readFinalSectionStart(doc) {
1331
+ const sectionStart = doc.attrs["_finalSectionStart"];
1332
+ switch (sectionStart) {
1333
+ case "continuous":
1334
+ case "nextPage":
1335
+ case "oddPage":
1336
+ case "evenPage": return sectionStart;
1337
+ default: return;
1338
+ }
1339
+ }
1340
+ /**
1280
1341
  * Convert a ProseMirror document to FlowBlock array.
1281
1342
  *
1282
1343
  * Walks the document tree, converting each node to the appropriate block type.
@@ -1315,6 +1376,7 @@ function toFlowBlocks(doc, options = {}) {
1315
1376
  final: listCounterState,
1316
1377
  original: originalListCounterState
1317
1378
  },
1379
+ textBoxAnchorBlockIds: /* @__PURE__ */ new Map(),
1318
1380
  numberedRefResults: resolveNumberedRefFields(doc, {
1319
1381
  listCounterState: cloneListCounterState(listCounterState),
1320
1382
  originalListCounterState: cloneListCounterState(originalListCounterState)
@@ -1521,9 +1583,11 @@ function toFlowBlocks(doc, options = {}) {
1521
1583
  case "table":
1522
1584
  trackedPush(convertTable(node, pos, opts));
1523
1585
  break;
1524
- case "image":
1525
- trackedPush(convertImage(node, pos, opts.pageContentHeight));
1586
+ case "image": {
1587
+ const image = convertImage(node, pos, opts.pageContentHeight);
1588
+ if (image !== void 0) trackedPush(image);
1526
1589
  break;
1590
+ }
1527
1591
  case "textBox":
1528
1592
  trackedPush(convertTextBoxNode(node, pos, opts));
1529
1593
  break;
@@ -1548,24 +1612,43 @@ function toFlowBlocks(doc, options = {}) {
1548
1612
  reserveLeadingEmptyOutlineHeight(blocks);
1549
1613
  suppressFinalEmptyParagraphAfterTable(blocks);
1550
1614
  suppressFinalParagraphInRepeatedEmptySuffix(blocks);
1551
- return groupParagraphFrames(applySectionDocumentGrid(mergeRunInParagraphs(blocks), opts.finalSectionDocumentGridLinePitchTwips), nextBlockId);
1615
+ const mergedBlocks = mergeRunInParagraphs(blocks);
1616
+ const tableCellLinePitch = doc.attrs["_adjustLineHeightInTable"] === true ? "sectionGrid" : void 0;
1617
+ return groupParagraphFrames(applySectionStartsToBoundaries(applySectionDocumentGrid(mergedBlocks, {
1618
+ finalLinePitchTwips: opts.finalSectionDocumentGridLinePitchTwips,
1619
+ tableCellLinePitch
1620
+ }), readFinalSectionStart(doc)), nextBlockId);
1552
1621
  }
1553
- function applySectionDocumentGrid(blocks, finalLinePitchTwips) {
1622
+ function applySectionDocumentGrid(blocks, { finalLinePitchTwips, tableCellLinePitch }) {
1554
1623
  const result = [...blocks];
1555
1624
  let sectionStart = 0;
1556
1625
  const stampSection = (end, linePitchTwips) => {
1557
1626
  if (linePitchTwips === void 0 || linePitchTwips <= 0) return;
1558
1627
  const linePitch = twipsToPixels(linePitchTwips);
1559
- for (let index = sectionStart; index < end; index += 1) {
1560
- const block = result[index];
1561
- if (block?.kind !== "paragraph") continue;
1562
- result[index] = {
1628
+ const stampBlock = (block) => {
1629
+ if (block.kind === "paragraph") return {
1563
1630
  ...block,
1564
1631
  attrs: {
1565
1632
  ...block.attrs,
1566
1633
  documentGridLinePitch: linePitch
1567
1634
  }
1568
1635
  };
1636
+ if (block.kind !== "table" || tableCellLinePitch !== "sectionGrid") return block;
1637
+ return {
1638
+ ...block,
1639
+ rows: block.rows.map((row) => ({
1640
+ ...row,
1641
+ cells: row.cells.map((cell) => ({
1642
+ ...cell,
1643
+ blocks: cell.blocks.map(stampBlock)
1644
+ }))
1645
+ }))
1646
+ };
1647
+ };
1648
+ for (let index = sectionStart; index < end; index += 1) {
1649
+ const block = result[index];
1650
+ if (!block) continue;
1651
+ result[index] = stampBlock(block);
1569
1652
  }
1570
1653
  };
1571
1654
  for (let index = 0; index < blocks.length; index += 1) {