@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.
- package/dist/ai-edits/table-cell-mutations.js +38 -3
- package/dist/docx/paragraphParser.js +3 -2
- package/dist/docx/parser.js +28 -5
- package/dist/docx/rezip.d.ts +21 -1
- package/dist/docx/rezip.js +80 -2
- package/dist/docx/runParser.js +6 -5
- package/dist/docx/selectiveSave.d.ts +0 -8
- package/dist/docx/selectiveSave.js +16 -1
- package/dist/docx/serializer/runSerializer.js +4 -4
- package/dist/docx/serializer/sectionPropertiesSerializer.js +5 -5
- package/dist/docx/server/createBilingualDocument.js +73 -7
- package/dist/docx/server/extractDocxText.js +124 -30
- package/dist/docx/server/materializeYjsDocx.d.ts +31 -0
- package/dist/docx/server/materializeYjsDocx.js +61 -0
- package/dist/docx/styleParser.js +6 -5
- package/dist/docx/unzip.js +17 -3
- package/dist/docx/xmlParser.js +34 -8
- package/dist/layout-painter/renderParagraph.js +36 -9
- package/dist/prosemirror/conversion/fromProseDoc.js +4 -3
- package/dist/server.d.ts +2 -1
- package/dist/server.js +2 -1
- package/dist/utils/headerFooter.js +0 -12
- package/dist/utils/tiffConverter.d.ts +11 -2
- package/dist/utils/tiffConverter.js +12 -4
- 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;
|
|
@@ -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
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
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(
|
|
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,
|
|
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(
|
|
177
|
-
|
|
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,
|
|
201
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
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 };
|
package/dist/docx/styleParser.js
CHANGED
|
@@ -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 = {};
|
package/dist/docx/unzip.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/docx/xmlParser.js
CHANGED
|
@@ -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 (
|
|
630
|
-
|
|
631
|
-
|
|
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)
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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 =
|
|
331
|
+
anchor.href = hyperlinkHref;
|
|
321
332
|
if (hyperlinkDirection || DISPLAYED_URL_PATTERN.test(paintedText.trim())) anchor.dir = LEFT_TO_RIGHT_DIRECTION;
|
|
322
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|