@stll/folio-core 0.27.0 → 0.28.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.
@@ -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;
@@ -103,18 +104,42 @@ const NESTED_TABLE_CELL_SEPARATOR = " / ";
103
104
  const MAX_TABLE_COLUMNS = 256;
104
105
  /** Bound the mutual recursion between a cell and the tables nested inside it. */
105
106
  const MAX_NESTED_TABLE_DEPTH = 8;
107
+ /** Rows collected from one `w:tbl`. The column cap alone leaves row count unbounded. */
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
+ };
106
126
  /**
107
- * Collect a table's `w:tr`, or a row's `w:tc`, seeing through the wrappers Word
108
- * puts around them (`w:sdt` / `w:sdtContent` content controls, `w:customXml`).
109
- * The walk stops at `w:tbl` and `w:p` so a nested table's rows and cells never
110
- * leak into the grid of the table that contains them.
127
+ * Characters one extraction emits, shared by the body and every header/footer
128
+ * part. Element count is bounded at unzip, but a bounded element count still
129
+ * renders an unbounded number of table rows once `w:gridSpan` padding and the
130
+ * GFM scaffolding are counted, so the emitted side carries its own ceiling.
111
131
  */
112
- const collectTableParts = (parent, localName) => {
132
+ const MAX_EXTRACTED_CHARS = 8e6;
133
+ const collectTableParts = ({ parent, localName, limit }) => {
113
134
  const parts = [];
114
135
  const walk = (node) => {
115
136
  for (const child of childElements(node)) {
116
137
  const childName = wordElementName(child);
117
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
+ });
118
143
  parts.push(child);
119
144
  continue;
120
145
  }
@@ -142,7 +167,15 @@ const readCellSourceParagraphs = (cell, depth) => {
142
167
  }
143
168
  if (childName === "tbl") {
144
169
  if (depth >= MAX_NESTED_TABLE_DEPTH) continue;
145
- for (const row of collectTableParts(child, "tr")) for (const nestedCell of collectTableParts(row, "tc")) 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);
146
179
  continue;
147
180
  }
148
181
  walk(child);
@@ -151,18 +184,26 @@ const readCellSourceParagraphs = (cell, depth) => {
151
184
  walk(cell);
152
185
  return paragraphs;
153
186
  };
