@stll/folio-core 0.37.1 → 0.37.3

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 (63) hide show
  1. package/dist/ai-edits/headless.d.ts +24 -1
  2. package/dist/ai-edits/headless.js +108 -71
  3. package/dist/ai-edits/read.d.ts +9 -1
  4. package/dist/ai-edits/read.js +34 -16
  5. package/dist/ai-edits/snapshot.d.ts +7 -5
  6. package/dist/ai-edits/snapshot.js +39 -12
  7. package/dist/ai-edits/table-cell-mutations.js +10 -5
  8. package/dist/ai-edits/table-template.js +19 -5
  9. package/dist/compare/compare.d.ts +2 -3
  10. package/dist/compare/compare.js +41 -64
  11. package/dist/compare/content-alignment.js +0 -1
  12. package/dist/compare/scenario.d.ts +11 -2
  13. package/dist/compare/scenario.js +6 -2
  14. package/dist/controller/hiddenEditorManager.js +11 -0
  15. package/dist/controller/layoutPipeline.js +1 -1
  16. package/dist/display-list/build/textBoxPrimitives.js +34 -1
  17. package/dist/display-list/dom/renderDisplayListToDom.js +5 -2
  18. package/dist/display-list/primitives.d.ts +1 -1
  19. package/dist/display-list/types.d.ts +8 -4
  20. package/dist/docx/paragraphPropertySource.d.ts +82 -2
  21. package/dist/docx/paragraphPropertySource.js +569 -3
  22. package/dist/docx/parser.js +9 -0
  23. package/dist/docx/server/materializeYjsDocx.d.ts +1 -1
  24. package/dist/docx/server/materializeYjsDocx.js +21 -2
  25. package/dist/headless-layout.js +1 -1
  26. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +7 -1
  27. package/dist/layout-bridge/convert/headerFooterLayout.js +20 -3
  28. package/dist/layout-bridge/convert/toFlowBlocks.js +43 -12
  29. package/dist/layout-engine/index.js +20 -10
  30. package/dist/layout-engine/measure/measureBlocks.d.ts +7 -3
  31. package/dist/layout-engine/measure/measureBlocks.js +10 -7
  32. package/dist/layout-engine/measure/measureParagraph.d.ts +2 -0
  33. package/dist/layout-engine/measure/measureParagraph.js +1 -1
  34. package/dist/layout-engine/paginator.d.ts +1 -1
  35. package/dist/layout-engine/paginator.js +41 -4
  36. package/dist/layout-engine/textBoxFlow.d.ts +2 -0
  37. package/dist/layout-engine/textBoxFlow.js +9 -5
  38. package/dist/layout-engine/types.d.ts +6 -0
  39. package/dist/layout-painter/documentColors.d.ts +10 -1
  40. package/dist/layout-painter/documentColors.js +16 -1
  41. package/dist/layout-painter/renderParagraph.js +9 -24
  42. package/dist/layout-painter/renderTable.js +3 -3
  43. package/dist/layout-painter/renderTextBox.js +2 -1
  44. package/dist/pdf/pageSpace.d.ts +12 -1
  45. package/dist/pdf/pageSpace.js +24 -1
  46. package/dist/pdf/paint.js +2 -2
  47. package/dist/prosemirror/attrs/index.js +19 -8
  48. package/dist/prosemirror/commands/comments.js +12 -3
  49. package/dist/prosemirror/commands/tableCellMergeResolution.js +18 -11
  50. package/dist/prosemirror/conversion/fromProseDoc.js +94 -21
  51. package/dist/prosemirror/conversion/toProseDoc.js +100 -8
  52. package/dist/prosemirror/extensions/core/DocExtension.js +2 -0
  53. package/dist/prosemirror/extensions/core/ParagraphExtension.js +2 -0
  54. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts +5 -1
  55. package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +132 -41
  56. package/dist/prosemirror/extensions/nodes/TextBoxExtension.d.ts +2 -0
  57. package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +7 -0
  58. package/dist/prosemirror/schema/nodes.d.ts +3 -1
  59. package/dist/prosemirror/yjsParagraphSourceContract.d.ts +9 -0
  60. package/dist/prosemirror/yjsParagraphSourceContract.js +26 -0
  61. package/dist/utils/rotationBoundingBox.d.ts +5 -1
  62. package/dist/utils/rotationBoundingBox.js +9 -1
  63. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  import { resolveColorValueToHex } from "../../docx/drawingUtils.js";
2
- import { linkProseParagraphPropertySource, recreateProseNodeWithParagraphPropertySource } from "../../docx/paragraphPropertySource.js";
2
+ import { PROSE_PARAGRAPH_SOURCE_CONTRACT_ATTR, createProseParagraphWithPropertySource, getDocumentParagraphPropertySourceContract, recreateProseNodeWithParagraphPropertySource, transportTableCellsWithParagraphPropertySources } from "../../docx/paragraphPropertySource.js";
3
3
  import { buildPageBreakRunSourceDescendantIndex } from "../../internal/pageBreakRunSourceDescendantIndex.js";
4
4
  import { createStyleEngine } from "../../style-engine/styleEngine.js";
5
5
  import { normalizeHorizontalScalePercent } from "../../utils/horizontalScale.js";
