@stll/folio-core 0.27.1 → 0.28.1

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 (33) hide show
  1. package/dist/docx/server/extractDocxText.js +108 -33
  2. package/dist/docx/server/materializeYjsDocx.d.ts +31 -0
  3. package/dist/docx/server/materializeYjsDocx.js +61 -0
  4. package/dist/docx/styleParser.js +15 -14
  5. package/dist/layout-bridge/convert/toFlowBlocks.js +74 -23
  6. package/dist/layout-engine/footnoteColumnReflow.d.ts +15 -0
  7. package/dist/layout-engine/footnoteColumnReflow.js +74 -0
  8. package/dist/layout-engine/index.d.ts +3 -5
  9. package/dist/layout-engine/index.js +36 -15
  10. package/dist/layout-engine/measure/cache.d.ts +1 -1
  11. package/dist/layout-engine/measure/cache.js +11 -2
  12. package/dist/layout-engine/measure/listMarkerWidth.d.ts +3 -1
  13. package/dist/layout-engine/measure/listMarkerWidth.js +6 -4
  14. package/dist/layout-engine/paginator.d.ts +6 -0
  15. package/dist/layout-engine/paginator.js +30 -11
  16. package/dist/layout-engine/types.d.ts +11 -6
  17. package/dist/prosemirror/attrs/index.js +3 -2
  18. package/dist/prosemirror/commands/formatPainter.js +45 -1
  19. package/dist/prosemirror/conversion/fromProseDoc.d.ts +4 -1
  20. package/dist/prosemirror/conversion/fromProseDoc.js +59 -44
  21. package/dist/prosemirror/conversion/toProseDoc.d.ts +4 -1
  22. package/dist/prosemirror/conversion/toProseDoc.js +156 -33
  23. package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.js +22 -22
  24. package/dist/prosemirror/schema/marks.d.ts +3 -1
  25. package/dist/prosemirror/styles/styleResolver.d.ts +0 -1
  26. package/dist/prosemirror/styles/styleResolver.js +22 -29
  27. package/dist/prosemirror/styles/styleToggleCascade.d.ts +37 -0
  28. package/dist/prosemirror/styles/styleToggleCascade.js +52 -0
  29. package/dist/server.d.ts +2 -1
  30. package/dist/server.js +2 -1
  31. package/dist/utils/textFormattingMerge.d.ts +9 -1
  32. package/dist/utils/textFormattingMerge.js +32 -1
  33. package/package.json +2 -2
@@ -1,9 +1,10 @@
1
1
  import { escapeTableCell } from "../../markdown/escape.js";
2
2
  import { RELATIONSHIP_TYPES, parseRelationships } from "../relsParser.js";
3
3
  import { findAllDeep, findDeep, getAttribute, getAttributeByNamespaceUri, getLocalName, getNamespaceUri, getTextContent, parseXml } from "../xmlParser.js";
4
- import { loadDocxArchive } from "./boundedArchive.js";
4
+ import { DocxArchiveError, loadDocxArchive } from "./boundedArchive.js";
5
5
  //#region src/docx/server/extractDocxText.ts
6
6
  const DOCUMENT_RELS_PATH = "word/_rels/document.xml.rels";
