@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 { expectParagraphAttrs, expectRunPropertyChangeMarkAttrs } from "../prosemirror/attrs/index.js";
1
+ import { expectParagraphAttrs, expectRunFormattingOverrideMarkAttrs, expectRunPropertyChangeMarkAttrs } from "../prosemirror/attrs/index.js";
2
2
  import { PPR_CHANGE_SCOPED_ATTR_KEYS } from "../prosemirror/commands/propertyChangeScope.js";
3
3
  import { addedBreakCarrierBefore, finalParagraphsOf, paragraphEndsItsContainer } from "../prosemirror/containerFinalParagraph.js";
4
4
  import { marksToTextFormatting } from "../prosemirror/conversion/fromProseDoc.js";
@@ -154,24 +154,132 @@ const REPLACEMENT_BACKGROUND_CLEAR_FORMATTING = {
154
154
  highlight: false,
155
155
  runShading: false
156
156
  };
157
- const applyInlineFormatting = ({ tr, schema, from, to, formatting }) => {
158
- for (const [name, enabled] of Object.entries(formatting)) {
159
- const markType = schema.marks[name];
157
+ const INLINE_FORMATTING_MARK_NAMES = {
158
+ bold: "bold",
159
+ italic: "italic",
160
+ underline: "underline",
161
+ strike: "strike",
162
+ fontFamily: "fontFamily",
163
+ fontSizePt: "fontSize",
164
+ color: "textColor",
165
+ highlight: "highlight",
166
+ runShading: "runShading"
167
+ };
168
+ const DIRECT_FONT_PROPERTIES = [
169
+ "fontFamily",
170
+ "fontSize",
171
+ "color"
172
+ ];
173
+ const formattingMarkName = (property) => {
174
+ if (!Object.hasOwn(INLINE_FORMATTING_MARK_NAMES, property)) return null;
175
+ const name = Reflect.get(INLINE_FORMATTING_MARK_NAMES, property);
176
+ return typeof name === "string" ? name : null;
177
+ };
178
+ const formattingMarkAttrs = (property, value) => {
179
+ switch (property) {
180
+ case "underline": return { style: "single" };
181
+ case "fontFamily": return typeof value === "string" ? {
182
+ ascii: value,
183
+ hAnsi: value
184
+ } : null;
185
+ case "fontSizePt": return typeof value === "number" ? { size: value * 2 } : null;
186
+ case "color": return typeof value === "string" ? { rgb: value.replace(/^#/u, "").toUpperCase() } : null;
187
+ default: return {};
188
+ }
189
+ };
190
+ const applyInlineFormatting = ({ tr, schema, from, to, formatting, includedProperties }) => {
191
+ for (const [property, value] of Object.entries(formatting)) {
192
+ if (includedProperties && !includedProperties.includes(property)) continue;
193
+ const markName = formattingMarkName(property);
194
+ const markType = markName ? schema.marks[markName] : void 0;
160
195
  if (!markType) continue;
161
- if (enabled) {
162
- tr.addMark(from, to, name === "underline" ? markType.create({ style: "single" }) : markType.create());
196
+ if (value !== false && value !== null) {
197
+ const attrs = formattingMarkAttrs(property, value);
198
+ if (attrs) tr.addMark(from, to, markType.create(attrs));
163
199
  continue;
164
200
  }
165
201
  tr.removeMark(from, to, markType);
166
202
  }
203
+ applyDirectFontProvenance({
204
+ tr,
205
+ schema,
206
+ from,
207
+ to,
208
+ formatting,
209
+ ...includedProperties ? { includedProperties } : {}
210
+ });
167
211
  return tr;
168
212
  };
169
- const formattingWouldChange = (marks, formatting) => Object.entries(formatting).some(([name, enabled]) => {
170
- const mark = marks.find((candidate) => candidate.type.name === name);
171
- if (!enabled) return mark !== void 0;
172
- if (name === "underline") return mark?.attrs["style"] !== "single";
213
+ const applyDirectFontProvenance = ({ tr, schema, from, to, formatting, includedProperties }) => {
214
+ if ("highlight" in formatting) return;
215
+ const changed = [
216
+ [
217
+ "fontFamily",
218
+ "fontFamily",
219
+ formatting.fontFamily
220
+ ],
221
+ [
222
+ "fontSize",
223
+ "fontSizePt",
224
+ formatting.fontSizePt
225
+ ],
226
+ [
227
+ "color",
228
+ "color",
229
+ formatting.color
230
+ ]
231
+ ].filter(([, inputProperty, value]) => value !== void 0 && (!includedProperties || includedProperties.includes(inputProperty)));
232
+ const markType = schema.marks["runFormattingOverride"];
233
+ if (!markType || changed.length === 0) return;
234
+ tr.doc.nodesBetween(from, to, (node, pos) => {
235
+ if (!node.isText) return;
236
+ const segmentFrom = Math.max(from, pos);
237
+ const segmentTo = Math.min(to, pos + node.nodeSize);
238
+ const existing = node.marks.find((mark) => mark.type === markType);
239
+ const attrs = existing ? expectRunFormattingOverrideMarkAttrs(existing) : {};
240
+ const directFontProperties = new Set(attrs.directFontProperties);
241
+ for (const [property, , value] of changed) if (value === null) directFontProperties.delete(property);
242
+ else directFontProperties.add(property);
243
+ tr.removeMark(segmentFrom, segmentTo, markType);
244
+ const nextAttrs = {
245
+ ...attrs,
246
+ directFontProperties: DIRECT_FONT_PROPERTIES.filter((property) => directFontProperties.has(property))
247
+ };
248
+ if (Object.entries(nextAttrs).some(([key, value]) => key === "directFontProperties" ? Array.isArray(value) && value.length > 0 : value !== null && value !== void 0)) tr.addMark(segmentFrom, segmentTo, markType.create(nextAttrs));
249
+ });
250
+ };
251
+ const directFontPropertyForFormattingProperty = (property) => {
252
+ switch (property) {
253
+ case "fontFamily": return "fontFamily";
254
+ case "fontSizePt": return "fontSize";
255
+ case "color": return "color";
256
+ default: return null;
257
+ }
258
+ };
259
+ const formattingPropertyWouldChange = (marks, property, value) => {
260
+ const markName = formattingMarkName(property);
261
+ const mark = marks.find((candidate) => candidate.type.name === markName);
262
+ const directFontProperty = directFontPropertyForFormattingProperty(property);
263
+ if (directFontProperty) {
264
+ const overrideMark = marks.find((candidate) => candidate.type.name === "runFormattingOverride");
265
+ const isDirect = overrideMark ? (expectRunFormattingOverrideMarkAttrs(overrideMark).directFontProperties ?? []).includes(directFontProperty) : false;
266
+ if (value === false || value === null) return isDirect;
267
+ if (!isDirect) return true;
268
+ } else if (value === false || value === null) return mark !== void 0;
269
+ if (property === "underline") return mark?.attrs["style"] !== "single";
270
+ if (property === "fontFamily") return mark?.attrs["ascii"] !== value || mark?.attrs["hAnsi"] !== value;
271
+ if (property === "fontSizePt") return Number(mark?.attrs["size"]) !== Number(value) * 2;
272
+ if (property === "color") {
273
+ const current = mark?.attrs["rgb"];
274
+ return (typeof current === "string" ? current.replace(/^#/u, "").toUpperCase() : null) !== String(value).replace(/^#/u, "").toUpperCase();
275
+ }
173
276
  return mark === void 0;
174
- });
277
+ };
278
+ const formattingChangesForMarks = (marks, formatting) => {
279
+ const changes = [];
280
+ for (const [property, value] of Object.entries(formatting)) if (formattingPropertyWouldChange(marks, property, value)) changes.push(property);
281
+ return changes;
282
+ };
175
283
  const clearReplacementBackground = ({ tr, schema, from, to, mode, revisionId, author, date, initials, suggestionId = null }) => {
176
284
  if (![schema.marks["highlight"], schema.marks["runShading"]].some((markType) => markType !== void 0 && tr.doc.rangeHasMark(from, to, markType))) return tr;
177
285
  if (mode === "direct") return applyInlineFormatting({
@@ -200,7 +308,9 @@ const applyTrackedInlineFormatting = ({ tr, schema, doc, from, to, formatting, r
200
308
  if (!propertyChangeType) return tr;
201
309
  const segments = [];
202
310
  doc.nodesBetween(from, to, (node, pos) => {
203
- if (!node.isText || !formattingWouldChange(node.marks, formatting)) return;
311
+ if (!node.isText) return;
312
+ const includedProperties = formattingChangesForMarks(node.marks, formatting);
313
+ if (includedProperties.length === 0) return;
204
314
  const segmentFrom = Math.max(from, pos);
205
315
  const segmentTo = Math.min(to, pos + node.nodeSize);
206
316
  const previousFormatting = marksToTextFormatting(node.marks);
@@ -219,6 +329,7 @@ const applyTrackedInlineFormatting = ({ tr, schema, doc, from, to, formatting, r
219
329
  segments.push({
220
330
  from: segmentFrom,
221
331
  to: segmentTo,
332
+ includedProperties,
222
333
  changes: [...existingChanges, change]
223
334
  });
224
335
  });
@@ -227,17 +338,20 @@ const applyTrackedInlineFormatting = ({ tr, schema, doc, from, to, formatting, r
227
338
  provenance: "suggested",
228
339
  suggestionId
229
340
  };
230
- applyInlineFormatting({
231
- tr,
232
- schema,
233
- from,
234
- to,
235
- formatting
236
- });
237
- for (const segment of segments) tr.addMark(segment.from, segment.to, propertyChangeType.create({
238
- changes: segment.changes,
239
- ...suggestionAttrs
240
- }));
341
+ for (const segment of segments) {
342
+ applyInlineFormatting({
343
+ tr,
344
+ schema,
345
+ from: segment.from,
346
+ to: segment.to,
347
+ formatting,
348
+ includedProperties: segment.includedProperties
349
+ });
350
+ tr.addMark(segment.from, segment.to, propertyChangeType.create({
351
+ changes: segment.changes,
352
+ ...suggestionAttrs
353
+ }));
354
+ }
241
355
  return tr;
242
356
  };
243
357
  /**
@@ -1,6 +1,8 @@
1
1
  import { FolioAIBlock, FolioAIEditSnapshot, FolioAITextRangeHandle } from "./types.js";
2
2
  import { Node } from "prosemirror-model";
3
3
  //#region src/ai-edits/snapshot.d.ts
4
+ /** @internal Numbering references collected during the snapshot's document walk. */
5
+ declare const numberingReferenceKeysOf: (snapshot: FolioAIEditSnapshot) => readonly string[];
4
6
  declare const normalizeFolioAIBlockText: (text: string) => string;
5
7
  /**
6
8
  * Whether a block carries text a reader would see.
@@ -70,4 +72,4 @@ type FolioStoryTable = {
70
72
  declare const folioStoryTables: (doc: Node) => FolioStoryTable[];
71
73
  declare const createFolioAIEditSnapshot: (doc: Node) => FolioAIEditSnapshot;
72
74
  //#endregion
73
- export { FolioStoryTable, createFolioAIEditSnapshot, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, trailingBodyBlockId };
75
+ export { FolioStoryTable, createFolioAIEditSnapshot, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, numberingReferenceKeysOf, trailingBodyBlockId };
@@ -1,7 +1,12 @@
1
+ import { expectRunFormattingOverrideMarkAttrs } from "../prosemirror/attrs/index.js";
1
2
  import { deriveBlankBlockId, deriveBlockId } from "../types/block-id.js";
2
3
  import { buildCleanBlockText } from "./clean-text.js";
4
+ import { panic } from "better-result";
3
5
  import { TableMap } from "prosemirror-tables";
4
6
  //#region src/ai-edits/snapshot.ts
7
+ const numberingReferenceKeysBySnapshot = /* @__PURE__ */ new WeakMap();
8
+ /** @internal Numbering references collected during the snapshot's document walk. */
9
+ const numberingReferenceKeysOf = (snapshot) => numberingReferenceKeysBySnapshot.get(snapshot) ?? panic("A numbering census was requested for a snapshot that did not record one");
5
10
  const normalizeFolioAIBlockText = (text) => text.replace(/\s+/gu, " ").trim();
6
11
  /**
7
12
  * Whether a block carries text a reader would see.
@@ -125,6 +130,7 @@ const createFolioAIEditSnapshot = (doc) => {
125
130
  const draftBlocks = [];
126
131
  const hashCounts = /* @__PURE__ */ new Map();
127
132
  const usedBlockIds = /* @__PURE__ */ new Set();
133
+ const numberingReferenceKeys = /* @__PURE__ */ new Set();
128
134
  const tableIndexByStart = new Map(folioStoryTables(doc).map(({ start, index }) => [start, index]));
129
135
  const path = [];
130
136
  let blockIndex = 0;
@@ -170,6 +176,8 @@ const createFolioAIEditSnapshot = (doc) => {
170
176
  const displayLabel = getDisplayLabel(node);
171
177
  const styleId = getStyleId(node);
172
178
  const listLevel = getListLevel(node);
179
+ const numberingReferenceKey = getNumberingReferenceKey(node);
180
+ if (numberingReferenceKey) numberingReferenceKeys.add(numberingReferenceKey);
173
181
  const previewRuns = getPreviewRuns(node);
174
182
  const table = getTableLocation({
175
183
  path,
@@ -208,10 +216,12 @@ const createFolioAIEditSnapshot = (doc) => {
208
216
  hashOccurrenceCount: hashCounts.get(draft.anchor.textHash) ?? 0
209
217
  };
210
218
  }
211
- return {
219
+ const snapshot = {
212
220
  blocks,
213
221
  anchors
214
222
  };
223
+ numberingReferenceKeysBySnapshot.set(snapshot, [...numberingReferenceKeys]);
224
+ return snapshot;
215
225
  };
216
226
  const getBlockKind = (node, headingLevel) => {
217
227
  const listMarker = node.attrs["listMarker"];
@@ -240,6 +250,15 @@ const getListLevel = (node) => {
240
250
  const { ilvl } = numPr;
241
251
  return typeof ilvl === "number" && Number.isInteger(ilvl) && ilvl >= 0 ? ilvl : void 0;
242
252
  };
253
+ const getNumberingReferenceKey = (node) => {
254
+ const numPr = node.attrs["numPr"];
255
+ if (typeof numPr !== "object" || numPr === null || !("numId" in numPr)) return null;
256
+ const { numId } = numPr;
257
+ if (typeof numId !== "number" || !Number.isInteger(numId) || numId <= 0) return null;
258
+ const level = "ilvl" in numPr ? numPr.ilvl : void 0;
259
+ if (level !== void 0 && (typeof level !== "number" || !Number.isInteger(level) || level < 0)) return null;
260
+ return `${String(numId)}:${String(level ?? 0)}`;
261
+ };
243
262
  const getStyleId = (node) => {
244
263
  const styleId = node.attrs["styleId"];
245
264
  return typeof styleId === "string" && styleId.length > 0 ? styleId : void 0;
@@ -252,14 +271,16 @@ const getPreviewRuns = (node) => {
252
271
  if (!child.isText || child.text === void 0) return true;
253
272
  if (child.marks.some((mark) => mark.type.name === DELETION_MARK)) return false;
254
273
  const style = getPreviewRunStyle(child.marks, defaultStyle);
274
+ const directFormatting = getDirectPreviewRunStyle(child.marks, defaultStyle);
255
275
  const previous = runs.at(-1);
256
- if (previous && samePreviewRunStyle(previous, style)) {
276
+ if (previous && samePreviewRunStyle(previous, style) && sameDirectFormatting(previous.directFormatting, directFormatting)) {
257
277
  previous.text += child.text;
258
278
  return false;
259
279
  }
260
280
  runs.push({
261
281
  text: child.text,
262
- ...style
282
+ ...style,
283
+ ...!isEmptyPreviewRunStyle(directFormatting) && { directFormatting }
263
284
  });
264
285
  return false;
265
286
  });
@@ -310,6 +331,23 @@ const getPreviewRunStyle = (marks, defaultStyle) => {
310
331
  }
311
332
  return style;
312
333
  };
334
+ const getDirectPreviewRunStyle = (marks, inheritedStyle) => {
335
+ const markedStyle = getPreviewRunStyle(marks, {});
336
+ const directStyle = {};
337
+ const overrideMark = marks.find(({ type }) => type.name === "runFormattingOverride");
338
+ const hasFormattingProvenance = overrideMark !== void 0 || marks.some(({ type }) => type.name === "characterStyle");
339
+ const directFontProperties = overrideMark ? expectRunFormattingOverrideMarkAttrs(overrideMark).directFontProperties : void 0;
340
+ for (const property of [
341
+ "bold",
342
+ "italic",
343
+ "underline",
344
+ "strike"
345
+ ]) if (Boolean(markedStyle[property]) !== Boolean(inheritedStyle[property])) directStyle[property] = Boolean(markedStyle[property]);
346
+ if (markedStyle.fontFamily !== void 0 && (hasFormattingProvenance ? directFontProperties?.includes("fontFamily") : markedStyle.fontFamily !== inheritedStyle.fontFamily)) directStyle.fontFamily = markedStyle.fontFamily;
347
+ if (markedStyle.fontSizePt !== void 0 && (hasFormattingProvenance ? directFontProperties?.includes("fontSize") : markedStyle.fontSizePt !== inheritedStyle.fontSizePt)) directStyle.fontSizePt = markedStyle.fontSizePt;
348
+ if (markedStyle.color !== void 0 && (hasFormattingProvenance ? directFontProperties?.includes("color") : markedStyle.color !== inheritedStyle.color)) directStyle.color = markedStyle.color;
349
+ return directStyle;
350
+ };
313
351
  const getBooleanTextFormatting = (formatting) => ({
314
352
  ...Reflect.get(formatting, "bold") === true && { bold: true },
315
353
  ...Reflect.get(formatting, "italic") === true && { italic: true },
@@ -352,6 +390,8 @@ const getColorFromAttrs = (attrs) => {
352
390
  return `#${rgb}`;
353
391
  };
354
392
  const samePreviewRunStyle = (run, style) => run.bold === style.bold && run.italic === style.italic && run.underline === style.underline && run.strike === style.strike && run.fontFamily === style.fontFamily && run.fontSizePt === style.fontSizePt && run.color === style.color;
393
+ const isEmptyPreviewRunStyle = ({ bold, italic, underline, strike, fontFamily, fontSizePt, color }) => bold === void 0 && italic === void 0 && underline === void 0 && strike === void 0 && fontFamily === void 0 && fontSizePt === void 0 && color === void 0;
394
+ const sameDirectFormatting = (left, right) => left === void 0 && isEmptyPreviewRunStyle(right) || left !== void 0 && left.bold === right.bold && left.italic === right.italic && left.underline === right.underline && left.strike === right.strike && left.fontFamily === right.fontFamily && left.fontSizePt === right.fontSizePt && left.color === right.color;
355
395
  const isUnstyledPreviewRun = ({ bold, italic, underline, strike, fontFamily, fontSizePt, color }) => bold === void 0 && italic === void 0 && underline === void 0 && strike === void 0 && fontFamily === void 0 && fontSizePt === void 0 && color === void 0;
356
396
  //#endregion
357
- export { createFolioAIEditSnapshot, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, trailingBodyBlockId };
397
+ export { createFolioAIEditSnapshot, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, numberingReferenceKeysOf, trailingBodyBlockId };
@@ -1,5 +1,10 @@
1
1
  //#region src/ai-edits/types.d.ts
2
2
  type FolioAIBlockKind = "heading" | "listItem" | "paragraph";
3
+ type FolioAIInlineFormatting = Partial<Record<"bold" | "italic" | "underline" | "strike", boolean>> & {
4
+ fontFamily?: string | null;
5
+ fontSizePt?: number | null;
6
+ color?: string | null;
7
+ };
3
8
  type FolioAIBlockPreviewRun = {
4
9
  text: string;
5
10
  bold?: boolean;
@@ -9,6 +14,7 @@ type FolioAIBlockPreviewRun = {
9
14
  fontFamily?: string;
10
15
  fontSizePt?: number;
11
16
  color?: string;
17
+ directFormatting?: FolioAIInlineFormatting;
12
18
  };
13
19
  /**
14
20
  * Where a block sits inside its innermost enclosing table. Every index is
@@ -165,12 +171,6 @@ type FolioDocumentNavigationTarget = {
165
171
  story: "main";
166
172
  blockId: string;
167
173
  } | FolioAITextRangeHandle;
168
- type FolioAIInlineFormatting = {
169
- bold?: boolean;
170
- italic?: boolean;
171
- underline?: boolean;
172
- strike?: boolean;
173
- };
174
174
  /**
175
175
  * A party in an `insertSignatureTable` op. Mirrors the
176
176
  * `signatureTable` helper in `docx-core/legal-source/compile.ts`:
@@ -1,4 +1,5 @@
1
1
  import { FolioDocxReviewer } from "../ai-edits/headless.js";
2
+ import { numberingReferenceKeysOf } from "../ai-edits/snapshot.js";
2
3
  import { projectTableGeometry } from "../ai-edits/table-geometry.js";
3
4
  import "../document-operations.js";
4
5
  import { pairFolioDocumentStories } from "../document-stories.js";
@@ -114,11 +115,12 @@ const formattingRoundTripFailure = ({ invariant, story, changes, actualBlocks, e
114
115
  };
115
116
  const numberingKey = ({ numId, level }) => `${String(numId)}:${String(level)}`;
116
117
  const sameNumbering = (left, right) => left.format === right.format && left.levelText === right.levelText && left.start === right.start;
117
- const compareNumbering = (base, target) => {
118
+ const compareNumbering = (base, target, referenced) => {
118
119
  const baseLevels = new Map(base.readNumberingDefinitions().map((level) => [numberingKey(level), level]));
119
120
  const targetLevels = new Map(target.readNumberingDefinitions().map((level) => [numberingKey(level), level]));
120
121
  const changes = [];
121
122
  for (const [key, before] of baseLevels) {
123
+ if (!referenced.has(key)) continue;
122
124
  const after = targetLevels.get(key) ?? null;
123
125
  if (after === null || !sameNumbering(before, after)) changes.push({
124
126
  kind: "numbering",
@@ -128,7 +130,7 @@ const compareNumbering = (base, target) => {
128
130
  after
129
131
  });
130
132
  }
131
- for (const [key, after] of targetLevels) if (!baseLevels.has(key)) changes.push({
133
+ for (const [key, after] of targetLevels) if (referenced.has(key) && !baseLevels.has(key)) changes.push({
132
134
  kind: "numbering",
133
135
  numId: after.numId,
134
136
  level: after.level,
@@ -162,8 +164,15 @@ const parseComparison = async (base, target, options) => {
162
164
  });
163
165
  const pairs = [];
164
166
  const unsupported = [];
167
+ const referencedNumberingLevels = /* @__PURE__ */ new Set();
168
+ const collectNumberingReferences = (snapshot) => {
169
+ if (!snapshot) return;
170
+ for (const referenceKey of numberingReferenceKeysOf(snapshot)) referencedNumberingLevels.add(referenceKey);
171
+ };
165
172
  for (const { baseStory, revisedStory: targetStory } of pairFolioDocumentStories(reviewer.listStories().map(({ handle }) => handle), targetReviewer.listStories().map(({ handle }) => handle))) {
166
173
  if (!baseStory) {
174
+ if (!targetStory) panic("A story pair contained neither a base nor a target story");
175
+ collectNumberingReferences(targetReviewer.snapshotStory(targetStory));
167
176
  unsupported.push({
168
177
  reason: "story-missing-in-base",
169
178
  baseStory: null,
@@ -172,6 +181,7 @@ const parseComparison = async (base, target, options) => {
172
181
  continue;
173
182
  }
174
183
  if (!targetStory) {
184
+ collectNumberingReferences(reviewer.snapshotStory(baseStory));
175
185
  unsupported.push({
176
186
  reason: "story-missing-in-target",
177
187
  baseStory,
@@ -181,6 +191,8 @@ const parseComparison = async (base, target, options) => {
181
191
  }
182
192
  const baseSnapshot = reviewer.snapshotStory(baseStory);
183
193
  const targetSnapshot = targetReviewer.snapshotStory(targetStory);
194
+ collectNumberingReferences(baseSnapshot);
195
+ collectNumberingReferences(targetSnapshot);
184
196
  if (!baseSnapshot || !targetSnapshot) {
185
197
  unsupported.push({
186
198
  reason: "story-not-editable",
@@ -208,7 +220,7 @@ const parseComparison = async (base, target, options) => {
208
220
  },
209
221
  packageDate,
210
222
  pairs,
211
- numberingChanges: compareNumbering(reviewer, targetReviewer),
223
+ numberingChanges: compareNumbering(reviewer, targetReviewer, referencedNumberingLevels),
212
224
  unsupported
213
225
  });
214
226
  };
@@ -15,7 +15,7 @@ type InlineFormattingSegmentsOptions = {
15
15
  maxSegments: number;
16
16
  };
17
17
  /**
18
- * Segments where `targetBlock`'s bold / italic / underline / strike differs from
18
+ * Segments where `targetBlock`'s supported run formatting differs from
19
19
  * `baseBlock`'s, for two blocks that carry the same text. Returns `null` only
20
20
  * when the diff would exceed `maxSegments`.
21
21
  *
@@ -1,12 +1,41 @@
1
+ import { resolveColorToHex } from "../utils/colorResolver.js";
1
2
  //#region src/compare/formatting.ts
2
- const changedSupportedFormatting = (base, target) => ({
3
- ...Boolean(base.bold) !== Boolean(target.bold) && { bold: Boolean(target.bold) },
4
- ...Boolean(base.italic) !== Boolean(target.italic) && { italic: Boolean(target.italic) },
5
- ...Boolean(base.underline) !== Boolean(target.underline) && { underline: Boolean(target.underline) },
6
- ...Boolean(base.strike) !== Boolean(target.strike) && { strike: Boolean(target.strike) }
7
- });
8
- const sameInlineFormatting = (left, right) => left.bold === right.bold && left.italic === right.italic && left.underline === right.underline && left.strike === right.strike;
9
- const hasInlineFormatting = (formatting) => formatting.bold !== void 0 || formatting.italic !== void 0 || formatting.underline !== void 0 || formatting.strike !== void 0;
3
+ const normalizeInlineFormattingColor = (color) => resolveColorToHex(color === void 0 ? void 0 : { rgb: color }, null);
4
+ const changedStringValue = (baseEffective, targetEffective, baseAuthored, targetAuthored) => {
5
+ if (baseAuthored !== targetAuthored) return targetAuthored ?? null;
6
+ return baseEffective === targetEffective ? void 0 : targetEffective ?? null;
7
+ };
8
+ const changedNumberValue = (baseEffective, targetEffective, baseAuthored, targetAuthored) => {
9
+ if (baseAuthored !== targetAuthored) return targetAuthored ?? null;
10
+ return baseEffective === targetEffective ? void 0 : targetEffective ?? null;
11
+ };
12
+ const changedSupportedFormatting = (base, target) => {
13
+ const baseDirect = base.directFormatting ?? {};
14
+ const targetDirect = target.directFormatting ?? {};
15
+ const formatting = {};
16
+ const changedBoolean = (property) => {
17
+ if (baseDirect[property] !== targetDirect[property]) return targetDirect[property] === true;
18
+ return Boolean(base[property]) === Boolean(target[property]) ? void 0 : Boolean(target[property]);
19
+ };
20
+ for (const property of [
21
+ "bold",
22
+ "italic",
23
+ "underline",
24
+ "strike"
25
+ ]) {
26
+ const changed = changedBoolean(property);
27
+ if (changed !== void 0) formatting[property] = changed;
28
+ }
29
+ const fontFamily = changedStringValue(base.fontFamily, target.fontFamily, baseDirect.fontFamily, targetDirect.fontFamily);
30
+ if (fontFamily !== void 0) formatting.fontFamily = fontFamily;
31
+ const fontSizePt = changedNumberValue(base.fontSizePt, target.fontSizePt, baseDirect.fontSizePt, targetDirect.fontSizePt);
32
+ if (fontSizePt !== void 0) formatting.fontSizePt = fontSizePt;
33
+ const color = changedStringValue(normalizeInlineFormattingColor(base.color), normalizeInlineFormattingColor(target.color), normalizeInlineFormattingColor(baseDirect.color ?? void 0), normalizeInlineFormattingColor(targetDirect.color ?? void 0));
34
+ if (color !== void 0) formatting.color = color;
35
+ return formatting;
36
+ };
37
+ const sameInlineFormatting = (left, right) => left.bold === right.bold && left.italic === right.italic && left.underline === right.underline && left.strike === right.strike && left.fontFamily === right.fontFamily && left.fontSizePt === right.fontSizePt && left.color === right.color;
38
+ const hasInlineFormatting = (formatting) => formatting.bold !== void 0 || formatting.italic !== void 0 || formatting.underline !== void 0 || formatting.strike !== void 0 || formatting.fontFamily !== void 0 || formatting.fontSizePt !== void 0 || formatting.color !== void 0;
10
39
  /**
11
40
  * A block's runs, or `null` when they cannot describe the block's text.
12
41
  * Non-text inline content (a field, an image) leaves the concatenated run text
@@ -18,7 +47,7 @@ const previewRunsForBlock = (block) => {
18
47
  return runs.map(({ text }) => text).join("") === block.text ? runs : null;
19
48
  };
20
49
  /**
21
- * Segments where `targetBlock`'s bold / italic / underline / strike differs from
50
+ * Segments where `targetBlock`'s supported run formatting differs from
22
51
  * `baseBlock`'s, for two blocks that carry the same text. Returns `null` only
23
52
  * when the diff would exceed `maxSegments`.
24
53
  *
@@ -1,3 +1,4 @@
1
+ import { resolveColorToHex } from "../utils/colorResolver.js";
1
2
  import { PARAGRAPH_MARK_CHANGE_KINDS } from "@stll/docx-core/model";
2
3
  //#region src/compare/verification.ts
3
4
  /**
@@ -14,6 +15,7 @@ import { PARAGRAPH_MARK_CHANGE_KINDS } from "@stll/docx-core/model";
14
15
  * Never a phrase of either document, because a caller may log it, put it in a
15
16
  * report, or quote it in a review.
16
17
  */
18
+ const normalizeInlineFormattingColor = (color) => resolveColorToHex(color === void 0 ? void 0 : { rgb: color }, null);
17
19
  /** The two directions of the round trip, each an invariant of its own. */
18
20
  const COMPARE_VERIFICATION_INVARIANTS = Object.freeze(["accept-reproduces-target", "reject-reproduces-base"]);
19
21
  /**
@@ -32,7 +34,22 @@ const COMPARE_VERIFICATION_CAUSES = Object.freeze([
32
34
  "whitespace",
33
35
  "text"
34
36
  ]);
35
- const supportedInlineStyle = ({ bold, italic, underline, strike }) => `${bold === true ? "b" : ""}${italic === true ? "i" : ""}${underline === true ? "u" : ""}${strike === true ? "s" : ""}`;
37
+ const supportedInlineStyle = ({ bold, italic, underline, strike, fontFamily, fontSizePt, color, directFormatting }) => JSON.stringify([
38
+ bold === true,
39
+ italic === true,
40
+ underline === true,
41
+ strike === true,
42
+ fontFamily ?? null,
43
+ fontSizePt ?? null,
44
+ normalizeInlineFormattingColor(color) ?? null,
45
+ directFormatting?.bold === true,
46
+ directFormatting?.italic === true,
47
+ directFormatting?.underline === true,
48
+ directFormatting?.strike === true,
49
+ directFormatting?.fontFamily ?? null,
50
+ directFormatting?.fontSizePt ?? null,
51
+ normalizeInlineFormattingColor(directFormatting?.color ?? void 0) ?? null
52
+ ]);
36
53
  /** Effective supported formatting with equivalent adjacent runs normalized. */
37
54
  const projectSupportedInlineFormatting = ({ text, previewRuns }) => {
38
55
  const projected = [];
@@ -10,19 +10,21 @@ import { schema } from "../prosemirror/schema/index.js";
10
10
  import { EditorState } from "prosemirror-state";
11
11
  import { EditorView } from "prosemirror-view";
12
12
  //#region src/controller/headerFooterEditorManager.ts
13
- /**
14
- * Framework-neutral lifecycle owner for persistent header/footer EditorViews.
15
- *
16
- * Each distinct relationship id owns one off-screen ProseMirror view. Painted
17
- * header/footer instances that share that relationship therefore share live
18
- * editor state, and adapters only decide when to synchronize or destroy the
19
- * manager.
20
- */
13
+ const DETACHED_WATERMARK_HOST = Symbol.for("stll.detachedWatermarkHost");
14
+ const headerFooterToProseDocWithDetachedWatermarkHost = (headerFooter, options) => {
15
+ return headerFooterToProseDoc(headerFooter.content.map((block, blockIndex) => {
16
+ if (blockIndex !== headerFooter.watermarkBlockIndex || block.type !== "paragraph") return block;
17
+ return {
18
+ ...block,
19
+ [DETACHED_WATERMARK_HOST]: true
20
+ };
21
+ }), options);
22
+ };
21
23
  const buildInitialState = (headerFooter, styles, theme, manager) => {
22
24
  const proseDocOptions = {};
23
25
  if (styles) proseDocOptions.styles = styles;
24
26
  if (theme !== void 0) proseDocOptions.theme = theme;
25
- const document = headerFooterToProseDoc(headerFooter.content, proseDocOptions);
27
+ const document = headerFooter.watermarkBlockIndex === void 0 ? headerFooterToProseDoc(headerFooter.content, proseDocOptions) : headerFooterToProseDocWithDetachedWatermarkHost(headerFooter, proseDocOptions);
26
28
  return ensureBaseDirectionInState(ensureParaIdsInState(EditorState.create({
27
29
  doc: document,
28
30
  schema,
@@ -1,6 +1,7 @@
1
1
  import { createDisplayListPagePainter } from "../display-list/editor/displayListPagePainter.js";
2
2
  import { PAGE_RENDERER, displayListFurnitureFrom } from "../display-list/editor/pageRenderer.js";
3
3
  import { resolveDocumentGridLinePitch } from "../docx/documentGrid.js";
4
+ import { formatOoxmlCounter } from "../docx/ooxmlCounterFormatter.js";
4
5
  import { buildBookmarkPageMap } from "../fields/bookmarkPages.js";
5
6
  import { buildBookmarkText } from "../fields/bookmarkText.js";
6
7
  import { buildHeaderFooterFieldValues, fieldValuesEqual, resolveFieldValues } from "../fields/resolveFieldValues.js";
@@ -26,6 +27,17 @@ import { getMargins, getPageNumbering, getPageSize, twipsToPixels } from "../pag
26
27
  import { templatePreviewValuesKey } from "../prosemirror/plugins/templatePreviewValues.js";
27
28
  import { getDocumentWatermark } from "../watermark/index.js";
28
29
  //#region src/controller/layoutPipeline.ts
30
+ const formatEndnoteTexts = (numbers, formatNumber) => {
31
+ const texts = /* @__PURE__ */ new Map();
32
+ for (const [id, displayNumber] of numbers) texts.set(id, formatNumber(displayNumber));
33
+ return texts;
34
+ };
35
+ /** Resolve only package-owned picture-watermark bytes for the DOM painter. */
36
+ const resolveWatermarkImageSrc = (document, watermark) => {
37
+ if (watermark?.kind !== "picture" || watermark.imageTargetExternal === true) return;
38
+ const target = watermark.imageTarget;
39
+ return target === void 0 ? void 0 : document.package.media?.get(target)?.dataUrl;
40
+ };
29
41
  function bodyMarginsClearHeaderFooter({ authoredMargins, preparedHeader, preparedFooter }) {
30
42
  const headerBottom = preparedHeader ? (authoredMargins.header ?? 0) + (preparedHeader.marginPushBottom ?? preparedHeader.height) : authoredMargins.top;
31
43
  const footerClearance = preparedFooter ? (authoredMargins.footer ?? 0) + (preparedFooter.marginPushBottom ?? preparedFooter.height) : authoredMargins.bottom;
@@ -123,7 +135,10 @@ function runLayoutPipeline(deps, state, options = {}) {
123
135
  };
124
136
  const finalSectionDocumentGridLinePitchTwips = resolveDocumentGridLinePitch(document?.package.document.sections?.at(-1)?.properties.docGrid);
125
137
  if (finalSectionDocumentGridLinePitchTwips !== void 0) flowOpts.finalSectionDocumentGridLinePitchTwips = finalSectionDocumentGridLinePitchTwips;
126
- let newBlocks = toFlowBlocks(state.doc, flowOpts);
138
+ let newBlocks = toFlowBlocks(document === null ? state.doc.type.create({
139
+ ...state.doc.attrs,
140
+ _finalSectionStart: sectionProperties?.sectionStart ?? null
141
+ }, state.doc.content, state.doc.marks) : state.doc, flowOpts);
127
142
  const previewState = templatePreviewValuesKey.getState(state);
128
143
  const previewEntries = previewState?.entries ?? EMPTY_TEMPLATE_PREVIEW_ENTRIES;
129
144
  const previewMode = previewState?.preview?.mode ?? "plain";
@@ -144,9 +159,11 @@ function runLayoutPipeline(deps, state, options = {}) {
144
159
  const footnoteDisplayNumbers = documentFootnotes ? computeNoteDisplayNumbers(documentFootnotes, footnoteRefs.map((ref) => ref.footnoteId)) : void 0;
145
160
  const documentEndnotes = document?.package.endnotes;
146
161
  const endnoteDisplayNumbers = documentEndnotes ? computeNoteDisplayNumbers(documentEndnotes, collectEndnoteRefs(newBlocks).map((ref) => ref.endnoteId)) : void 0;
162
+ const endnoteNumberFormat = document?.package.document.sections?.at(-1)?.properties.endnotePr?.numFmt ?? "lowerRoman";
163
+ const endnoteTexts = endnoteDisplayNumbers ? formatEndnoteTexts(endnoteDisplayNumbers, (displayNumber) => formatOoxmlCounter(displayNumber, endnoteNumberFormat)) : void 0;
147
164
  newBlocks = remapNoteMarkerText(newBlocks, {
148
165
  ...footnoteDisplayNumbers ? { footnoteNumbers: footnoteDisplayNumbers } : {},
149
- ...endnoteDisplayNumbers ? { endnoteNumbers: endnoteDisplayNumbers } : {}
166
+ ...endnoteTexts ? { endnoteTexts } : {}
150
167
  });
151
168
  outcome.blocks = newBlocks;
152
169
  const hfMetricsHeader = {
@@ -479,12 +496,16 @@ function runLayoutPipeline(deps, state, options = {}) {
479
496
  if (document) {
480
497
  const watermark = getDocumentWatermark(document);
481
498
  if (watermark) renderOpts.watermark = watermark;
499
+ let watermarkByHeaderRId;
482
500
  const headers = document.package.headers;
483
501
  if (headers) {
484
- const watermarkByHeaderRId = /* @__PURE__ */ new Map();
502
+ watermarkByHeaderRId = /* @__PURE__ */ new Map();
485
503
  for (const [rId, header] of headers) if (header.watermark) watermarkByHeaderRId.set(rId, header.watermark);
486
504
  if (watermarkByHeaderRId.size > 0) renderOpts.watermarkByHeaderRId = watermarkByHeaderRId;
487
505
  }
506
+ const pictureSources = new Set([watermark, ...watermarkByHeaderRId?.values() ?? []].filter((candidate) => candidate !== void 0).map((candidate) => resolveWatermarkImageSrc(document, candidate)));
507
+ const watermarkImageSrc = pictureSources.size === 1 ? [...pictureSources].at(0) : void 0;
508
+ if (watermarkImageSrc !== void 0) renderOpts.watermarkImageSrc = watermarkImageSrc;
488
509
  }
489
510
  if (pageRenderer === PAGE_RENDERER.displayList) renderOpts.paintPage = createDisplayListPagePainter({
490
511
  layout: newLayout,