@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
@@ -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,3 +1,4 @@
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";
3
4
  import { TableMap } from "prosemirror-tables";
@@ -252,14 +253,16 @@ const getPreviewRuns = (node) => {
252
253
  if (!child.isText || child.text === void 0) return true;
253
254
  if (child.marks.some((mark) => mark.type.name === DELETION_MARK)) return false;
254
255
  const style = getPreviewRunStyle(child.marks, defaultStyle);
256
+ const directFormatting = getDirectPreviewRunStyle(child.marks, defaultStyle);
255
257
  const previous = runs.at(-1);
256
- if (previous && samePreviewRunStyle(previous, style)) {
258
+ if (previous && samePreviewRunStyle(previous, style) && sameDirectFormatting(previous.directFormatting, directFormatting)) {
257
259
  previous.text += child.text;
258
260
  return false;
259
261
  }
260
262
  runs.push({
261
263
  text: child.text,
262
- ...style
264
+ ...style,
265
+ ...!isEmptyPreviewRunStyle(directFormatting) && { directFormatting }
263
266
  });
264
267
  return false;
265
268
  });
@@ -310,6 +313,23 @@ const getPreviewRunStyle = (marks, defaultStyle) => {
310
313
  }
311
314
  return style;
312
315
  };
316
+ const getDirectPreviewRunStyle = (marks, inheritedStyle) => {
317
+ const markedStyle = getPreviewRunStyle(marks, {});
318
+ const directStyle = {};
319
+ const overrideMark = marks.find(({ type }) => type.name === "runFormattingOverride");
320
+ const hasFormattingProvenance = overrideMark !== void 0 || marks.some(({ type }) => type.name === "characterStyle");
321
+ const directFontProperties = overrideMark ? expectRunFormattingOverrideMarkAttrs(overrideMark).directFontProperties : void 0;
322
+ for (const property of [
323
+ "bold",
324
+ "italic",
325
+ "underline",
326
+ "strike"
327
+ ]) if (Boolean(markedStyle[property]) !== Boolean(inheritedStyle[property])) directStyle[property] = Boolean(markedStyle[property]);
328
+ if (markedStyle.fontFamily !== void 0 && (hasFormattingProvenance ? directFontProperties?.includes("fontFamily") : markedStyle.fontFamily !== inheritedStyle.fontFamily)) directStyle.fontFamily = markedStyle.fontFamily;
329
+ if (markedStyle.fontSizePt !== void 0 && (hasFormattingProvenance ? directFontProperties?.includes("fontSize") : markedStyle.fontSizePt !== inheritedStyle.fontSizePt)) directStyle.fontSizePt = markedStyle.fontSizePt;
330
+ if (markedStyle.color !== void 0 && (hasFormattingProvenance ? directFontProperties?.includes("color") : markedStyle.color !== inheritedStyle.color)) directStyle.color = markedStyle.color;
331
+ return directStyle;
332
+ };
313
333
  const getBooleanTextFormatting = (formatting) => ({
314
334
  ...Reflect.get(formatting, "bold") === true && { bold: true },
315
335
  ...Reflect.get(formatting, "italic") === true && { italic: true },
@@ -352,6 +372,8 @@ const getColorFromAttrs = (attrs) => {
352
372
  return `#${rgb}`;
353
373
  };
354
374
  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;
375
+ 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;
376
+ 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
377
  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
378
  //#endregion
357
379
  export { createFolioAIEditSnapshot, createFolioAITextRangeHandle, folioStoryTables, hashFolioAIBlockText, isFolioAIContentBlock, isHiddenTableRow, normalizeFolioAIBlockText, 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`:
@@ -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,
@@ -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