7
+ const ARCHIVE_LIMIT_REASON = "total-too-large";
7
8
  const WORDPROCESSINGML_NAMESPACES = /* @__PURE__ */ new Set(["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]);
8
9
  const childElements = (element) => element.elements?.filter((child) => child.type === "element") ?? [];
9
10
  const wordElementName = (element) => WORDPROCESSINGML_NAMESPACES.has(getNamespaceUri(element) ?? "") ? getLocalName(element.name) : null;
@@ -105,6 +106,23 @@ const MAX_TABLE_COLUMNS = 256;
105
106
  const MAX_NESTED_TABLE_DEPTH = 8;
106
107
  /** Rows collected from one `w:tbl`. The column cap alone leaves row count unbounded. */
107
108
  const MAX_TABLE_ROWS = 8192;
109
+ /** Source and rendered characters retained across every table in one extraction. */
110
+ const MAX_TABLE_CHARACTERS = 8 * 1024 * 1024;
111
+ const createTableExtractionBudget = () => ({
112
+ remainingSourceCharacters: MAX_TABLE_CHARACTERS,
113
+ remainingRenderedCharacters: MAX_TABLE_CHARACTERS
114
+ });
115
+ const chargeTableCharacters = ({ budget, characters, kind }) => {
116
+ if (characters > (kind === "source" ? budget.remainingSourceCharacters : budget.remainingRenderedCharacters)) throw new DocxArchiveError({
117
+ message: `Extracted DOCX table ${kind} text exceeded the ${MAX_TABLE_CHARACTERS}-character limit`,
118
+ reason: ARCHIVE_LIMIT_REASON
119
+ });
120
+ if (kind === "source") {
121
+ budget.remainingSourceCharacters -= characters;
122
+ return;
123
+ }
124
+ budget.remainingRenderedCharacters -= characters;
125
+ };
108
126
  /**
109
127
  * Characters one extraction emits, shared by the body and every header/footer
110
128
  * part. Element count is bounded at unzip, but a bounded element count still
@@ -112,19 +130,16 @@ const MAX_TABLE_ROWS = 8192;
112
130
  * GFM scaffolding are counted, so the emitted side carries its own ceiling.
113
131
  */
114
132
  const MAX_EXTRACTED_CHARS = 8e6;
115
- /**
116
- * Collect a table's `w:tr`, or a row's `w:tc`, seeing through the wrappers Word
117
- * puts around them (`w:sdt` / `w:sdtContent` content controls, `w:customXml`).
118
- * The walk stops at `w:tbl` and `w:p` so a nested table's rows and cells never
119
- * leak into the grid of the table that contains them.
120
- */
121
- const collectTableParts = (parent, localName, limit) => {
133
+ const collectTableParts = ({ parent, localName, limit }) => {
122
134
  const parts = [];
123
135
  const walk = (node) => {
124
136
  for (const child of childElements(node)) {
125
- if (parts.length >= limit) return;
126
137
  const childName = wordElementName(child);
127
138
  if (childName === localName) {
139
+ if (parts.length >= limit) throw new DocxArchiveError({
140
+ message: `Extracted DOCX table exceeded the ${limit}-${localName} limit`,
141
+ reason: ARCHIVE_LIMIT_REASON
142
+ });
128
143
  parts.push(child);
129
144
  continue;
130
145
  }
@@ -152,7 +167,15 @@ const readCellSourceParagraphs = (cell, depth) => {
152
167
  }
153
168
  if (childName === "tbl") {
154
169
  if (depth >= MAX_NESTED_TABLE_DEPTH) continue;
155
- for (const row of collectTableParts(child, "tr", MAX_TABLE_ROWS)) for (const nestedCell of collectTableParts(row, "tc", MAX_TABLE_COLUMNS)) for (const paragraph of readCellSourceParagraphs(nestedCell, depth + 1)) paragraphs.push(paragraph);
170
+ for (const row of collectTableParts({
171
+ parent: child,
172
+ localName: "tr",
173
+ limit: MAX_TABLE_ROWS
174
+ })) for (const nestedCell of collectTableParts({
175
+ parent: row,
176
+ localName: "tc",
177
+ limit: MAX_TABLE_COLUMNS
178
+ })) for (const paragraph of readCellSourceParagraphs(nestedCell, depth + 1)) paragraphs.push(paragraph);
156
179
  continue;
157
180
  }
158
181
  walk(child);
@@ -161,18 +184,26 @@ const readCellSourceParagraphs = (cell, depth) => {
161
184
  walk(cell);
162
185
  return paragraphs;
163
186
  };
164
- const readCellRenderedLines = (cell, depth) => {
187
+ const readCellRenderedLines = (cell, { depth, budget }) => {
165
188
  const lines = [];
166
189
  const walk = (node) => {
167
190
  for (const child of childElements(node)) {
168
191
  const childName = wordElementName(child);
169
192
  if (childName === "p") {
170
193
  const text = collectText(child);
194
+ chargeTableCharacters({
195
+ budget,
196
+ characters: text.length,
197
+ kind: "source"
198
+ });
171
199
  if (text.length > 0) lines.push(text);
172
200
  continue;
173
201
  }
174
202
  if (childName === "tbl") {
175
- if (depth < MAX_NESTED_TABLE_DEPTH) for (const line of flattenNestedTable(child, depth + 1)) lines.push(line);
203
+ if (depth < MAX_NESTED_TABLE_DEPTH) for (const line of flattenNestedTable(child, {
204
+ depth: depth + 1,
205
+ budget
206
+ })) lines.push(line);
176
207
  continue;
177
208
  }
178
209
  walk(child);
@@ -181,10 +212,21 @@ const readCellRenderedLines = (cell, depth) => {
181
212
  walk(cell);
182
213
  return lines;
183
214
  };
184
- const flattenNestedTable = (table, depth) => {
215
+ const flattenNestedTable = (table, { depth, budget }) => {
185
216
  const lines = [];
186
- for (const row of collectTableParts(table, "tr", MAX_TABLE_ROWS)) {
187
- const cells = collectTableParts(row, "tc", MAX_TABLE_COLUMNS).map((cell) => readCellRenderedLines(cell, depth).join("\n"));
217
+ for (const row of collectTableParts({
218
+ parent: table,
219
+ localName: "tr",
220
+ limit: MAX_TABLE_ROWS
221
+ })) {
222
+ const cells = collectTableParts({
223
+ parent: row,
224
+ localName: "tc",
225
+ limit: MAX_TABLE_COLUMNS
226
+ }).map((cell) => readCellRenderedLines(cell, {
227
+ depth,
228
+ budget
229
+ }).join("\n"));
188
230
  if (cells.some((text) => text.length > 0)) lines.push(cells.join(NESTED_TABLE_CELL_SEPARATOR));
189
231
  }
190
232
  return lines;
@@ -194,7 +236,7 @@ const emptyTableCell = () => ({
194
236
  paragraphs: [],
195
237
  gridSpan: 1
196
238
  });
197
- const readTableCell = (cell, depth) => {
239
+ const readTableCell = (cell, { depth, budget }) => {
198
240
  const properties = findWordChild(cell, "tcPr");
199
241
  const gridSpanValue = getWordAttribute(findWordChild(properties, "gridSpan"), "val");
200
242
  const parsedGridSpan = gridSpanValue === null ? 1 : Number.parseInt(gridSpanValue, 10);
@@ -205,10 +247,12 @@ const readTableCell = (cell, depth) => {
205
247
  paragraphs: [],
206
248
  gridSpan
207
249
  };
208
- const paragraphs = readCellSourceParagraphs(cell, depth);
209
250
  return {
210
- text: readCellRenderedLines(cell, depth).join("\n"),
211
- paragraphs,
251
+ text: readCellRenderedLines(cell, {
252
+ depth,
253
+ budget
254
+ }).join("\n"),
255
+ paragraphs: readCellSourceParagraphs(cell, depth),
212
256
  gridSpan
213
257
  };
214
258
  };
@@ -235,18 +279,29 @@ const readRowGridOffset = (row, localName) => {
235
279
  const parsed = value === null ? 0 : Number.parseInt(value, 10);
236
280
  return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, MAX_TABLE_COLUMNS) : 0;
237
281
  };
238
- const readTableGrid = (table) => {
282
+ const readTableGrid = (table, budget) => {
239
283
  const rows = [];
240
284
  let columnCount = 0;
241
285
  let firstRowIsHeader = false;
242
- for (const [rowIndex, row] of collectTableParts(table, "tr", MAX_TABLE_ROWS).entries()) {
286
+ for (const [rowIndex, row] of collectTableParts({
287
+ parent: table,
288
+ localName: "tr",
289
+ limit: MAX_TABLE_ROWS
290
+ }).entries()) {
243
291
  if (rowIndex === 0) firstRowIsHeader = declaresHeaderRow(row);
244
292
  const columns = [];
245
293
  const gridBefore = readRowGridOffset(row, "gridBefore");
246
294
  for (let index = 0; index < gridBefore; index += 1) columns.push(emptyTableCell());
247
- for (const cell of collectTableParts(row, "tc", MAX_TABLE_COLUMNS)) {
295
+ for (const cell of collectTableParts({
296
+ parent: row,
297
+ localName: "tc",
298
+ limit: MAX_TABLE_COLUMNS
299
+ })) {
248
300
  if (columns.length >= MAX_TABLE_COLUMNS) break;
249
- const extractedCell = readTableCell(cell, 0);
301
+ const extractedCell = readTableCell(cell, {
302
+ depth: 0,
303
+ budget
304
+ });
250
305
  columns.push(extractedCell);
251
306
  const padding = Math.min(extractedCell.gridSpan - 1, MAX_TABLE_COLUMNS - columns.length);
252
307
  for (let index = 0; index < padding; index++) columns.push(emptyTableCell());
@@ -269,12 +324,17 @@ const toRowLine = (columns, columnCount) => {
269
324
  return `| ${cells.join(" | ")} |`;
270
325
  };
271
326
  /** Render a `w:tbl` as GFM rows. A table with no cell at all renders nothing. */
272
- const renderTableRows = (table, tableIndex) => {
273
- const { rows, columnCount, firstRowIsHeader } = readTableGrid(table);
327
+ const renderTableRows = ({ table, tableIndex, budget }) => {
328
+ const { rows, columnCount, firstRowIsHeader } = readTableGrid(table, budget);
274
329
  const [firstRow, ...remainingRows] = rows;
275
330
  if (columnCount === 0 || firstRow === void 0) return [];
276
331
  const rendered = [];
277
332
  const pushScaffolding = (text, kind) => {
333
+ chargeTableCharacters({
334
+ budget,
335
+ characters: text.length,
336
+ kind: "rendered"
337
+ });
278
338
  rendered.push({
279
339
  text,
280
340
  position: {
@@ -284,8 +344,14 @@ const renderTableRows = (table, tableIndex) => {
284
344
  });
285
345
  };
286
346
  const pushCells = (cells) => {
347
+ const text = toRowLine(cells, columnCount);
348
+ chargeTableCharacters({
349
+ budget,
350
+ characters: text.length,
351
+ kind: "rendered"
352
+ });
287
353
  rendered.push({
288
- text: toRowLine(cells, columnCount),
354
+ text,
289
355
  position: {
290
356
  table: tableIndex,
291
357
  kind: "cells",
@@ -304,7 +370,7 @@ const renderTableRows = (table, tableIndex) => {
304
370
  return rendered;
305
371
  };
306
372
  const createCharBudget = () => ({ remaining: MAX_EXTRACTED_CHARS });
307
- const extractContainer = ({ container, source, startIndex, startTableIndex, budget }) => {
373
+ const extractContainer = ({ container, source, startIndex, startTableIndex, budget, tableBudget }) => {
308
374
  const paragraphs = [];
309
375
  let charCount = 0;
310
376
  let tableCount = 0;
@@ -341,7 +407,11 @@ const extractContainer = ({ container, source, startIndex, startTableIndex, budg
341
407
  if (budget.remaining <= 0) return;
342
408
  const childName = wordElementName(child);
343
409
  if (childName === "tbl") {
344
- for (const row of renderTableRows(child, startTableIndex + tableCount)) pushTableRow(row);
410
+ for (const row of renderTableRows({
411
+ table: child,
412
+ tableIndex: startTableIndex + tableCount,
413
+ budget: tableBudget
414
+ })) pushTableRow(row);
345
415
  tableCount += 1;
346
416
  continue;
347
417
  }
@@ -356,7 +426,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex, budg
356
426
  tableCount
357
427
  };
358
428
  };
359
- const extractParts = async ({ archive, source, rootName, startIndex, startTableIndex, paths, budget }) => {
429
+ const extractParts = async ({ archive, source, rootName, startIndex, startTableIndex, paths, budget, tableBudget }) => {
360
430
  const paragraphs = [];
361
431
  let charCount = 0;
362
432
  let tableCount = 0;
@@ -371,7 +441,8 @@ const extractParts = async ({ archive, source, rootName, startIndex, startTableI
371
441
  source,
372
442
  startIndex: nextIndex,
373
443
  startTableIndex: startTableIndex + tableCount,
374
- budget
444
+ budget,
445
+ tableBudget
375
446
  });
376
447
  for (const paragraph of result.paragraphs) paragraphs.push(paragraph);
377
448
  charCount += result.charCount;
@@ -443,6 +514,7 @@ const extractDocxText = async (bytes) => {
443
514
  if (!body) return createEmptyResult();
444
515
  const referencedParts = await resolveReferencedHeaderFooterParts(archive, root);
445
516
  const budget = createCharBudget();
517
+ const tableBudget = createTableExtractionBudget();
446
518
  const headers = await extractParts({
447
519
  archive,
448
520
  source: "header",
@@ -450,14 +522,16 @@ const extractDocxText = async (bytes) => {
450
522
  startIndex: 0,
451
523
  startTableIndex: 0,
452
524
  paths: referencedParts.headers,
453
- budget
525
+ budget,
526
+ tableBudget
454
527
  });
455
528
  const bodyResult = extractContainer({
456
529
  container: body,
457
530
  source: "body",
458
531
  startIndex: headers.paragraphs.length,
459
532
  startTableIndex: headers.tableCount,
460
- budget
533
+ budget,
534
+ tableBudget
461
535
  });
462
536
  const footers = await extractParts({
463
537
  archive,
@@ -466,7 +540,8 @@ const extractDocxText = async (bytes) => {
466
540
  startIndex: headers.paragraphs.length + bodyResult.paragraphs.length,
467
541
  startTableIndex: headers.tableCount + bodyResult.tableCount,
468
542
  paths: referencedParts.footers,
469
- budget
543
+ budget,
544
+ tableBudget
470
545
  });
471
546
  return {
472
547
  paragraphs: [
@@ -0,0 +1,31 @@
1
+ //#region src/docx/server/materializeYjsDocx.d.ts
2
+ /** Yjs fragment that stores Folio's canonical ProseMirror document. */
3
+ declare const FOLIO_YJS_PROSEMIRROR_FRAGMENT_NAME = "prosemirror";
4
+ /** Maximum accepted size of a complete Yjs collaboration state update. */
5
+ declare const FOLIO_YJS_UPDATE_MAX_BYTES: number;
6
+ /** Stable failure codes returned by server-side Yjs-to-DOCX materialization. */
7
+ declare const FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES: readonly ["empty_update", "invalid_update", "missing_document", "update_too_large"];
8
+ /** Failure code for a rejected Yjs-to-DOCX materialization request. */
9
+ type FolioYjsDocxMaterializationErrorCode = (typeof FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES)[number];
10
+ declare const FolioYjsDocxMaterializationError_base: import("better-result").TaggedErrorClass<"FolioYjsDocxMaterializationError">;
11
+ /** Typed failure raised when a collaboration snapshot cannot be materialized. */
12
+ declare class FolioYjsDocxMaterializationError extends FolioYjsDocxMaterializationError_base<{
13
+ code: FolioYjsDocxMaterializationErrorCode;
14
+ message: string;
15
+ cause?: unknown;
16
+ }> {}
17
+ /** Inputs for materializing a complete Yjs state update into a DOCX package. */
18
+ type MaterializeYjsDocxOptions = {
19
+ /** Original DOCX whose package parts and non-body stories must be preserved. */
20
+ sourceDocx: ArrayBuffer | Uint8Array;
21
+ /** Complete Yjs state update containing Folio's ProseMirror fragment. */
22
+ yjsUpdate: Uint8Array;
23
+ };
24
+ /**
25
+ * Materialize a complete Folio Yjs state update into a DOCX while preserving
26
+ * package parts from the source document. This is the server-side equivalent
27
+ * of the browser editor's full save path for the main document story.
28
+ */
29
+ declare const materializeYjsDocx: ({ sourceDocx, yjsUpdate }: MaterializeYjsDocxOptions) => Promise<ArrayBuffer>;
30
+ //#endregion
31
+ export { FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES, FOLIO_YJS_PROSEMIRROR_FRAGMENT_NAME, FOLIO_YJS_UPDATE_MAX_BYTES, FolioYjsDocxMaterializationError, FolioYjsDocxMaterializationErrorCode, MaterializeYjsDocxOptions, materializeYjsDocx };
@@ -0,0 +1,61 @@
1
+ import { fromProseDoc } from "../../prosemirror/conversion/fromProseDoc.js";
2
+ import { schema } from "../../prosemirror/schema/index.js";
3
+ import { parseDocx } from "../parser.js";
4
+ import { repackDocx } from "../rezip.js";
5
+ import { Result, TaggedError } from "better-result";
6
+ import { initProseMirrorDoc } from "y-prosemirror";
7
+ import * as Y from "yjs";
8
+ //#region src/docx/server/materializeYjsDocx.ts
9
+ /** Yjs fragment that stores Folio's canonical ProseMirror document. */
10
+ const FOLIO_YJS_PROSEMIRROR_FRAGMENT_NAME = "prosemirror";
11
+ /** Maximum accepted size of a complete Yjs collaboration state update. */
12
+ const FOLIO_YJS_UPDATE_MAX_BYTES = 10 * 1024 * 1024;
13
+ /** Stable failure codes returned by server-side Yjs-to-DOCX materialization. */
14
+ const FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES = [
15
+ "empty_update",
16
+ "invalid_update",
17
+ "missing_document",
18
+ "update_too_large"
19
+ ];
20
+ /** Typed failure raised when a collaboration snapshot cannot be materialized. */
21
+ var FolioYjsDocxMaterializationError = class extends TaggedError("FolioYjsDocxMaterializationError") {};
22
+ const readProseMirrorDocument = (yjsUpdate) => {
23
+ if (yjsUpdate.byteLength === 0) throw new FolioYjsDocxMaterializationError({
24
+ code: "empty_update",
25
+ message: "Cannot materialize DOCX from an empty Yjs update."
26
+ });
27
+ if (yjsUpdate.byteLength > 10485760) throw new FolioYjsDocxMaterializationError({
28
+ code: "update_too_large",
29
+ message: "Yjs update exceeds the DOCX materialization limit."
30
+ });
31
+ const ydoc = new Y.Doc();
32
+ const parsed = Result.try({
33
+ try: () => {
34
+ Y.applyUpdate(ydoc, yjsUpdate);
35
+ const fragment = ydoc.getXmlFragment(FOLIO_YJS_PROSEMIRROR_FRAGMENT_NAME);
36
+ if (fragment.length === 0) throw new FolioYjsDocxMaterializationError({
37
+ code: "missing_document",
38
+ message: "Yjs update does not contain a Folio document."
39
+ });
40
+ return initProseMirrorDoc(fragment, schema).doc;
41
+ },
42
+ catch: (cause) => cause instanceof FolioYjsDocxMaterializationError ? cause : new FolioYjsDocxMaterializationError({
43
+ code: "invalid_update",
44
+ message: "Yjs update is not a valid Folio collaboration snapshot.",
45
+ cause
46
+ })
47
+ });
48
+ ydoc.destroy();
49
+ if (parsed.isOk()) return parsed.value;
50
+ throw parsed.error;
51
+ };
52
+ /**
53
+ * Materialize a complete Folio Yjs state update into a DOCX while preserving
54
+ * package parts from the source document. This is the server-side equivalent
55
+ * of the browser editor's full save path for the main document story.
56
+ */
57
+ const materializeYjsDocx = async ({ sourceDocx, yjsUpdate }) => {
58
+ return await repackDocx(fromProseDoc(readProseMirrorDocument(yjsUpdate), await parseDocx(sourceDocx, { preloadFonts: false })));
59
+ };
60
+ //#endregion
61
+ export { FOLIO_YJS_DOCX_MATERIALIZATION_ERROR_CODES, FOLIO_YJS_PROSEMIRROR_FRAGMENT_NAME, FOLIO_YJS_UPDATE_MAX_BYTES, FolioYjsDocxMaterializationError, materializeYjsDocx };
@@ -1,23 +1,24 @@
1
1
  import { isValidHexColor } from "../utils/colorResolver.js";
2
2
  import { mergeParagraphFormatting } from "../utils/paragraphFormattingMerge.js";
3
- import { mergeTextFormatting } from "../utils/textFormattingMerge.js";
3
+ import { mergeStyleTextFormatting } from "../utils/textFormattingMerge.js";
4
4
  import { BorderStyleSchema, ConditionalStyleTypeSchema, EmphasisMarkSchema, FontHintSchema, FontThemeSchema, HighlightColorSchema, LineSpacingRuleSchema, ParagraphAlignmentSchema, ShadingPatternSchema, StyleTypeSchema, TabLeaderSchema, TabStopAlignmentSchema, TableCellTextDirectionSchema, TableRowHeightRuleSchema, TableWidthTypeSchema, TextEffectSchema, ThemeColorSlotSchema, UnderlineStyleSchema, narrowEnum } from "./parserEnums.js";
5
5
  import { resolveThemeFontRef } from "./themeParser.js";
6
6
  import { findChild, findChildren, getAttribute, getLocalName, parseBooleanElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXmlDocument } from "./xmlParser.js";
7
7
  //#region src/docx/styleParser.ts
8
+ const findLastRunToggle = (rPr, localName) => findChildren(rPr, "w", localName).at(-1) ?? null;
8
9
  /**
9
10
  * Parse text formatting properties (w:rPr)
10
11
  */
11
12
  function parseRunProperties(rPr, theme) {
12
13
  if (!rPr) return;
13
14
  const formatting = {};
14
- const b = findChild(rPr, "w", "b");
15
+ const b = findLastRunToggle(rPr, "b");
15
16
  if (b) formatting.bold = parseBooleanElement(b);
16
- const bCs = findChild(rPr, "w", "bCs");
17
+ const bCs = findLastRunToggle(rPr, "bCs");
17
18
  if (bCs) formatting.boldCs = parseBooleanElement(bCs);
18
- const i = findChild(rPr, "w", "i");
19
+ const i = findLastRunToggle(rPr, "i");
19
20
  if (i) formatting.italic = parseBooleanElement(i);
20
- const iCs = findChild(rPr, "w", "iCs");
21
+ const iCs = findLastRunToggle(rPr, "iCs");
21
22
  if (iCs) formatting.italicCs = parseBooleanElement(iCs);
22
23
  const u = findChild(rPr, "w", "u");
23
24
  if (u) {
@@ -29,7 +30,7 @@ function parseRunProperties(rPr, theme) {
29
30
  if (colorVal || themeColor) formatting.underline.color = parseColorValue(colorVal, themeColor, getAttribute(u, "w", "themeTint"), getAttribute(u, "w", "themeShade"));
30
31
  }
31
32
  }
32
- const strike = findChild(rPr, "w", "strike");
33
+ const strike = findLastRunToggle(rPr, "strike");
33
34
  if (strike) formatting.strike = parseBooleanElement(strike);
34
35
  const dstrike = findChild(rPr, "w", "dstrike");
35
36
  if (dstrike) formatting.doubleStrike = parseBooleanElement(dstrike);
@@ -38,11 +39,11 @@ function parseRunProperties(rPr, theme) {
38
39
  const val = getAttribute(vertAlign, "w", "val");
39
40
  if (val === "superscript" || val === "subscript" || val === "baseline") formatting.vertAlign = val;
40
41
  }
41
- const smallCaps = findChild(rPr, "w", "smallCaps");
42
+ const smallCaps = findLastRunToggle(rPr, "smallCaps");
42
43
  if (smallCaps) formatting.smallCaps = parseBooleanElement(smallCaps);
43
- const caps = findChild(rPr, "w", "caps");
44
+ const caps = findLastRunToggle(rPr, "caps");
44
45
  if (caps) formatting.allCaps = parseBooleanElement(caps);
45
- const vanish = findChild(rPr, "w", "vanish");
46
+ const vanish = findLastRunToggle(rPr, "vanish");
46
47
  if (vanish) formatting.hidden = parseBooleanElement(vanish);
47
48
  const color = findChild(rPr, "w", "color");
48
49
  if (color) formatting.color = parseColorValue(getAttribute(color, "w", "val"), getAttribute(color, "w", "themeColor"), getAttribute(color, "w", "themeTint"), getAttribute(color, "w", "themeShade"));
@@ -154,13 +155,13 @@ function parseRunProperties(rPr, theme) {
154
155
  const val = narrowEnum(getAttribute(em, "w", "val"), EmphasisMarkSchema);
155
156
  if (val) formatting.emphasisMark = val;
156
157
  }
157
- const emboss = findChild(rPr, "w", "emboss");
158
+ const emboss = findLastRunToggle(rPr, "emboss");
158
159
  if (emboss) formatting.emboss = parseBooleanElement(emboss);
159
- const imprint = findChild(rPr, "w", "imprint");
160
+ const imprint = findLastRunToggle(rPr, "imprint");
160
161
  if (imprint) formatting.imprint = parseBooleanElement(imprint);
161
- const outline = findChild(rPr, "w", "outline");
162
+ const outline = findLastRunToggle(rPr, "outline");
162
163
  if (outline) formatting.outline = parseBooleanElement(outline);
163
- const shadow = findChild(rPr, "w", "shadow");
164
+ const shadow = findLastRunToggle(rPr, "shadow");
164
165
  if (shadow) formatting.shadow = parseBooleanElement(shadow);
165
166
  const rtl = findChild(rPr, "w", "rtl");
166
167
  if (rtl) formatting.rtl = parseBooleanElement(rtl);
@@ -807,7 +808,7 @@ function resolveStyleInheritance(style, styleMap, visited = /* @__PURE__ */ new
807
808
  const resolved = { ...style };
808
809
  const mergedPPr = mergeParagraphFormatting(resolvedParent.pPr, style.pPr);
809
810
  if (mergedPPr) resolved.pPr = mergedPPr;
810
- const mergedRPr = mergeTextFormatting(resolvedParent.rPr, style.rPr);
811
+ const mergedRPr = mergeStyleTextFormatting(resolvedParent.rPr, style.rPr);
811
812
  if (mergedRPr) resolved.rPr = mergedRPr;
812
813
  if (style.type === "table") {
813
814
  if (resolvedParent.tblPr || style.tblPr) resolved.tblPr = {
@@ -6,10 +6,11 @@ import { setParagraphFrame } from "../../layout-engine/paragraphFrame.js";
6
6
  import { setTextBoxGroupId } from "../../layout-engine/textBoxGroup.js";
7
7
  import { DEFAULT_TEXTBOX_MARGINS } from "../../layout-engine/types.js";
8
8
  import { getPageNumbering } from "../../paged-layout/sectionGeometry.js";
9
- import { expectBlockSdtAttrs, expectCharacterSpacingMarkAttrs, expectCommentMarkAttrs, expectEmphasisMarkAttrs, expectFieldAttrs, expectFontFamilyMarkAttrs, expectFontSizeMarkAttrs, expectFootnoteRefMarkAttrs, expectHardBreakAttrs, expectHighlightMarkAttrs, expectHyperlinkMarkAttrs, expectImageAttrs, expectLanguageMarkAttrs, expectMathAttrs, expectParagraphAttrs, expectRunFormattingOverrideMarkAttrs, expectRunShadingMarkAttrs, expectSymbolAttrs, expectTableAttrs, expectTableCellAttrs, expectTableRowAttrs, expectTextBoxAttrs, expectTextColorMarkAttrs, expectTextEffectMarkAttrs, expectTrackedChangeMarkAttrs, expectUnderlineMarkAttrs } from "../../prosemirror/attrs/index.js";
9
+ import { expectBlockSdtAttrs, expectCharacterSpacingMarkAttrs, expectCharacterStyleMarkAttrs, expectCommentMarkAttrs, expectEmphasisMarkAttrs, expectFieldAttrs, expectFontFamilyMarkAttrs, expectFontSizeMarkAttrs, expectFootnoteRefMarkAttrs, expectHardBreakAttrs, expectHighlightMarkAttrs, expectHyperlinkMarkAttrs, expectImageAttrs, expectLanguageMarkAttrs, expectMathAttrs, expectParagraphAttrs, expectRunFormattingOverrideMarkAttrs, expectRunShadingMarkAttrs, expectSymbolAttrs, expectTableAttrs, expectTableCellAttrs, expectTableRowAttrs, expectTextBoxAttrs, expectTextColorMarkAttrs, expectTextEffectMarkAttrs, expectTrackedChangeMarkAttrs, expectUnderlineMarkAttrs } from "../../prosemirror/attrs/index.js";
10
10
  import { autospacingMatchesBase } from "../../prosemirror/autospacingBase.js";
11
11
  import { runShadingAttrsToShading } from "../../prosemirror/conversion/runShadingMark.js";
12
12
  import { directionToBidi } from "../../prosemirror/paragraphDirection.js";
13
+ import { cascadeStyleTextFormatting } from "../../prosemirror/styles/styleToggleCascade.js";
13
14
  import { assertValidProseMirrorDocument } from "../../prosemirror/validation.js";
14
15
  import { resolveColor, resolveHighlightToCss } from "../../utils/colorResolver.js";
15
16
  import { resolveThemeFont } from "../../utils/fontResolver.js";
@@ -348,17 +349,49 @@ function mergeRunFormatting(paraDefaults, formatting) {
348
349
  if (merged.letterSpacing === 0) delete merged.letterSpacing;
349
350
  return merged;
350
351
  }
352
+ /** Restore character-style toggle values that plain visual marks cannot represent. */
353
+ function applyCharacterStyleToggleFormatting({ formatting, marks, paraDefaults }) {
354
+ const characterStyleMark = marks.find((mark) => mark.type.name === "characterStyle");
355
+ if (!characterStyleMark) return;
356
+ const styleRPr = expectCharacterStyleMarkAttrs(characterStyleMark)._styleRPr;
357
+ if (!styleRPr) return;
358
+ const effectiveStyleFormatting = cascadeStyleTextFormatting([{
359
+ formatting: {
360
+ bold: paraDefaults.bold ?? false,
361
+ boldCs: paraDefaults.complexScriptBold ?? false,
362
+ italic: paraDefaults.italic ?? false,
363
+ italicCs: paraDefaults.complexScriptItalic ?? false
364
+ },
365
+ type: "direct"
366
+ }, {
367
+ formatting: styleRPr,
368
+ type: "style"
369
+ }]).formatting;
370
+ if (styleRPr.bold !== void 0 && formatting.bold === void 0) formatting.bold = effectiveStyleFormatting?.bold ?? false;
371
+ if (styleRPr.boldCs !== void 0 && formatting.complexScriptBold === void 0) formatting.complexScriptBold = effectiveStyleFormatting?.boldCs ?? false;
372
+ if (styleRPr.italic !== void 0 && formatting.italic === void 0) formatting.italic = effectiveStyleFormatting?.italic ?? false;
373
+ if (styleRPr.italicCs !== void 0 && formatting.complexScriptItalic === void 0) formatting.complexScriptItalic = effectiveStyleFormatting?.italicCs ?? false;
374
+ if (styleRPr.allCaps === true && formatting.allCaps === void 0) formatting.allCaps = false;
375
+ if (styleRPr.emboss === true && formatting.emboss === void 0) formatting.emboss = false;
376
+ if (styleRPr.imprint === true && formatting.imprint === void 0) formatting.imprint = false;
377
+ if (styleRPr.outline === true && formatting.textOutline === void 0) formatting.textOutline = false;
378
+ if (styleRPr.shadow === true && formatting.textShadow === void 0) formatting.textShadow = false;
379
+ if (styleRPr.smallCaps === true && formatting.smallCaps === void 0) formatting.smallCaps = false;
380
+ if (styleRPr.strike === true && formatting.strike === void 0) formatting.strike = false;
381
+ if (styleRPr.hidden === true && formatting.hidden === void 0) formatting.hidden = false;
382
+ }
351
383
  function applyRunFormattingOverrides(formatting, attrs) {
352
- if (attrs.bold === false) formatting.bold = false;
353
- if (attrs.italic === false) formatting.italic = false;
384
+ if (attrs.bold !== void 0) formatting.bold = attrs.bold;
385
+ if (attrs.italic !== void 0) formatting.italic = attrs.italic;
354
386
  if (attrs.underline === "none") formatting.underline = false;
355
- if (attrs.strike === false) formatting.strike = false;
356
- if (attrs.allCaps === false) formatting.allCaps = false;
357
- if (attrs.smallCaps === false) formatting.smallCaps = false;
358
- if (attrs.emboss === false) formatting.emboss = false;
359
- if (attrs.imprint === false) formatting.imprint = false;
360
- if (attrs.shadow === false) formatting.textShadow = false;
361
- if (attrs.outline === false) formatting.textOutline = false;
387
+ if (attrs.strike !== void 0) formatting.strike = attrs.strike;
388
+ if (attrs.allCaps !== void 0) formatting.allCaps = attrs.allCaps;
389
+ if (attrs.smallCaps !== void 0) formatting.smallCaps = attrs.smallCaps;
390
+ if (attrs.hidden !== void 0) formatting.hidden = attrs.hidden;
391
+ if (attrs.emboss !== void 0) formatting.emboss = attrs.emboss;
392
+ if (attrs.imprint !== void 0) formatting.imprint = attrs.imprint;
393
+ if (attrs.shadow !== void 0) formatting.textShadow = attrs.shadow;
394
+ if (attrs.outline !== void 0) formatting.textOutline = attrs.outline;
362
395
  if (attrs.rtl === false) formatting.rtl = false;
363
396
  if (attrs.boldCs !== void 0) formatting.complexScriptBold = attrs.boldCs;
364
397
  if (attrs.italicCs !== void 0) formatting.complexScriptItalic = attrs.italicCs;
@@ -497,6 +530,11 @@ function paragraphToRuns(node, startPos, _options) {
497
530
  if (child.type.name !== "sdt") leadingRenderedPageBreakPending = false;
498
531
  if (child.isText && child.text) {
499
532
  const formatting = extractRunFormatting(child.marks, theme);
533
+ applyCharacterStyleToggleFormatting({
534
+ formatting,
535
+ marks: child.marks,
536
+ paraDefaults
537
+ });
500
538
  if (inTocParagraph) stripTocHyperlinkStyle(formatting);
501
539
  const run = {
502
540
  kind: "text",
@@ -513,6 +551,11 @@ function paragraphToRuns(node, startPos, _options) {
513
551
  const text = decodeOoxmlSymbolCharacter(attrs.char);
514
552
  if (text === null) return;
515
553
  const formatting = extractRunFormatting(child.marks, theme);
554
+ applyCharacterStyleToggleFormatting({
555
+ formatting,
556
+ marks: child.marks,
557
+ paraDefaults
558
+ });
516
559
  if (inTocParagraph) stripTocHyperlinkStyle(formatting);
517
560
  runs.push({
518
561
  kind: "text",
@@ -534,6 +577,11 @@ function paragraphToRuns(node, startPos, _options) {
534
577
  }
535
578
  if (child.type.name === "tab") {
536
579
  const formatting = extractRunFormatting(child.marks, theme);
580
+ applyCharacterStyleToggleFormatting({
581
+ formatting,
582
+ marks: child.marks,
583
+ paraDefaults
584
+ });
537
585
  const run = {
538
586
  kind: "tab",
539
587
  ...mergeRunFormatting(paraDefaults, formatting),
@@ -561,6 +609,11 @@ function paragraphToRuns(node, startPos, _options) {
561
609
  else if (ft === "DATE") mappedType = "DATE";
562
610
  else if (ft === "TIME") mappedType = "TIME";
563
611
  const extractedFieldFormatting = extractRunFormatting(child.marks, theme);
612
+ applyCharacterStyleToggleFormatting({
613
+ formatting: extractedFieldFormatting,
614
+ marks: child.marks,
615
+ paraDefaults
616
+ });
564
617
  if (inTocParagraph) stripTocHyperlinkStyle(extractedFieldFormatting);
565
618
  const fieldFormatting = markDefaultBlackTextColorSource(extractedFieldFormatting, paraDefaults);
566
619
  const run = {
@@ -902,27 +955,25 @@ function convertParagraph(node, startPos, options) {
902
955
  return block;
903
956
  }
904
957
  /**
905
- * Word keeps terminal empty body paragraphs after a final table as editable
906
- * anchors, but they do not create a page of their own. Preserve every block
907
- * and PM range while collapsing only the contiguous, run-free suffix; empty
908
- * paragraphs elsewhere still retain their normal line height.
958
+ * Word keeps a final empty body paragraph after a table as an editable anchor,
959
+ * but that final anchor does not create a page of its own. Earlier authored
960
+ * empty paragraphs retain their height and may carry the document onto a blank
961
+ * page. Preserve every block and PM range while collapsing only the final one.
909
962
  */
910
963
  function isPaintlessTerminalParagraph(block) {
911
964
  if (block?.kind !== "paragraph" || block.runs.length !== 0) return false;
912
965
  const attrs = block.attrs;
913
966
  return !(attrs?.listMarker !== void 0 && !attrs.listMarkerHidden || attrs?.borders?.top || attrs?.borders?.bottom || attrs?.borders?.left || attrs?.borders?.right || attrs?.borders?.between || attrs?.borders?.bar || attrs?.shading || attrs?.spacingExplicit?.before || attrs?.spacingExplicit?.after || attrs?.pageBreakBefore || attrs?.renderedPageBreakBefore);
914
967
  }
915
- function suppressTerminalEmptyParagraphsAfterTable(blocks) {
968
+ function suppressFinalEmptyParagraphAfterTable(blocks) {
916
969
  let suffixStart = blocks.length;
917
970
  while (suffixStart > 0 && isPaintlessTerminalParagraph(blocks[suffixStart - 1])) suffixStart -= 1;
918
971
  if (suffixStart === blocks.length || suffixStart === 0 || blocks[suffixStart - 1]?.kind !== "table") return;
919
- for (let index = suffixStart; index < blocks.length; index += 1) {
920
- const block = blocks[index];
921
- if (isPaintlessTerminalParagraph(block)) block.attrs = {
922
- ...block.attrs,
923
- suppressEmptyParagraphHeight: true
924
- };
925
- }
972
+ const finalBlock = blocks.at(-1);
973
+ if (isPaintlessTerminalParagraph(finalBlock)) finalBlock.attrs = {
974
+ ...finalBlock.attrs,
975
+ suppressEmptyParagraphHeight: true
976
+ };
926
977
  }
927
978
  function suppressFinalParagraphInRepeatedEmptySuffix(blocks) {
928
979
  let suffixStart = blocks.length;
@@ -1477,7 +1528,7 @@ function toFlowBlocks(doc, options = {}) {
1477
1528
  visit(node, offset + nodeOffset);
1478
1529
  });
1479
1530
  reserveLeadingEmptyOutlineHeight(blocks);
1480
- suppressTerminalEmptyParagraphsAfterTable(blocks);
1531
+ suppressFinalEmptyParagraphAfterTable(blocks);
1481
1532
  suppressFinalParagraphInRepeatedEmptySuffix(blocks);
1482
1533
  return groupParagraphFrames(applySectionDocumentGrid(mergeRunInParagraphs(blocks), opts.finalSectionDocumentGridLinePitchTwips), nextBlockId);
1483
1534
  }
@@ -0,0 +1,15 @@
1
+ import { Layout } from "./types.js";
2
+ //#region src/layout-engine/footnoteColumnReflow.d.ts
3
+ type ReflowFootnoteColumnsOptions = {
4
+ initialLayout: Layout;
5
+ initialReserveFloors?: ReadonlyMap<number, number>;
6
+ runLayout: (reserveFloors: Map<number, number>) => Layout;
7
+ };
8
+ /**
9
+ * Re-run a multi-column layout until its body flow clears the shared footnote band.
10
+ * Reserve floors follow the current page-to-footnote assignment. Repeated floor states
11
+ * and persistent overlap use a stable fallback layout instead of throwing.
12
+ */
13
+ declare function reflowFootnoteColumns({ initialLayout, initialReserveFloors, runLayout }: ReflowFootnoteColumnsOptions): Layout;
14
+ //#endregion
15
+ export { reflowFootnoteColumns };