@stll/folio-core 0.33.2 → 0.35.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 (49) hide show
  1. package/dist/ai-edits/apply.js +137 -23
  2. package/dist/ai-edits/snapshot.d.ts +3 -1
  3. package/dist/ai-edits/snapshot.js +44 -4
  4. package/dist/ai-edits/types.d.ts +6 -6
  5. package/dist/compare/compare.js +15 -3
  6. package/dist/compare/formatting.d.ts +1 -1
  7. package/dist/compare/formatting.js +38 -9
  8. package/dist/compare/verification.js +18 -1
  9. package/dist/controller/headerFooterEditorManager.js +11 -9
  10. package/dist/controller/layoutPipeline.js +24 -3
  11. package/dist/display-list/build/watermarkPrimitives.js +15 -3
  12. package/dist/document-operations.js +30 -3
  13. package/dist/docx/headerFooterParser.js +8 -14
  14. package/dist/docx/paragraphParser.js +45 -0
  15. package/dist/docx/serializer/headerFooterSerializer.js +21 -2
  16. package/dist/docx/serializer/paragraphSerializer.d.ts +1 -1
  17. package/dist/docx/serializer/paragraphSerializer.js +19 -9
  18. package/dist/docx/settingsParser.js +3 -0
  19. package/dist/docx/watermarkParser.d.ts +2 -4
  20. package/dist/docx/watermarkParser.js +4 -6
  21. package/dist/headless-layout.js +14 -2
  22. package/dist/layout-bridge/convert/footnoteLayout.d.ts +2 -2
  23. package/dist/layout-bridge/convert/footnoteLayout.js +23 -10
  24. package/dist/layout-bridge/convert/headerFooterLayout.js +13 -2
  25. package/dist/layout-bridge/convert/toFlowBlocks.js +96 -13
  26. package/dist/layout-engine/index.js +11 -4
  27. package/dist/layout-engine/justifiedLineFit.d.ts +4 -4
  28. package/dist/layout-engine/justifiedLineFit.js +4 -4
  29. package/dist/layout-engine/measure/lineBreakProvider.js +1 -0
  30. package/dist/layout-engine/measure/measureBlocks.js +1 -1
  31. package/dist/layout-engine/measure/measureParagraph.js +29 -29
  32. package/dist/layout-painter/renderPage.js +6 -1
  33. package/dist/layout-painter/renderParagraph.js +18 -8
  34. package/dist/layout-painter/renderWatermark.js +11 -4
  35. package/dist/prosemirror/attrs/index.js +11 -0
  36. package/dist/prosemirror/commands/comments.js +22 -2
  37. package/dist/prosemirror/conversion/fromProseDoc.js +32 -5
  38. package/dist/prosemirror/conversion/toProseDoc.js +49 -11
  39. package/dist/prosemirror/extensions/core/DocExtension.js +7 -1
  40. package/dist/prosemirror/extensions/core/ParagraphExtension.js +1 -0
  41. package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.js +1 -0
  42. package/dist/prosemirror/plugins/templateDirectives.d.ts +14 -7
  43. package/dist/prosemirror/plugins/templateDirectives.js +26 -19
  44. package/dist/prosemirror/plugins/templateSlashMenu.js +2 -2
  45. package/dist/prosemirror/schema/marks.d.ts +2 -8
  46. package/dist/utils/fontResolver.js +51 -0
  47. package/dist/utils/formatToStyle.js +41 -1
  48. package/dist/watermark/index.js +7 -0
  49. package/package.json +3 -3
@@ -1,4 +1,4 @@
1
- import { pxToPt } from "../../layout-engine/measure/measureHelpers.js";
1
+ import { ptToPx, pxToPt } from "../../layout-engine/measure/measureHelpers.js";
2
2
  import { getFontMetrics } from "../../layout-engine/measure/measureProvider.js";
3
3
  import { parseDisplayColor } from "./colors.js";