154
- const readCellRenderedLines = (cell, depth) => {
187
+ const readCellRenderedLines = (cell, { depth, budget }) => {
155
188
  const lines = [];
156
189
  const walk = (node) => {
157
190
  for (const child of childElements(node)) {
158
191
  const childName = wordElementName(child);
159
192
  if (childName === "p") {
160
193
  const text = collectText(child);
194
+ chargeTableCharacters({
195
+ budget,
196
+ characters: text.length,
197
+ kind: "source"
198
+ });
161
199
  if (text.length > 0) lines.push(text);
162
200
  continue;
163
201
  }
164
202
  if (childName === "tbl") {
165
- 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);
166
207
  continue;
167
208
  }
168
209
  walk(child);
@@ -171,10 +212,21 @@ const readCellRenderedLines = (cell, depth) => {
171
212
  walk(cell);
172
213
  return lines;
173
214
  };
174
- const flattenNestedTable = (table, depth) => {
215
+ const flattenNestedTable = (table, { depth, budget }) => {
175
216
  const lines = [];
176
- for (const row of collectTableParts(table, "tr")) {
177
- const cells = collectTableParts(row, "tc").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"));
178
230
  if (cells.some((text) => text.length > 0)) lines.push(cells.join(NESTED_TABLE_CELL_SEPARATOR));
179
231
  }
180
232
  return lines;
@@ -184,7 +236,7 @@ const emptyTableCell = () => ({
184
236
  paragraphs: [],
185
237
  gridSpan: 1
186
238
  });
187
- const readTableCell = (cell, depth) => {
239
+ const readTableCell = (cell, { depth, budget }) => {
188
240
  const properties = findWordChild(cell, "tcPr");
189
241
  const gridSpanValue = getWordAttribute(findWordChild(properties, "gridSpan"), "val");
190
242
  const parsedGridSpan = gridSpanValue === null ? 1 : Number.parseInt(gridSpanValue, 10);
@@ -195,10 +247,12 @@ const readTableCell = (cell, depth) => {
195
247
  paragraphs: [],
196
248
  gridSpan
197
249
  };
198
- const paragraphs = readCellSourceParagraphs(cell, depth);
199
250
  return {
200
- text: readCellRenderedLines(cell, depth).join("\n"),
201
- paragraphs,
251
+ text: readCellRenderedLines(cell, {
252
+ depth,
253
+ budget
254
+ }).join("\n"),
255
+ paragraphs: readCellSourceParagraphs(cell, depth),
202
256
  gridSpan
203
257
  };
204
258
  };
@@ -225,18 +279,29 @@ const readRowGridOffset = (row, localName) => {
225
279
  const parsed = value === null ? 0 : Number.parseInt(value, 10);
226
280
  return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, MAX_TABLE_COLUMNS) : 0;
227
281
  };
228
- const readTableGrid = (table) => {
282
+ const readTableGrid = (table, budget) => {
229
283
  const rows = [];
230
284
  let columnCount = 0;
231
285
  let firstRowIsHeader = false;
232
- for (const [rowIndex, row] of collectTableParts(table, "tr").entries()) {
286
+ for (const [rowIndex, row] of collectTableParts({
287
+ parent: table,
288
+ localName: "tr",
289
+ limit: MAX_TABLE_ROWS
290
+ }).entries()) {
233
291
  if (rowIndex === 0) firstRowIsHeader = declaresHeaderRow(row);
234
292
  const columns = [];
235
293
  const gridBefore = readRowGridOffset(row, "gridBefore");
236
294
  for (let index = 0; index < gridBefore; index += 1) columns.push(emptyTableCell());
237
- for (const cell of collectTableParts(row, "tc")) {
295
+ for (const cell of collectTableParts({
296
+ parent: row,
297
+ localName: "tc",
298
+ limit: MAX_TABLE_COLUMNS
299
+ })) {
238
300
  if (columns.length >= MAX_TABLE_COLUMNS) break;
239
- const extractedCell = readTableCell(cell, 0);
301
+ const extractedCell = readTableCell(cell, {
302
+ depth: 0,
303
+ budget
304
+ });
240
305
  columns.push(extractedCell);
241
306
  const padding = Math.min(extractedCell.gridSpan - 1, MAX_TABLE_COLUMNS - columns.length);
242
307
  for (let index = 0; index < padding; index++) columns.push(emptyTableCell());
@@ -259,12 +324,17 @@ const toRowLine = (columns, columnCount) => {
259
324
  return `| ${cells.join(" | ")} |`;
260
325
  };
261
326
  /** Render a `w:tbl` as GFM rows. A table with no cell at all renders nothing. */
262
- const renderTableRows = (table, tableIndex) => {
263
- const { rows, columnCount, firstRowIsHeader } = readTableGrid(table);
327
+ const renderTableRows = ({ table, tableIndex, budget }) => {
328
+ const { rows, columnCount, firstRowIsHeader } = readTableGrid(table, budget);
264
329
  const [firstRow, ...remainingRows] = rows;
265
330
  if (columnCount === 0 || firstRow === void 0) return [];
266
331
  const rendered = [];
267
332
  const pushScaffolding = (text, kind) => {
333
+ chargeTableCharacters({
334
+ budget,
335
+ characters: text.length,
336
+ kind: "rendered"
337
+ });
268
338
  rendered.push({
269
339
  text,
270
340
  position: {
@@ -274,8 +344,14 @@ const renderTableRows = (table, tableIndex) => {
274
344
  });
275
345
  };
276
346
  const pushCells = (cells) => {
347
+ const text = toRowLine(cells, columnCount);
348
+ chargeTableCharacters({
349
+ budget,
350
+ characters: text.length,
351
+ kind: "rendered"
352
+ });
277
353
  rendered.push({
278
- text: toRowLine(cells, columnCount),
354
+ text,
279
355
  position: {
280
356
  table: tableIndex,
281
357
  kind: "cells",
@@ -293,7 +369,8 @@ const renderTableRows = (table, tableIndex) => {
293
369
  for (const row of firstRowIsHeader ? remainingRows : rows) pushCells(row);
294
370
  return rendered;
295
371
  };
296
- const extractContainer = ({ container, source, startIndex, startTableIndex }) => {
372
+ const createCharBudget = () => ({ remaining: MAX_EXTRACTED_CHARS });
373
+ const extractContainer = ({ container, source, startIndex, startTableIndex, budget, tableBudget }) => {
297
374
  const paragraphs = [];
298
375
  let charCount = 0;
299
376
  let tableCount = 0;
@@ -307,6 +384,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
307
384
  };
308
385
  paragraphs.push(entry);
309
386
  charCount += text.length;
387
+ budget.remaining -= text.length;
310
388
  };
311
389
  const pushTableRow = ({ text, position }) => {
312
390
  paragraphs.push({
@@ -316,6 +394,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
316
394
  tableRow: position
317
395
  });
318
396
  charCount += text.length;
397
+ budget.remaining -= text.length;
319
398
  };
320
399
  /**
321
400
  * Walk block content in document order. Descent mirrors the previous
@@ -325,9 +404,14 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
325
404
  */
326
405
  const walkBlocks = (node) => {
327
406
  for (const child of childElements(node)) {
407
+ if (budget.remaining <= 0) return;
328
408
  const childName = wordElementName(child);
329
409
  if (childName === "tbl") {
330
- 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);
331
415
  tableCount += 1;
332
416
  continue;
333
417
  }
@@ -342,7 +426,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
342
426
  tableCount
343
427
  };
344
428
  };
345
- const extractParts = async ({ archive, source, rootName, startIndex, startTableIndex, paths }) => {
429
+ const extractParts = async ({ archive, source, rootName, startIndex, startTableIndex, paths, budget, tableBudget }) => {
346
430
  const paragraphs = [];
347
431
  let charCount = 0;
348
432
  let tableCount = 0;
@@ -356,7 +440,9 @@ const extractParts = async ({ archive, source, rootName, startIndex, startTableI
356
440
  container,
357
441
  source,
358
442
  startIndex: nextIndex,
359
- startTableIndex: startTableIndex + tableCount
443
+ startTableIndex: startTableIndex + tableCount,
444
+ budget,
445
+ tableBudget
360
446
  });
361
447
  for (const paragraph of result.paragraphs) paragraphs.push(paragraph);
362
448
  charCount += result.charCount;
@@ -427,19 +513,25 @@ const extractDocxText = async (bytes) => {
427
513
  const body = findDeep(root, "w", "body");
428
514
  if (!body) return createEmptyResult();
429
515
  const referencedParts = await resolveReferencedHeaderFooterParts(archive, root);
516
+ const budget = createCharBudget();
517
+ const tableBudget = createTableExtractionBudget();
430
518
  const headers = await extractParts({
431
519
  archive,
432
520
  source: "header",
433
521
  rootName: "hdr",
434
522
  startIndex: 0,
435
523
  startTableIndex: 0,
436
- paths: referencedParts.headers
524
+ paths: referencedParts.headers,
525
+ budget,
526
+ tableBudget
437
527
  });
438
528
  const bodyResult = extractContainer({
439
529
  container: body,
440
530
  source: "body",
441
531
  startIndex: headers.paragraphs.length,
442
- startTableIndex: headers.tableCount
532
+ startTableIndex: headers.tableCount,
533
+ budget,
534
+ tableBudget
443
535
  });
444
536
  const footers = await extractParts({
445
537
  archive,
@@ -447,7 +539,9 @@ const extractDocxText = async (bytes) => {
447
539
  rootName: "ftr",
448
540
  startIndex: headers.paragraphs.length + bodyResult.paragraphs.length,
449
541
  startTableIndex: headers.tableCount + bodyResult.tableCount,
450
- paths: referencedParts.footers
542
+ paths: referencedParts.footers,
543
+ budget,
544
+ tableBudget
451
545
  });
452
546
  return {
453
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,3 +1,4 @@
1
+ import { isValidHexColor } from "../utils/colorResolver.js";
1
2
  import { mergeParagraphFormatting } from "../utils/paragraphFormattingMerge.js";
2
3
  import { mergeTextFormatting } from "../utils/textFormattingMerge.js";
3
4
  import { BorderStyleSchema, ConditionalStyleTypeSchema, EmphasisMarkSchema, FontHintSchema, FontThemeSchema, HighlightColorSchema, LineSpacingRuleSchema, ParagraphAlignmentSchema, ShadingPatternSchema, StyleTypeSchema, TabLeaderSchema, TabStopAlignmentSchema, TableCellTextDirectionSchema, TableRowHeightRuleSchema, TableWidthTypeSchema, TextEffectSchema, ThemeColorSlotSchema, UnderlineStyleSchema, narrowEnum } from "./parserEnums.js";
@@ -86,7 +87,7 @@ function parseRunProperties(rPr, theme) {
86
87
  if (resolved) fontFamily.ascii = resolved;
87
88
  }
88
89
  }
89
- const hAnsiTheme = getAttribute(rFonts, "w", "hAnsiTheme");
90
+ const hAnsiTheme = narrowEnum(getAttribute(rFonts, "w", "hAnsiTheme"), FontThemeSchema);
90
91
  if (hAnsiTheme) {
91
92
  fontFamily.hAnsiTheme = hAnsiTheme;
92
93
  if (theme && !fontFamily.hAnsi) {
@@ -94,7 +95,7 @@ function parseRunProperties(rPr, theme) {
94
95
  if (resolved) fontFamily.hAnsi = resolved;
95
96
  }
96
97
  }
97
- const eastAsiaTheme = getAttribute(rFonts, "w", "eastAsiaTheme");
98
+ const eastAsiaTheme = narrowEnum(getAttribute(rFonts, "w", "eastAsiaTheme"), FontThemeSchema);
98
99
  if (eastAsiaTheme) {
99
100
  fontFamily.eastAsiaTheme = eastAsiaTheme;
100
101
  if (theme && !fontFamily.eastAsia) {
@@ -102,7 +103,7 @@ function parseRunProperties(rPr, theme) {
102
103
  if (resolved) fontFamily.eastAsia = resolved;
103
104
  }
104
105
  }
105
- const csTheme = getAttribute(rFonts, "w", "cstheme");
106
+ const csTheme = narrowEnum(getAttribute(rFonts, "w", "cstheme"), FontThemeSchema);
106
107
  if (csTheme) {
107
108
  fontFamily.csTheme = csTheme;
108
109
  if (theme && !fontFamily.cs) {
@@ -192,9 +193,9 @@ function parseShadingProperties(shd) {
192
193
  if (!shd) return;
193
194
  const props = {};
194
195
  const color = getAttribute(shd, "w", "color");
195
- if (color && color !== "auto") props.color = { rgb: color };
196
+ if (color && color !== "auto" && isValidHexColor(color)) props.color = { rgb: color };
196
197
  const fill = getAttribute(shd, "w", "fill");
197
- if (fill && fill !== "auto") props.fill = { rgb: fill };
198
+ if (fill && fill !== "auto" && isValidHexColor(fill)) props.fill = { rgb: fill };
198
199
  const validatedThemeFill = narrowEnum(getAttribute(shd, "w", "themeFill"), ThemeColorSlotSchema);
199
200
  if (validatedThemeFill) {
200
201
  if (!props.fill) props.fill = {};
@@ -1,6 +1,6 @@
1
1
  import { DOCX_CONTAINER_TYPES, detectDocxContainerType } from "./encryption/containerFormat.js";
2
2
  import { openDocxBuffer } from "./encryption/openEncryptedDocx.js";
3
- import { FOLIO_XML_RESOURCE_LIMITS } from "./xmlResourceLimits.js";
3
+ import { FOLIO_XML_RESOURCE_LIMITS, assertXmlResourceLimits } from "./xmlResourceLimits.js";
4
4
  import JSZip from "jszip";
5
5
  //#region src/docx/unzip.ts
6
6
  /**
@@ -191,7 +191,7 @@ async function unzipDocx(buffer, options = {}) {
191
191
  for (const extracted of await Promise.all(extractionTasks.map((extract) => extract()))) {
192
192
  if (!extracted) continue;
193
193
  if (extracted.type === "xml") {
194
- assignXmlContent(content, extracted);
194
+ assignXmlContent(content, extracted, limits);
195
195
  continue;
196
196
  }
197
197
  if (extracted.type === "media") {
@@ -202,7 +202,21 @@ async function unzipDocx(buffer, options = {}) {
202
202
  }
203
203
  return content;
204
204
  }
205
- function assignXmlContent(content, { path, lowerPath, content: xmlContent }) {
205
+ /**
206
+ * Parts that every consumer expands into an object tree. Preflighting them once
207
+ * here puts the bound on the unzip, so `parseDocx`, the selective save and the
208
+ * repack path all share it instead of each entry point carrying its own.
209
+ */
210
+ const PREFLIGHT_XML_PARTS = /* @__PURE__ */ new Set([
211
+ "word/document.xml",
212
+ "word/styles.xml",
213
+ "word/numbering.xml"
214
+ ]);
215
+ function assignXmlContent(content, { path, lowerPath, content: xmlContent }, limits) {
216
+ if (PREFLIGHT_XML_PARTS.has(lowerPath)) assertXmlResourceLimits(xmlContent, {
217
+ ...FOLIO_XML_RESOURCE_LIMITS,
218
+ maxBytes: limits.maxXmlBytes
219
+ });
206
220
  content.allXml.set(path, xmlContent);
207
221
  if (lowerPath === "word/document.xml") content.documentXml = xmlContent;
208
222
  else if (lowerPath === "word/styles.xml") content.stylesXml = xmlContent;
@@ -610,6 +610,24 @@ function findAllDeep(root, namespace, localName) {
610
610
  */
611
611
  const MAX_XMLNS_DECLARATIONS_PER_ELEMENT = 64;
612
612
  /**
613
+ * Sanity cap on one declaration's value. Namespace URIs are short; a longer
614
+ * binding is dropped rather than replayed onto every captured subtree that
615
+ * inherits from the declaring element.
616
+ */
617
+ const MAX_XMLNS_VALUE_LENGTH = 512;
618
+ /**
619
+ * Sanity cap on an accumulated declaration set. Applied both when collecting one
620
+ * element's declarations and when merging down the ancestor chain, so every set
621
+ * this module produces or returns is bounded and a captured `w:pict` subtree
622
+ * replays at most this much regardless of how the chain was built.
623
+ */
624
+ const MAX_XMLNS_DECLARATION_CHARS = 8192;
625
+ const xmlnsDeclarationChars = (declarations) => {
626
+ let chars = 0;
627
+ for (const [name, value] of Object.entries(declarations)) chars += name.length + value.length;
628
+ return chars;
629
+ };
630
+ /**
613
631
  * Collect every `xmlns` / `xmlns:*` declaration from an element's attributes.
614
632
  *
615
633
  * The serializer's hard-coded root namespaces only cover canonical prefixes
@@ -623,13 +641,18 @@ function collectXmlnsDeclarations(element) {
623
641
  const attrs = element.attributes;
624
642
  if (!attrs) return out;
625
643
  let declarationCount = 0;
644
+ let declarationChars = 0;
626
645
  for (const key in attrs) {
627
646
  if (declarationCount >= MAX_XMLNS_DECLARATIONS_PER_ELEMENT) break;
628
647
  const value = attrs[key];
629
- if ((key === "xmlns" || key.startsWith("xmlns:")) && value !== void 0) {
630
- out[key] = String(value);
631
- declarationCount += 1;
632
- }
648
+ if (key !== "xmlns" && !key.startsWith("xmlns:")) continue;
649
+ if (value === void 0) continue;
650
+ const declaration = String(value);
651
+ if (declaration.length > MAX_XMLNS_VALUE_LENGTH) continue;
652
+ if (declarationChars + key.length + declaration.length > MAX_XMLNS_DECLARATION_CHARS) break;
653
+ out[key] = declaration;
654
+ declarationCount += 1;
655
+ declarationChars += key.length + declaration.length;
633
656
  }
634
657
  return out;
635
658
  }
@@ -643,10 +666,13 @@ function collectXmlnsDeclarations(element) {
643
666
  */
644
667
  function mergeXmlnsDeclarations(inherited, element) {
645
668
  const own = collectXmlnsDeclarations(element);
646
- for (const _key in own) return {
647
- ...inherited,
648
- ...own
649
- };
669
+ for (const _key in own) {
670
+ const merged = {
671
+ ...inherited,
672
+ ...own
673
+ };
674
+ return xmlnsDeclarationChars(merged) > MAX_XMLNS_DECLARATION_CHARS ? inherited : merged;
675
+ }
650
676
  return inherited;
651
677
  }
652
678
  const QNAME_VALUE_ATTRIBUTES = /* @__PURE__ */ new Set([
@@ -15,6 +15,7 @@ import { resolvePhysicalParagraphInlineLayout } from "../utils/paragraphInlineLa
15
15
  import { inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox } from "../utils/rotationBoundingBox.js";
16
16
  import { applySanitizedImageSrc } from "../utils/sanitizeImageSrc.js";
17
17
  import { SCRIPT_CLASS, hasCjk, hasComplexScript, segmentByScript } from "../utils/scriptSegments.js";
18
+ import { sanitizeExternalUrl } from "../utils/urlSecurity.js";
18
19
  import { borderStrokeToCss, resolveParagraphBorderHorizontalOutsets } from "./borderStroke.js";
19
20
  import { planCursiveJoiners, withCursiveJoiners } from "./cursiveJoiners.js";
20
21
  import { getAutomaticTextColorForBackground } from "./documentColors.js";
@@ -114,6 +115,12 @@ const DEFAULT_BLACK_TEXT_COLOR_VALUES = /* @__PURE__ */ new Set(["000000", "000"
114
115
  const SUGGESTION_COLOR_CSS = "var(--suggestion-color, #6d3bd6)";
115
116
  const SUGGESTION_TINT_CSS = "var(--suggestion-bg, color-mix(in oklch, #6d3bd6 12%, transparent))";
116
117
  const SUGGESTION_TINT_LAYER_CSS = `linear-gradient(${SUGGESTION_TINT_CSS}, ${SUGGESTION_TINT_CSS})`;
118
+ const RUN_BACKGROUND_TEXT_COLOR_VAR = "--doc-run-background-text-color";
119
+ const setRunBackgroundTextColor = (element, color) => {
120
+ element.classList.add("docx-run-background-text");
121
+ element.style.setProperty(RUN_BACKGROUND_TEXT_COLOR_VAR, color);
122
+ };
123
+ const hasRunBackgroundTextSurface = (run) => Boolean(run.highlight ?? run.shading) && !run.isInsertion && !run.isDeletion && !(run.commentIds !== void 0 && run.commentIds.length > 0);
117
124
  function normalizeTextColorValue(color) {
118
125
  return color.trim().toLowerCase().replace(/^#/u, "");
119
126
  }
@@ -205,6 +212,8 @@ function applyRunStyles(element, run) {
205
212
  const hasCommentHighlight = run.commentIds !== void 0 && run.commentIds.length > 0;
206
213
  const automaticTextColor = hasExplicitTextColor || hasTrackedChangeColor || hasCommentHighlight ? void 0 : getAutomaticTextColorForBackground(runBackground);
207
214
  if (automaticTextColor) element.style.color = automaticTextColor;
215
+ const backgroundTextColor = hasExplicitTextColor ? textColor : automaticTextColor;
216
+ if (backgroundTextColor && hasRunBackgroundTextSurface(run)) setRunBackgroundTextColor(element, backgroundTextColor);
208
217
  }
209
218
  const decorations = [];
210
219
  let explicitDecorationStyle = false;
@@ -315,11 +324,13 @@ function renderTextRun(run, doc, hyperlinkDirection) {
315
324
  applyRunStyles(span, run);
316
325
  applyPmPositions(span, run.pmStart, run.pmEnd);
317
326
  const paintedText = toPaintedText(run.text);
318
- if (run.hyperlink) {
327
+ const isBookmarkTarget = run.hyperlink?.href.startsWith("#") === true;
328
+ const hyperlinkHref = resolveHyperlinkHref(run.hyperlink?.href, isBookmarkTarget);
329
+ if (run.hyperlink && hyperlinkHref !== void 0) {
319
330
  const anchor = doc.createElement("a");
320
- anchor.href = run.hyperlink.href;
331
+ anchor.href = hyperlinkHref;
321
332
  if (hyperlinkDirection || DISPLAYED_URL_PATTERN.test(paintedText.trim())) anchor.dir = LEFT_TO_RIGHT_DIRECTION;
322
- if (!run.hyperlink.href.startsWith("#")) {
333
+ if (!isBookmarkTarget) {
323
334
  anchor.target = "_blank";
324
335
  anchor.rel = "noopener noreferrer";
325
336
  }
@@ -332,12 +343,27 @@ function renderTextRun(run, doc, hyperlinkDirection) {
332
343
  span.style.color = hyperlinkColor;
333
344
  anchor.style.setProperty("--doc-run-color", hyperlinkColor);
334
345
  span.style.setProperty("--doc-run-color", hyperlinkColor);
346
+ if (hasRunBackgroundTextSurface(run)) {
347
+ setRunBackgroundTextColor(span, hyperlinkColor);
348
+ setRunBackgroundTextColor(anchor, hyperlinkColor);
349
+ }
335
350
  }
336
351
  span.append(anchor);
337
352
  } else span.textContent = paintedText;
338
353
  applyWhitespaceUnderline(span, run);
339
354
  return span;
340
355
  }
356
+ /**
357
+ * Bookmark targets (`#name`) scroll within the document and stay verbatim;
358
+ * every other target is narrowed to the protocols the painter navigates to,
359
+ * matching the image hyperlink path. `undefined` means the run gets no anchor:
360
+ * an empty `href` resolves to the current document, so a rejected target would
361
+ * otherwise stay navigable.
362
+ */
363
+ function resolveHyperlinkHref(href, isBookmarkTarget) {
364
+ if (href === void 0) return;
365
+ return isBookmarkTarget ? href : sanitizeExternalUrl(href);
366
+ }
341
367
  function isNoteReferenceRun(run) {
342
368
  return run.footnoteRefId !== void 0 || run.endnoteRefId !== void 0;
343
369
  }
@@ -345,11 +371,14 @@ function removeUnderlineTextDecoration(element) {
345
371
  const textDecorationLines = (element.style.textDecorationLine || "").split(/\s+/u).filter((line) => line && line !== "underline");
346
372
  element.style.textDecorationLine = textDecorationLines.join(" ");
347
373
  }
374
+ function applyContinuousUnderline(element, underline) {
375
+ removeUnderlineTextDecoration(element);
376
+ const color = typeof underline === "object" && underline.color ? underline.color : "currentColor";
377
+ element.style.boxShadow = `inset 0 -1px 0 ${color}`;
378
+ }
348
379
  function applyWhitespaceUnderline(element, run) {
349
380
  if (!run.underline || run.text.trim().length > 0) return;
350
- removeUnderlineTextDecoration(element);
351
- element.style.borderBottom = "1px solid currentColor";
352
- if (typeof run.underline === "object" && run.underline.color) element.style.borderBottomColor = run.underline.color;
381
+ applyContinuousUnderline(element, run.underline);
353
382
  }
354
383
  /**
355
384
  * Number of leader characters to fill the tab's inner span. The inner span
@@ -401,9 +430,7 @@ function canClampTabToRightEdge(alignment, hasPriorRenderedContent, hasPriorTab,
401
430
  }
402
431
  function applyTabUnderline(element, run) {
403
432
  if (!run.underline) return;
404
- removeUnderlineTextDecoration(element);
405
- element.style.borderBottom = "1px solid currentColor";
406
- if (typeof run.underline === "object" && run.underline.color) element.style.borderBottomColor = run.underline.color;
433
+ applyContinuousUnderline(element, run.underline);
407
434
  }
408
435
  /**
409
436
  * Get leader character for tab