@@ -131,6 +131,7 @@ function toProseDoc(document, options) {
131
131
  const finalSectionStart = document.package.document.sections?.at(-1)?.properties.sectionStart ?? null;
132
132
  const adjustLineHeightInTable = document.package.settings?.adjustLineHeightInTable === true;
133
133
  const pmDoc = stampNumberedRefFieldBaselines(schema.node("doc", {
134
+ [PROSE_PARAGRAPH_SOURCE_CONTRACT_ATTR]: getDocumentParagraphPropertySourceContract(document) ?? null,
134
135
  _finalSectionStart: finalSectionStart,
135
136
  _adjustLineHeightInTable: adjustLineHeightInTable
136
137
  }, nodes));
@@ -205,7 +206,8 @@ function convertParagraph(paragraph, styleResolver, context, activeCommentIds, e
205
206
  let paragraphStyleRunFormatting;
206
207
  let paragraphStyleFontFamily;
207
208
  if (styleResolver) {
208
- styleRunFormatting = styleResolver.resolveParagraphStyle(paragraph.formatting?.styleId).runFormatting;
209
+ const resolved = styleResolver.resolveParagraphStyle(paragraph.formatting?.styleId);
210
+ styleRunFormatting = extraRunFormatting === void 0 ? resolved.runFormatting : withoutFontFamily(resolved.runFormatting);
209
211
  const paragraphStyle = paragraph.formatting?.styleId ? styleResolver.getStyle(paragraph.formatting.styleId) ?? styleResolver.getDefaultParagraphStyle() : styleResolver.getDefaultParagraphStyle();
210
212
  paragraphStyleRunFormatting = paragraphStyle?.type === "paragraph" ? paragraphStyle.rPr : void 0;
211
213
  paragraphStyleFontFamily = resolveParagraphStyleFontFamily(paragraph.formatting?.styleId, styleResolver);
@@ -343,18 +345,28 @@ function convertParagraph(paragraph, styleResolver, context, activeCommentIds, e
343
345
  }
344
346
  if (bookmarksArr) attrs.bookmarks = bookmarksArr;
345
347
  if (emptyHyperlinks) attrs._emptyHyperlinks = emptyHyperlinks;
346
- const proseParagraph = schema.node("paragraph", attrs, inlineNodes);
347
- linkProseParagraphPropertySource(proseParagraph, paragraph);
348
- return proseParagraph;
348
+ return createProseParagraphWithPropertySource(schema.nodes["paragraph"], paragraph, {
349
+ attrs,
350
+ content: inlineNodes
351
+ });
349
352
  }
353
+ const withoutFontFamily = (formatting) => {
354
+ if (!formatting?.fontFamily) return formatting;
355
+ const { fontFamily: _fontFamily, ...withoutFont } = formatting;
356
+ return Object.keys(withoutFont).length > 0 ? withoutFont : void 0;
357
+ };
350
358
  const resolveParagraphStyleFontFamily = (styleId, styleResolver) => {
351
359
  let style = styleId ? styleResolver.getStyle(styleId) : styleResolver.getDefaultParagraphStyle();
352
360
  const visited = /* @__PURE__ */ new Set();
361
+ const styleChain = [];
353
362
  while (style?.type === "paragraph" && !visited.has(style.styleId)) {
354
363
  visited.add(style.styleId);
355
- if (style.rPr?.fontFamily) return style.rPr.fontFamily;
364
+ if (style.rPr?.fontFamily) styleChain.push({ fontFamily: style.rPr.fontFamily });
356
365
  style = style.basedOn ? styleResolver.getStyle(style.basedOn) : void 0;
357
366
  }
367
+ let formatting;
368
+ for (const styleFormatting of styleChain.toReversed()) formatting = mergeTextFormatting(formatting, styleFormatting);
369
+ return formatting?.fontFamily;
358
370
  };
359
371
  /**
360
372
  * Apply comment marks to PM nodes within a comment range.
@@ -789,7 +801,14 @@ function paragraphContentHasMeaningfulContent(content) {
789
801
  return true;
790
802
  }
791
803
  function convertTable(table, styleResolver, context) {
792
- for (const row of table.rows) for (const cell of row.cells) assertSourceContainerHasNoPageBreakRun(cell.content, "table-cell", context.pageBreakRunSourceDescendants);
804
+ for (const row of table.rows) for (const cell of row.cells) {
805
+ if (cell.formatting?.vMerge === "continue" && context.pageBreakRunSourceDescendants.containsPageBreakRun(cell.content)) throw new UnsupportedDocxToProseMirrorConversionError({
806
+ message: `${PAGE_BREAK_CONTAINER_DESCRIPTIONS["table-cell"]} cannot be represented in the editor model`,
807
+ owner: "table-cell",
808
+ contentType: "break"
809
+ });
810
+ assertSourceContainerHasNoPageBreakRun(cell.content, "table-cell", context.pageBreakRunSourceDescendants);
811
+ }
793
812
  const rowSpanMap = calculateRowSpans(table);
794
813
  const columnWidths = table.columnWidths;
795
814
  const totalWidth = columnWidths?.reduce((sum, w) => sum + w, 0) ?? 0;
@@ -1077,7 +1096,7 @@ function convertTableCell({ cell, styleResolver, context, isHeader, gridWidthPer
1077
1096
  ...cell.structuralChange.verticalMergeOriginal !== void 0 ? { verticalMergeOriginal: cell.structuralChange.verticalMergeOriginal } : {}
1078
1097
  };
1079
1098
  if (preserveVMergeRestart) attrs._preserveVMergeRestart = true;
1080
- if (vMergeContinuationCells && vMergeContinuationCells.length > 0) attrs._docxVMergeContinuationCells = vMergeContinuationCells;
1099
+ if (vMergeContinuationCells && vMergeContinuationCells.length > 0) attrs._docxVMergeContinuationCells = transportTableCellsWithParagraphPropertySources(vMergeContinuationCells);
1081
1100
  const contentNodes = [];
1082
1101
  for (const content of cell.content) if (content.type === "paragraph") contentNodes.push(...convertParagraphWithTextBoxes(content, styleResolver, {
1083
1102
  textBoxGroupId: context.nextTextBoxGroupId(),
@@ -1255,12 +1274,74 @@ const PAGE_BREAK_PARAGRAPH_OWNERS = {
1255
1274
  };
1256
1275
  function assertSourceContainerHasNoPageBreakRun(content, owner, sourceDescendants) {
1257
1276
  if (!sourceDescendants.containsPageBreakRun(content)) return;
1277
+ if (owner === "table-cell" && hasSingleLeadingTableCellPageBreak(content)) return;
1258
1278
  throw new UnsupportedDocxToProseMirrorConversionError({
1259
1279
  message: `${PAGE_BREAK_CONTAINER_DESCRIPTIONS[owner]} cannot be represented in the editor model`,
1260
1280
  owner,
1261
1281
  contentType: "break"
1262
1282
  });
1263
1283
  }
1284
+ /** A leading page-break run in a cell advances the whole row during pagination. This
1285
+ * narrow shape has a lossless PM representation and a row-wide layout
1286
+ * projection; interior breaks still require table-fragment ownership. */
1287
+ function hasSingleLeadingTableCellPageBreak(content) {
1288
+ const paragraph = content.length === 1 && content[0]?.type === "paragraph" ? content[0] : void 0;
1289
+ return paragraph !== void 0 && hasSingleLeadingParagraphPageBreak(paragraph);
1290
+ }
1291
+ const scanLeadingPageBreakRun = (run, scan) => {
1292
+ for (const content of run.content) {
1293
+ if (content.type === "renderedPageBreak") continue;
1294
+ if (content.type === "break" && content.breakType === "page") {
1295
+ scan.pageBreaks += 1;
1296
+ continue;
1297
+ }
1298
+ if (scan.pageBreaks === 0) scan.contentBeforeBreak = true;
1299
+ }
1300
+ };
1301
+ const scanLeadingPageBreakContent = (content, scan) => {
1302
+ switch (content.type) {
1303
+ case "run":
1304
+ scanLeadingPageBreakRun(content, scan);
1305
+ return;
1306
+ case "hyperlink":
1307
+ for (const child of content.children) scanLeadingPageBreakContent(child, scan);
1308
+ return;
1309
+ case "insertion":
1310
+ case "deletion":
1311
+ case "moveFrom":
1312
+ case "moveTo":
1313
+ case "inlineSdt":
1314
+ for (const child of content.content) scanLeadingPageBreakContent(child, scan);
1315
+ return;
1316
+ case "simpleField":
1317
+ for (const child of content.content) scanLeadingPageBreakContent(child, scan);
1318
+ return;
1319
+ case "complexField":
1320
+ for (const child of content.fieldResult) scanLeadingPageBreakRun(child, scan);
1321
+ return;
1322
+ case "bookmarkStart":
1323
+ case "bookmarkEnd":
1324
+ case "commentRangeStart":
1325
+ case "commentRangeEnd":
1326
+ case "moveFromRangeStart":
1327
+ case "moveFromRangeEnd":
1328
+ case "moveToRangeStart":
1329
+ case "moveToRangeEnd": return;
1330
+ case "commentReference":
1331
+ case "mathEquation":
1332
+ if (scan.pageBreaks === 0) scan.contentBeforeBreak = true;
1333
+ return;
1334
+ default: return content;
1335
+ }
1336
+ };
1337
+ const hasSingleLeadingParagraphPageBreak = (paragraph) => {
1338
+ const scan = {
1339
+ contentBeforeBreak: false,
1340
+ pageBreaks: 0
1341
+ };
1342
+ for (const content of paragraph.content) scanLeadingPageBreakContent(content, scan);
1343
+ return !scan.contentBeforeBreak && scan.pageBreaks === 1;
1344
+ };
1264
1345
  function assertParagraphPageBreakCanBeProjected({ paragraph, attrs, effectiveFrame, sourceDescendants }) {
1265
1346
  const sourceFeatures = sourceDescendants.paragraphFeatures(paragraph);
1266
1347
  if (!sourceFeatures.hasPageBreakRun) return;
@@ -1270,6 +1351,7 @@ function assertParagraphPageBreakCanBeProjected({ paragraph, attrs, effectiveFra
1270
1351
  hasTextBoxAnchor: sourceFeatures.hasTextBoxShape && !sourceFeatures.pageBreakSharesTextBoxShape
1271
1352
  });
1272
1353
  if (disposition.status === "supported") return;
1354
+ if (disposition.reason !== "textBoxAnchor" && hasSingleLeadingParagraphPageBreak(paragraph)) return;
1273
1355
  throw new UnsupportedDocxToProseMirrorConversionError({
1274
1356
  message: disposition.message,
1275
1357
  owner: PAGE_BREAK_PARAGRAPH_OWNERS[disposition.reason],
@@ -2065,6 +2147,7 @@ function textBoxFromShape(shape, textBody) {
2065
2147
  if (shape.wrap) textBox.wrap = shape.wrap;
2066
2148
  if (shape.fill) textBox.fill = shape.fill;
2067
2149
  if (shape.outline) textBox.outline = shape.outline;
2150
+ if (shape.transform) textBox.transform = shape.transform;
2068
2151
  if (textBody.margins) textBox.margins = textBody.margins;
2069
2152
  if (textBody.autoFit) textBox.autoFit = textBody.autoFit;
2070
2153
  if (textBody.textWrap) textBox.textWrap = textBody.textWrap;
@@ -2089,6 +2172,14 @@ function convertTextBox(textBox, styleResolver, options) {
2089
2172
  if (textBox.outline.color?.rgb) outlineColor = `#${textBox.outline.color.rgb}`;
2090
2173
  outlineStyle = textBox.outline.style || "solid";
2091
2174
  }
2175
+ let transform;
2176
+ if (textBox.transform) {
2177
+ const transforms = [];
2178
+ if (textBox.transform.rotation) transforms.push(`rotate(${textBox.transform.rotation}deg)`);
2179
+ if (textBox.transform.flipH) transforms.push("scaleX(-1)");
2180
+ if (textBox.transform.flipV) transforms.push("scaleY(-1)");
2181
+ if (transforms.length > 0) transform = transforms.join(" ");
2182
+ }
2092
2183
  const marginTop = textBox.margins?.top !== void 0 ? emuToPixels(textBox.margins.top) : 4;
2093
2184
  const marginBottom = textBox.margins?.bottom !== void 0 ? emuToPixels(textBox.margins.bottom) : 4;
2094
2185
  const marginLeft = textBox.margins?.left !== void 0 ? emuToPixels(textBox.margins.left) : 7;
@@ -2151,6 +2242,7 @@ function convertTextBox(textBox, styleResolver, options) {
2151
2242
  outlineWidth,
2152
2243
  outlineColor,
2153
2244
  outlineStyle,
2245
+ transform,
2154
2246
  marginTop,
2155
2247
  marginBottom,
2156
2248
  marginLeft,
@@ -1,3 +1,4 @@
1
+ import { PROSE_PARAGRAPH_SOURCE_CONTRACT_ATTR } from "../../../docx/paragraphPropertySource.js";
1
2
  import { createNodeExtension } from "../create.js";
2
3
  //#region src/prosemirror/extensions/core/DocExtension.ts
3
4
  /**
@@ -8,6 +9,7 @@ const DocExtension = createNodeExtension({
8
9
  schemaNodeName: "doc",
9
10
  nodeSpec: {
10
11
  attrs: {
12
+ [PROSE_PARAGRAPH_SOURCE_CONTRACT_ATTR]: { default: null },
11
13
  _finalSectionStart: { default: null },
12
14
  _adjustLineHeightInTable: { default: false }
13
15
  },
@@ -1,3 +1,4 @@
1
+ import { PROSE_PARAGRAPH_SOURCE_TOKEN_ATTR } from "../../../docx/paragraphPropertySource.js";
1
2
  import { PARAGRAPH_ALIGNMENT_VALUES } from "../../../types/documentEnumValues.js";
2
3
  import { paragraphToStyle } from "../../../utils/formatToStyle.js";
3
4
  import { collectHeadings } from "../../../utils/headingCollector.js";
@@ -184,6 +185,7 @@ const paragraphNodeSpec = {
184
185
  group: "block",
185
186
  attrs: {
186
187
  paraId: { default: null },
188
+ [PROSE_PARAGRAPH_SOURCE_TOKEN_ATTR]: { default: null },
187
189
  idStability: { default: void 0 },
188
190
  textId: { default: null },
189
191
  alignment: { default: null },
@@ -2,7 +2,11 @@ import { Extension } from "../types.js";
2
2
  import { EditorState, PluginKey, Transaction } from "prosemirror-state";
3
3
  import { Node } from "prosemirror-model";
4
4
  //#region src/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts
5
- declare const paraIdAllocatorKey: PluginKey<any>;
5
+ type ParagraphPropertySourceSeed = {
6
+ contract: string | null;
7
+ tokens: ReadonlySet<string>;
8
+ };
9
+ declare const paraIdAllocatorKey: PluginKey<ParagraphPropertySourceSeed>;
6
10
  /**
7
11
  * Transaction meta asking this plugin to derive the ids it mints from a
8
12
  * caller-supplied seed instead of `Math.random()`.
@@ -1,7 +1,8 @@
1
- import { recreateProseNodeWithParagraphPropertySource, setProseParagraphMarkupWithPropertySource, transferProseParagraphPropertySource } from "../../../docx/paragraphPropertySource.js";
1
+ import { PROSE_PARAGRAPH_SOURCE_TOKEN_ATTR, getExplicitParagraphPropertySourceTransfers, getProseDocumentParagraphPropertySourceContract, getProseParagraphPropertySourceToken, paragraphPropertySourceTokenMatchesContract, recreateProseNodeWithParagraphPropertySource, setProseParagraphMarkupWithPropertySource, transferProseParagraphPropertySource } from "../../../docx/paragraphPropertySource.js";
2
2
  import { deterministicHexId, generateHexId } from "../../../utils/hexId.js";
3
3
  import { createExtension } from "../create.js";
4
4
  import { ignoreTrackedChanges } from "./ParagraphChangeTrackerExtension.js";
5
+ import { panic } from "better-result";
5
6
  import { Fragment } from "prosemirror-model";
6
7
  import { Plugin, PluginKey } from "prosemirror-state";
7
8
  //#region src/prosemirror/extensions/features/ParaIdAllocatorExtension.ts
@@ -73,61 +74,100 @@ const mintParaId = (taken, seed, pos) => {
73
74
  return id;
74
75
  };
75
76
  const isUsableParaId = (value) => typeof value === "string" && value.length > 0 && value !== "00000000";
76
- /**
77
- * For every valid paraId in the pre-transaction doc, the position its
78
- * paragraph ends up at after `transactions` — the occurrence that
79
- * rightfully keeps the id when the new doc holds duplicates. Ids whose
80
- * paragraph start was deleted by the steps are left out.
81
- */
82
- const mapKeeperPositions = (oldDoc, transactions) => {
83
- const keepers = /* @__PURE__ */ new Map();
77
+ const collectParagraphPropertySourceSeed = (doc) => {
78
+ const contract = getProseDocumentParagraphPropertySourceContract(doc);
79
+ if (!contract) return {
80
+ contract: null,
81
+ tokens: /* @__PURE__ */ new Set()
82
+ };
83
+ const counts = /* @__PURE__ */ new Map();
84
+ doc.descendants((node) => {
85
+ if (node.type.name !== "paragraph") return true;
86
+ const token = getProseParagraphPropertySourceToken(node);
87
+ if (paragraphPropertySourceTokenMatchesContract(token, contract)) counts.set(token, (counts.get(token) ?? 0) + 1);
88
+ return false;
89
+ });
90
+ return {
91
+ contract,
92
+ tokens: new Set([...counts].filter(([, count]) => count === 1).map(([token]) => token))
93
+ };
94
+ };
95
+ const mapParagraphKeepers = (oldDoc, transactions, sourceSeed) => {
96
+ const paraIds = /* @__PURE__ */ new Map();
97
+ const sourceTokens = /* @__PURE__ */ new Map();
84
98
  oldDoc.descendants((node, pos) => {
85
99
  if (node.type.name !== "paragraph") return true;
86
100
  const id = node.attrs["paraId"];
87
- if (!isUsableParaId(id) || keepers.has(id)) return false;
101
+ const token = getProseParagraphPropertySourceToken(node);
102
+ const mapsParaId = isUsableParaId(id) && !paraIds.has(id);
103
+ const mapsSourceToken = typeof token === "string" && sourceSeed.tokens.has(token);
104
+ if (!mapsParaId && !mapsSourceToken) return false;
88
105
  let mapped = pos;
106
+ let mappedInterior = pos + 1;
89
107
  let deleted = false;
90
- for (const tr of transactions) {
91
- const result = tr.mapping.mapResult(mapped);
92
- if (result.deleted) {
93
- deleted = true;
94
- break;
95
- }
96
- mapped = result.pos;
108
+ for (const transaction of transactions) {
109
+ const ownerResult = transaction.mapping.mapResult(mapped);
110
+ const interiorResult = transaction.mapping.mapResult(mappedInterior, -1);
111
+ deleted ||= interiorResult.deletedAcross;
112
+ mapped = ownerResult.pos;
113
+ mappedInterior = interiorResult.pos;
97
114
  }
98
- if (!deleted) keepers.set(id, mapped);
115
+ if (mapsParaId && !deleted) paraIds.set(id, mapped);
116
+ if (mapsSourceToken) sourceTokens.set(token, sourceTokens.has(token) ? { status: "ambiguous" } : {
117
+ pos: mapped,
118
+ status: deleted ? "deleted" : "mapped"
119
+ });
99
120
  return false;
100
121
  });
101
- return keepers;
122
+ return {
123
+ paraIds,
124
+ sourceTokens
125
+ };
102
126
  };
103
- const collectParaIdUpdates = (doc, keeperPositions, deterministicSeed = null) => {
104
- const missing = [];
105
- const occurrencesById = /* @__PURE__ */ new Map();
127
+ const collectParagraphCensus = (doc, sourceSeed) => {
128
+ const census = {
129
+ invalidSourceTokens: [],
130
+ missingParaIds: [],
131
+ paragraphs: /* @__PURE__ */ new Map(),
132
+ paraIds: /* @__PURE__ */ new Map(),
133
+ sourceTokens: /* @__PURE__ */ new Map()
134
+ };
135
+ const contractMatchesSeed = sourceSeed?.contract !== null && sourceSeed?.contract !== void 0 && getProseDocumentParagraphPropertySourceContract(doc) === sourceSeed.contract;
106
136
  doc.descendants((node, pos) => {
107
137
  if (node.type.name !== "paragraph") return true;
108
- const id = node.attrs["paraId"];
109
- if (!isUsableParaId(id)) missing.push({
138
+ const occurrence = {
110
139
  pos,
111
140
  attrs: node.attrs
112
- });
113
- else {
114
- const occurrences = occurrencesById.get(id) ?? [];
115
- occurrences.push({
116
- pos,
117
- attrs: node.attrs
118
- });
119
- occurrencesById.set(id, occurrences);
141
+ };
142
+ census.paragraphs.set(pos, occurrence);
143
+ const id = node.attrs["paraId"];
144
+ if (isUsableParaId(id)) {
145
+ const occurrences = census.paraIds.get(id) ?? [];
146
+ occurrences.push(occurrence);
147
+ census.paraIds.set(id, occurrences);
148
+ } else census.missingParaIds.push(occurrence);
149
+ if (sourceSeed) {
150
+ const token = getProseParagraphPropertySourceToken(node);
151
+ if (token !== null && token !== void 0) if (typeof token !== "string" || !contractMatchesSeed || !sourceSeed.contract || !sourceSeed.tokens.has(token) || !paragraphPropertySourceTokenMatchesContract(token, sourceSeed.contract)) census.invalidSourceTokens.push(occurrence);
152
+ else {
153
+ const occurrences = census.sourceTokens.get(token) ?? [];
154
+ occurrences.push(occurrence);
155
+ census.sourceTokens.set(token, occurrences);
156
+ }
120
157
  }
121
158
  return false;
122
159
  });
123
- const needFreshId = [...missing];
124
- for (const [id, occurrences] of occurrencesById) {
160
+ return census;
161
+ };
162
+ const collectParaIdUpdatesFromCensus = (census, keeperPositions, deterministicSeed = null) => {
163
+ const needFreshId = [...census.missingParaIds];
164
+ for (const [id, occurrences] of census.paraIds) {
125
165
  if (occurrences.length === 1) continue;
126
166
  const keeperPos = keeperPositions?.get(id);
127
- const keeper = occurrences.find((occurrence) => occurrence.pos === keeperPos) ?? occurrences[0];
167
+ const keeper = occurrences.find(({ pos }) => pos === keeperPos) ?? occurrences[0];
128
168
  for (const occurrence of occurrences) if (occurrence !== keeper) needFreshId.push(occurrence);
129
169
  }
130
- const taken = new Set(occurrencesById.keys());
170
+ const taken = new Set(census.paraIds.keys());
131
171
  const updates = [];
132
172
  for (const { pos, attrs } of needFreshId) {
133
173
  const newId = mintParaId(taken, deterministicSeed, pos);
@@ -140,9 +180,39 @@ const collectParaIdUpdates = (doc, keeperPositions, deterministicSeed = null) =>
140
180
  }
141
181
  });
142
182
  }
143
- updates.sort((a, b) => a.pos - b.pos);
183
+ updates.sort((left, right) => left.pos - right.pos);
144
184
  return updates;
145
185
  };
186
+ const collectParaIdUpdates = (doc) => collectParaIdUpdatesFromCensus(collectParagraphCensus(doc));
187
+ const collectParagraphPropertySourceUpdates = (census, keeperPositions, transactions) => {
188
+ const updates = /* @__PURE__ */ new Map();
189
+ for (const { pos } of census.invalidSourceTokens) updates.set(pos, null);
190
+ const explicitTransfers = transactions.flatMap((transaction) => [...getExplicitParagraphPropertySourceTransfers(transaction)]);
191
+ const explicitlySelected = new Set(explicitTransfers.map(({ selectedToken }) => selectedToken).filter((token) => token !== null));
192
+ const explicitlyDisplaced = new Set(explicitTransfers.map(({ displacedToken }) => displacedToken).filter((token) => token !== null));
193
+ const keptPositions = /* @__PURE__ */ new Set();
194
+ const keptTokens = /* @__PURE__ */ new Set();
195
+ for (const [token, occurrences] of census.sourceTokens) {
196
+ const mappedOwner = keeperPositions.get(token);
197
+ const mappedKeeper = mappedOwner?.status === "mapped" ? occurrences.find(({ pos }) => pos === mappedOwner.pos) : void 0;
198
+ const transferredKeeper = !mappedKeeper && explicitlySelected.has(token) && occurrences.length === 1 ? occurrences.at(0) : void 0;
199
+ const detachedKeeper = !mappedKeeper && !transferredKeeper && occurrences.length === 1 && (mappedOwner === void 0 || mappedOwner.status === "deleted" && mappedOwner.pos === occurrences.at(0)?.pos) ? occurrences.at(0) : void 0;
200
+ const keeper = mappedKeeper ?? transferredKeeper ?? detachedKeeper;
201
+ if (keeper) {
202
+ keptPositions.add(keeper.pos);
203
+ keptTokens.add(token);
204
+ }
205
+ for (const occurrence of occurrences) if (occurrence !== keeper) updates.set(occurrence.pos, null);
206
+ }
207
+ for (const [token, mappedOwner] of keeperPositions) {
208
+ if (mappedOwner.status !== "mapped" || keptTokens.has(token) || explicitlyDisplaced.has(token) || keptPositions.has(mappedOwner.pos) || !census.paragraphs.has(mappedOwner.pos)) continue;
209
+ updates.set(mappedOwner.pos, token);
210
+ }
211
+ return [...updates].map(([pos, token]) => ({
212
+ pos,
213
+ token
214
+ })).sort((left, right) => left.pos - right.pos);
215
+ };
146
216
  const collectInitialParaIds = (doc) => {
147
217
  let needsRewrite = false;
148
218
  const taken = /* @__PURE__ */ new Set();
@@ -215,12 +285,20 @@ const ensureParaIdsInState = (state) => {
215
285
  };
216
286
  const createParaIdAllocatorPlugin = () => new Plugin({
217
287
  key: paraIdAllocatorKey,
288
+ state: {
289
+ init: (_config, state) => collectParagraphPropertySourceSeed(state.doc),
290
+ apply: (_transaction, seed) => seed
291
+ },
218
292
  appendTransaction(transactions, oldState, newState) {
219
293
  if (!transactions.some((t) => t.docChanged)) return null;
220
- const keeperPositions = mapKeeperPositions(oldState.doc, transactions);
221
- const seed = transactions.map((transaction) => transaction.getMeta(deterministicParaIdSeedKey)).find((value) => typeof value === "string") ?? null;
222
- const updates = collectParaIdUpdates(newState.doc, keeperPositions, seed);
223
- if (updates.length === 0) return null;
294
+ const paragraphSourceSeed = paraIdAllocatorKey.getState(oldState);
295
+ if (!paragraphSourceSeed) panic("ParaId allocator lost its paragraph-property source seed");
296
+ const keeperPositions = mapParagraphKeepers(oldState.doc, transactions, paragraphSourceSeed);
297
+ const deterministicSeed = transactions.map((transaction) => transaction.getMeta(deterministicParaIdSeedKey)).find((value) => typeof value === "string") ?? null;
298
+ const census = collectParagraphCensus(newState.doc, paragraphSourceSeed);
299
+ const updates = collectParaIdUpdatesFromCensus(census, keeperPositions.paraIds, deterministicSeed);
300
+ const paragraphSourceUpdates = collectParagraphPropertySourceUpdates(census, keeperPositions.sourceTokens, transactions);
301
+ if (updates.length === 0 && paragraphSourceUpdates.length === 0) return null;
224
302
  const tr = newState.tr;
225
303
  for (const u of updates) setProseParagraphMarkupWithPropertySource({
226
304
  attrs: u.attrs,
@@ -228,6 +306,19 @@ const createParaIdAllocatorPlugin = () => new Plugin({
228
306
  pos: u.pos,
229
307
  transaction: tr
230
308
  });
309
+ for (const { pos, token } of paragraphSourceUpdates) {
310
+ const paragraph = tr.doc.nodeAt(pos);
311
+ if (!paragraph || paragraph.type.name !== "paragraph") panic("Paragraph-property token update lost its paragraph");
312
+ setProseParagraphMarkupWithPropertySource({
313
+ attrs: {
314
+ ...paragraph.attrs,
315
+ [PROSE_PARAGRAPH_SOURCE_TOKEN_ATTR]: token
316
+ },
317
+ ownership: "preserve",
318
+ pos,
319
+ transaction: tr
320
+ });
321
+ }
231
322
  ignoreTrackedChanges(tr);
232
323
  tr.setMeta(paraIdAllocatorKey, "allocated");
233
324
  tr.setMeta("addToHistory", false);
@@ -22,6 +22,8 @@ type TextBoxAttrs = {
22
22
  outlineColor?: string;
23
23
  /** Outline dash style, or `"none"` for an explicit no-outline. */
24
24
  outlineStyle?: OutlineStyleAttr;
25
+ /** DrawingML rotation and/or flips, serialized as CSS transform functions. */
26
+ transform?: string;
25
27
  /** Internal margin top in pixels */
26
28
  marginTop?: number;
27
29
  /** Internal margin bottom in pixels */
@@ -42,6 +42,7 @@ const TextBoxExtension = createNodeExtension({
42
42
  outlineWidth: { default: null },
43
43
  outlineColor: { default: null },
44
44
  outlineStyle: { default: null },
45
+ transform: { default: null },
45
46
  marginTop: { default: 4 },
46
47
  marginBottom: { default: 4 },
47
48
  marginLeft: { default: 7 },
@@ -82,6 +83,7 @@ const TextBoxExtension = createNodeExtension({
82
83
  ...d["outlineWidth"] ? { outlineWidth: Number(d["outlineWidth"]) } : {},
83
84
  ...d["outlineColor"] ? { outlineColor: d["outlineColor"] } : {},
84
85
  ...d["outlineStyle"] ? { outlineStyle: d["outlineStyle"] } : {},
86
+ ...d["transform"] ? { transform: d["transform"] } : {},
85
87
  ...d["marginTop"] ? { marginTop: Number(d["marginTop"]) } : {},
86
88
  ...d["marginBottom"] ? { marginBottom: Number(d["marginBottom"]) } : {},
87
89
  ...d["marginLeft"] ? { marginLeft: Number(d["marginLeft"]) } : {},
@@ -111,6 +113,7 @@ const TextBoxExtension = createNodeExtension({
111
113
  if (attrs.outlineWidth) domAttrs["data-outline-width"] = String(attrs.outlineWidth);
112
114
  if (attrs.outlineColor) domAttrs["data-outline-color"] = attrs.outlineColor;
113
115
  if (attrs.outlineStyle) domAttrs["data-outline-style"] = attrs.outlineStyle;
116
+ if (attrs.transform) domAttrs["data-transform"] = attrs.transform;
114
117
  if (typeof attrs.marginTop === "number") domAttrs["data-margin-top"] = String(attrs.marginTop);
115
118
  if (typeof attrs.marginBottom === "number") domAttrs["data-margin-bottom"] = String(attrs.marginBottom);
116
119
  if (typeof attrs.marginLeft === "number") domAttrs["data-margin-left"] = String(attrs.marginLeft);
@@ -158,6 +161,10 @@ const TextBoxExtension = createNodeExtension({
158
161
  styles.push("box-sizing: border-box");
159
162
  styles.push("overflow: hidden");
160
163
  styles.push("position: relative");
164
+ if (attrs.transform) {
165
+ styles.push(`transform: ${attrs.transform}`);
166
+ styles.push("transform-origin: center center");
167
+ }
161
168
  domAttrs["style"] = styles.join("; ");
162
169
  return [
163
170
  "div",
@@ -507,6 +507,8 @@ type TextBoxAttrs = {
507
507
  outlineColor?: string;
508
508
  /** Outline dash style, or `"none"` for an explicit no-outline. */
509
509
  outlineStyle?: OutlineStyleAttr;
510
+ /** DrawingML rotation and/or flips, serialized as CSS transform functions. */
511
+ transform?: string;
510
512
  /** Internal margin top in pixels */
511
513
  marginTop?: number;
512
514
  /** Internal margin bottom in pixels */
@@ -765,7 +767,7 @@ type TableCellAttrs = {
765
767
  /** Preserve a DOCX vMerge restart even when PM cannot model it as a rowspan. */
766
768
  _preserveVMergeRestart?: boolean;
767
769
  /** Original DOCX vMerge continuation cells skipped into this PM rowspan. */
768
- _docxVMergeContinuationCells?: document_d_exports.TableCell[];
770
+ _docxVMergeContinuationCells?: unknown;
769
771
  };
770
772
  //#endregion
771
773
  export { BlockSdtAttrs, BookmarkBoundaryAttrs, FieldAttrs, HardBreakAttrs, ImageAttrs, ImagePositionAttrs, MathAttrs, PageBreakRunAttrs, ParagraphAttrs, ParagraphPropertyChangeAttrs, SdtAttrs, ShapeAttrs, SuggestedStructuralMarker, SymbolAttrs, TabAttrs, TableAttrs, TableCellAttrs, TableRowAttrs, TextBoxAnchorAttrs, TextBoxAttrs };
@@ -0,0 +1,9 @@
1
+ import { Node } from "prosemirror-model";
2
+ import * as Y from "yjs";
3
+ //#region src/prosemirror/yjsParagraphSourceContract.d.ts
4
+ declare const proseDocumentParagraphSourceContract: (document: Node) => string | null;
5
+ declare const writeYjsParagraphSourceContract: (ydoc: Y.Doc, document: Node) => void;
6
+ declare const readYjsParagraphSourceContract: (ydoc: Y.Doc) => string | null;
7
+ declare const withParagraphSourceContract: (document: Node, contract: string) => Node;
8
+ //#endregion
9
+ export { proseDocumentParagraphSourceContract, readYjsParagraphSourceContract, withParagraphSourceContract, writeYjsParagraphSourceContract };
@@ -0,0 +1,26 @@
1
+ import { PROSE_PARAGRAPH_SOURCE_CONTRACT_ATTR, getProseDocumentParagraphPropertySourceContract } from "../docx/paragraphPropertySource.js";
2
+ import { panic } from "better-result";
3
+ //#region src/prosemirror/yjsParagraphSourceContract.ts
4
+ const FOLIO_YJS_METADATA_MAP_NAME = "folio:document-metadata";
5
+ const PARAGRAPH_SOURCE_CONTRACT_KEY = "paragraphSourceContract";
6
+ const proseDocumentParagraphSourceContract = (document) => {
7
+ return getProseDocumentParagraphPropertySourceContract(document);
8
+ };
9
+ const writeYjsParagraphSourceContract = (ydoc, document) => {
10
+ const contract = proseDocumentParagraphSourceContract(document);
11
+ if (!contract) panic("Cannot seed collaboration without a paragraph-property source contract");
12
+ ydoc.getMap(FOLIO_YJS_METADATA_MAP_NAME).set(PARAGRAPH_SOURCE_CONTRACT_KEY, contract);
13
+ };
14
+ const readYjsParagraphSourceContract = (ydoc) => {
15
+ const contract = ydoc.getMap(FOLIO_YJS_METADATA_MAP_NAME).get(PARAGRAPH_SOURCE_CONTRACT_KEY);
16
+ return typeof contract === "string" ? contract : null;
17
+ };
18
+ const withParagraphSourceContract = (document, contract) => {
19
+ if (document.type.name !== "doc") panic("A paragraph-property source contract can only attach to a document node");
20
+ return document.type.create({
21
+ ...document.attrs,
22
+ [PROSE_PARAGRAPH_SOURCE_CONTRACT_ATTR]: contract
23
+ }, document.content, document.marks);
24
+ };
25
+ //#endregion
26
+ export { proseDocumentParagraphSourceContract, readYjsParagraphSourceContract, withParagraphSourceContract, writeYjsParagraphSourceContract };
@@ -5,7 +5,11 @@ type BoundingBox = {
5
5
  height: number;
6
6
  };
7
7
  declare function parseRotationDegrees(transform: string | undefined): number;
8
+ /** Whether a DrawingML/CSS transform reflects around the vertical centre line. */
9
+ declare function hasHorizontalFlip(transform: string | undefined): boolean;
10
+ /** Whether a DrawingML/CSS transform reflects around the horizontal centre line. */
11
+ declare function hasVerticalFlip(transform: string | undefined): boolean;
8
12
  declare function rotatedBoundingBox(w: number, h: number, deg: number): BoundingBox;
9
13
  declare function inlineImageBoundingBox(run: ImageRun): BoundingBox;
10
14
  //#endregion
11
- export { BoundingBox, inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox };
15
+ export { BoundingBox, hasHorizontalFlip, hasVerticalFlip, inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox };
@@ -7,6 +7,14 @@ function parseRotationDegrees(transform) {
7
7
  if (!Number.isFinite(raw)) return 0;
8
8
  return (raw % 360 + 360) % 360;
9
9
  }
10
+ /** Whether a DrawingML/CSS transform reflects around the vertical centre line. */
11
+ function hasHorizontalFlip(transform) {
12
+ return /scaleX\(\s*-1\s*\)/iu.test(transform ?? "");
13
+ }
14
+ /** Whether a DrawingML/CSS transform reflects around the horizontal centre line. */
15
+ function hasVerticalFlip(transform) {
16
+ return /scaleY\(\s*-1\s*\)/iu.test(transform ?? "");
17
+ }
10
18
  function rotatedBoundingBox(w, h, deg) {
11
19
  if (deg === 0 || deg === 180) return {
12
20
  width: w,
@@ -33,4 +41,4 @@ function inlineImageBoundingBox(run) {
33
41
  return rotatedBoundingBox(run.width, run.height, rotation);
34
42
  }
35
43
  //#endregion
36
- export { inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox };
44
+ export { hasHorizontalFlip, hasVerticalFlip, inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.37.1",
3
+ "version": "0.37.3",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",
@@ -121,7 +121,7 @@
121
121
  "perf": "bun scripts/profile-editor.ts"
122
122
  },
123
123
  "dependencies": {
124
- "@stll/docx-core": "^0.19.3",
124
+ "@stll/docx-core": "^0.19.4",
125
125
  "@stll/docx-utils": "^0.1.0",
126
126
  "@stll/template-conditions": ">=0.4.0 <1.0.0",
127
127
  "better-result": "3.0.1",