4
4
  import { buildGlyphs, glyphRunText } from "./glyphs.js";
@@ -28,7 +28,7 @@ const TEXT_DEFAULT_COLOR = "#C0C0C0";
28
28
  const TEXT_DEFAULT_OPACITY = .5;
29
29
  const TEXT_DIAGONAL_DEGREES = -45;
30
30
  const PICTURE_NATIVE_SCALE = 1;
31
- const PICTURE_WASHOUT_OPACITY = .4;
31
+ const PICTURE_WASHOUT_OPACITY = .18;
32
32
  const paintTextWatermark = (watermark, page, context) => {
33
33
  if (watermark.text.length === 0) return [];
34
34
  const style = {
@@ -98,6 +98,18 @@ const containedRect = (page, scale, pixelWidth, pixelHeight) => {
98
98
  heightPx
99
99
  };
100
100
  };
101
+ /** The centred VML shape box, when both authored dimensions survived parsing. */
102
+ const authoredPictureRect = (page, widthPt, heightPt) => {
103
+ if (widthPt === void 0 || heightPt === void 0) return;
104
+ const widthPx = ptToPx(widthPt);
105
+ const heightPx = ptToPx(heightPt);
106
+ return {
107
+ xPx: (page.size.w - widthPx) / 2,
108
+ yPx: (page.size.h - heightPx) / 2,
109
+ widthPx,
110
+ heightPx
111
+ };
112
+ };
101
113
  const paintPictureWatermark = (watermark, page, imageSrc, context) => {
102
114
  if (imageSrc === void 0) {
103
115
  context.unsupported.report(UNSUPPORTED_CONSTRUCT.watermark, context.pageIndex, `picture watermark ${watermark.imageRId} has no resolved image source: the relationship id resolves in the package layer, not in the builder`);
@@ -113,7 +125,7 @@ const paintPictureWatermark = (watermark, page, imageSrc, context) => {
113
125
  return [{
114
126
  kind: "image",
115
127
  image: ref,
116
- rect: containedRect(page, watermark.scale ?? PICTURE_NATIVE_SCALE, source?.pixelWidth ?? 0, source?.pixelHeight ?? 0),
128
+ rect: authoredPictureRect(page, watermark.widthPt, watermark.heightPt) ?? containedRect(page, watermark.scale ?? PICTURE_NATIVE_SCALE, source?.pixelWidth ?? 0, source?.pixelHeight ?? 0),
117
129
  opacity: watermark.washout === false ? 1 : PICTURE_WASHOUT_OPACITY
118
130
  }];
119
131
  };
@@ -143,6 +143,24 @@ const readOptionalBoolean = (value, key, path) => {
143
143
  if (typeof candidate === "boolean") return candidate;
144
144
  return invalidBatch(`${path}.${key}`, "expected a boolean when provided");
145
145
  };
146
+ const readClearableNonEmptyString = (value, key, path) => {
147
+ const candidate = value[key];
148
+ if (candidate === void 0 || candidate === null) return candidate;
149
+ if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim();
150
+ return invalidBatch(`${path}.${key}`, "expected a non-empty string or null when provided");
151
+ };
152
+ const readClearableFontSize = (value, key, path) => {
153
+ const candidate = value[key];
154
+ if (candidate === void 0 || candidate === null) return candidate;
155
+ if (typeof candidate === "number" && candidate > 0 && Number.isInteger(candidate * 2)) return candidate;
156
+ return invalidBatch(`${path}.${key}`, "expected a positive half-point value or null");
157
+ };
158
+ const readClearableRgbColor = (value, key, path) => {
159
+ const candidate = value[key];
160
+ if (candidate === void 0 || candidate === null) return candidate;
161
+ if (typeof candidate === "string" && /^#?[0-9a-fA-F]{6}$/u.test(candidate)) return candidate.replace(/^#/u, "").toUpperCase();
162
+ return invalidBatch(`${path}.${key}`, "expected a six-digit RGB color or null");
163
+ };
146
164
  const readOptionalStringArray = (value, key, path) => {
147
165
  const candidate = value[key];
148
166
  if (candidate === void 0) return;
@@ -238,18 +256,27 @@ const readInlineFormatting = (value, path) => {
238
256
  "bold",
239
257
  "italic",
240
258
  "underline",
241
- "strike"
259
+ "strike",
260
+ "fontFamily",
261
+ "fontSizePt",
262
+ "color"
242
263
  ]);
243
264
  const bold = readOptionalBoolean(candidate, "bold", formattingPath);
244
265
  const italic = readOptionalBoolean(candidate, "italic", formattingPath);
245
266
  const underline = readOptionalBoolean(candidate, "underline", formattingPath);
246
267
  const strike = readOptionalBoolean(candidate, "strike", formattingPath);
247
- if (bold === void 0 && italic === void 0 && underline === void 0 && strike === void 0) return invalidBatch(formattingPath, "expected at least one formatting property");
268
+ const fontFamily = readClearableNonEmptyString(candidate, "fontFamily", formattingPath);
269
+ const fontSizePt = readClearableFontSize(candidate, "fontSizePt", formattingPath);
270
+ const color = readClearableRgbColor(candidate, "color", formattingPath);
271
+ if (bold === void 0 && italic === void 0 && underline === void 0 && strike === void 0 && fontFamily === void 0 && fontSizePt === void 0 && color === void 0) return invalidBatch(formattingPath, "expected at least one formatting property");
248
272
  return {
249
273
  ...bold !== void 0 && { bold },
250
274
  ...italic !== void 0 && { italic },
251
275
  ...underline !== void 0 && { underline },
252
- ...strike !== void 0 && { strike }
276
+ ...strike !== void 0 && { strike },
277
+ ...fontFamily !== void 0 && { fontFamily },
278
+ ...fontSizePt !== void 0 && { fontSizePt },
279
+ ...color !== void 0 && { color }
253
280
  };
254
281
  };
255
282
  const readOptionalComment = (value, path) => {
@@ -31,27 +31,21 @@ function parseHeader(headerXml, hdrFtrType = "default", styles = null, theme = n
31
31
  result.rawWatermarkXml = watermarkResult.rawParagraphXml;
32
32
  result.watermarkBlockIndex = watermarkResult.blockIndex;
33
33
  }
34
- result.content = parseBlockContent(watermarkResult ? withoutChild(rootElement, watermarkResult.hostingParagraph) : rootElement, styles, theme, numbering, rels, media, {
34
+ result.content = parseBlockContent(rootElement, styles, theme, numbering, rels, media, {
35
35
  inHeaderFooter: true,
36
36
  rootXmlns: collectXmlnsDeclarations(rootElement)
37
37
  });
38
+ if (watermarkResult) {
39
+ const host = result.content.at(watermarkResult.blockIndex);
40
+ if (host?.type === "paragraph") result.content[watermarkResult.blockIndex] = {
41
+ ...host,
42
+ content: []
43
+ };
44
+ }
38
45
  assignHeaderFooterVerbatimXml(result, headerXml);
39
46
  return result;
40
47
  }
41
48
  /**
42
- * Return a shallow copy of `parent` whose `elements` array omits the
43
- * single child reference `child`. Used to skip the watermark paragraph
44
- * when feeding the header into `parseBlockContent` — without this the
45
- * body parser would emit an empty placeholder paragraph where the
46
- * watermark sits in the source.
47
- */
48
- function withoutChild(parent, child) {
49
- return {
50
- ...parent,
51
- elements: (parent.elements ?? []).filter((el) => el !== child)
52
- };
53
- }
54
- /**
55
49
  * Parse a footer XML file (word/footer*.xml)
56
50
  *
57
51
  * @param footerXml - The raw XML content of the footer file
@@ -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",