@stll/folio-core 0.26.0 → 0.27.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.
@@ -103,16 +103,26 @@ const NESTED_TABLE_CELL_SEPARATOR = " / ";
103
103
  const MAX_TABLE_COLUMNS = 256;
104
104
  /** Bound the mutual recursion between a cell and the tables nested inside it. */
105
105
  const MAX_NESTED_TABLE_DEPTH = 8;
106
+ /** Rows collected from one `w:tbl`. The column cap alone leaves row count unbounded. */
107
+ const MAX_TABLE_ROWS = 8192;
108
+ /**
109
+ * Characters one extraction emits, shared by the body and every header/footer
110
+ * part. Element count is bounded at unzip, but a bounded element count still
111
+ * renders an unbounded number of table rows once `w:gridSpan` padding and the
112
+ * GFM scaffolding are counted, so the emitted side carries its own ceiling.
113
+ */
114
+ const MAX_EXTRACTED_CHARS = 8e6;
106
115
  /**
107
116
  * Collect a table's `w:tr`, or a row's `w:tc`, seeing through the wrappers Word
108
117
  * puts around them (`w:sdt` / `w:sdtContent` content controls, `w:customXml`).
109
118
  * The walk stops at `w:tbl` and `w:p` so a nested table's rows and cells never
110
119
  * leak into the grid of the table that contains them.
111
120
  */
112
- const collectTableParts = (parent, localName) => {
121
+ const collectTableParts = (parent, localName, limit) => {
113
122
  const parts = [];
114
123
  const walk = (node) => {
115
124
  for (const child of childElements(node)) {
125
+ if (parts.length >= limit) return;
116
126
  const childName = wordElementName(child);
117
127
  if (childName === localName) {
118
128
  parts.push(child);
@@ -142,7 +152,7 @@ const readCellSourceParagraphs = (cell, depth) => {
142
152
  }
143
153
  if (childName === "tbl") {
144
154
  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);
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);
146
156
  continue;
147
157
  }
148
158
  walk(child);
@@ -173,8 +183,8 @@ const readCellRenderedLines = (cell, depth) => {
173
183
  };
174
184
  const flattenNestedTable = (table, depth) => {
175
185
  const lines = [];
176
- for (const row of collectTableParts(table, "tr")) {
177
- const cells = collectTableParts(row, "tc").map((cell) => readCellRenderedLines(cell, depth).join("\n"));
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"));
178
188
  if (cells.some((text) => text.length > 0)) lines.push(cells.join(NESTED_TABLE_CELL_SEPARATOR));
179
189
  }
180
190
  return lines;
@@ -229,12 +239,12 @@ const readTableGrid = (table) => {
229
239
  const rows = [];
230
240
  let columnCount = 0;
231
241
  let firstRowIsHeader = false;
232
- for (const [rowIndex, row] of collectTableParts(table, "tr").entries()) {
242
+ for (const [rowIndex, row] of collectTableParts(table, "tr", MAX_TABLE_ROWS).entries()) {
233
243
  if (rowIndex === 0) firstRowIsHeader = declaresHeaderRow(row);
234
244
  const columns = [];
235
245
  const gridBefore = readRowGridOffset(row, "gridBefore");
236
246
  for (let index = 0; index < gridBefore; index += 1) columns.push(emptyTableCell());
237
- for (const cell of collectTableParts(row, "tc")) {
247
+ for (const cell of collectTableParts(row, "tc", MAX_TABLE_COLUMNS)) {
238
248
  if (columns.length >= MAX_TABLE_COLUMNS) break;
239
249
  const extractedCell = readTableCell(cell, 0);
240
250
  columns.push(extractedCell);
@@ -293,7 +303,8 @@ const renderTableRows = (table, tableIndex) => {
293
303
  for (const row of firstRowIsHeader ? remainingRows : rows) pushCells(row);
294
304
  return rendered;
295
305
  };
296
- const extractContainer = ({ container, source, startIndex, startTableIndex }) => {
306
+ const createCharBudget = () => ({ remaining: MAX_EXTRACTED_CHARS });
307
+ const extractContainer = ({ container, source, startIndex, startTableIndex, budget }) => {
297
308
  const paragraphs = [];
298
309
  let charCount = 0;
299
310
  let tableCount = 0;
@@ -307,6 +318,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
307
318
  };
308
319
  paragraphs.push(entry);
309
320
  charCount += text.length;
321
+ budget.remaining -= text.length;
310
322
  };
311
323
  const pushTableRow = ({ text, position }) => {
312
324
  paragraphs.push({
@@ -316,6 +328,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
316
328
  tableRow: position
317
329
  });
318
330
  charCount += text.length;
331
+ budget.remaining -= text.length;
319
332
  };
320
333
  /**
321
334
  * Walk block content in document order. Descent mirrors the previous
@@ -325,6 +338,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
325
338
  */
326
339
  const walkBlocks = (node) => {
327
340
  for (const child of childElements(node)) {
341
+ if (budget.remaining <= 0) return;
328
342
  const childName = wordElementName(child);
329
343
  if (childName === "tbl") {
330
344
  for (const row of renderTableRows(child, startTableIndex + tableCount)) pushTableRow(row);
@@ -342,7 +356,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
342
356
  tableCount
343
357
  };
344
358
  };
345
- const extractParts = async ({ archive, source, rootName, startIndex, startTableIndex, paths }) => {
359
+ const extractParts = async ({ archive, source, rootName, startIndex, startTableIndex, paths, budget }) => {
346
360
  const paragraphs = [];
347
361
  let charCount = 0;
348
362
  let tableCount = 0;
@@ -356,7 +370,8 @@ const extractParts = async ({ archive, source, rootName, startIndex, startTableI
356
370
  container,
357
371
  source,
358
372
  startIndex: nextIndex,
359
- startTableIndex: startTableIndex + tableCount
373
+ startTableIndex: startTableIndex + tableCount,
374
+ budget
360
375
  });
361
376
  for (const paragraph of result.paragraphs) paragraphs.push(paragraph);
362
377
  charCount += result.charCount;
@@ -427,19 +442,22 @@ const extractDocxText = async (bytes) => {
427
442
  const body = findDeep(root, "w", "body");
428
443
  if (!body) return createEmptyResult();
429
444
  const referencedParts = await resolveReferencedHeaderFooterParts(archive, root);
445
+ const budget = createCharBudget();
430
446
  const headers = await extractParts({
431
447
  archive,
432
448
  source: "header",
433
449
  rootName: "hdr",
434
450
  startIndex: 0,
435
451
  startTableIndex: 0,
436
- paths: referencedParts.headers
452
+ paths: referencedParts.headers,
453
+ budget
437
454
  });
438
455
  const bodyResult = extractContainer({
439
456
  container: body,
440
457
  source: "body",
441
458
  startIndex: headers.paragraphs.length,
442
- startTableIndex: headers.tableCount
459
+ startTableIndex: headers.tableCount,
460
+ budget
443
461
  });
444
462
  const footers = await extractParts({
445
463
  archive,
@@ -447,7 +465,8 @@ const extractDocxText = async (bytes) => {
447
465
  rootName: "ftr",
448
466
  startIndex: headers.paragraphs.length + bodyResult.paragraphs.length,
449
467
  startTableIndex: headers.tableCount + bodyResult.tableCount,
450
- paths: referencedParts.footers
468
+ paths: referencedParts.footers,
469
+ budget
451
470
  });
452
471
  return {
453
472
  paragraphs: [
@@ -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([
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ import { DocumentStyleCatalog, DocumentStyleCatalogEntry, ExtractDocumentStyleSe
17
17
  import { STELLA_STYLE_SET_NAME, createStellaStyleDocumentPreset, createStellaStyleSet } from "./style-sets/stellaStyle.js";
18
18
  import { createDocx } from "./docx/rezip.js";
19
19
  import { DocxCompatibility, DocxCompatibilityContext, DocxCompatibilityIssue, DocxCompatibilityLocation, DocxCompatibilityPart, FolioDocxCompatibilityHost, FolioDocxCompatibilityProfile, InspectDocxCompatibilityOptions, inspectDocxCompatibility } from "./docx/compatibility.js";
20
+ import { BlockRect } from "./paged-layout/blockGeometry.js";
20
21
  import { setAISuggestionsMeta, setFocusedSuggestionMeta } from "./prosemirror/plugins/aiSuggestionDecorations.js";
21
22
  import { scrollFolioPositionIntoView } from "./paged-layout/scrollToPmPosition.js";
22
23
  import { getFolioCaretViewportRect, getFolioSelectionViewportRect } from "./paged-layout/selectionViewportRect.js";
@@ -34,4 +35,4 @@ import { getGoogleFontsEnabled, setEmbeddedFontFamilyMap, setGoogleFontsEnabled
34
35
  import { DOCX_CONFORMANCE_CLASSES } from "@stll/docx-core/model";
35
36
  type Document = document_d_exports.Document;
36
37
  type DocxConformanceClass = document_d_exports.DocxConformanceClass;
37
- export { type AIBarStatus, type AIChatMode, type AICitation, type AICitationRange, type AICitationSource, type AIGenerateInput, type AISuggestion, type AISuggestionApplyMode, type AISuggestionPreset, type AISuggestionSeverity, type AISuggestionStatus, type AcceptAutocompleteResult, type AnonymizationMatch, type AnonymizationTerm, type ApplyFolioDocumentOperationsOptions, type ApplyResult, type AutocompleteSuggestionPluginOptions, type AutocompleteSuggestionState, type AutocompleteSuggestionStatus, type AutocompleteTriggerCheck, type AutocompleteTriggerOptions, type AutocompleteTriggerSkipReason, type CreateEmptyDocumentOptions, DEFAULT_AI_SUGGESTION_PRESETS, DEFAULT_AUTOCOMPLETE_DEAD_ZONE_NODES, DOCUMENT_PRESET_VERSION, DOCUMENT_STYLE_SET_VERSION, DOCX_CONFORMANCE_CLASSES, type DeriveBlockIdInput, type DirectiveKind, type DirectiveRange, type Document, type DocumentPreset, type DocumentStyleCatalog, type DocumentStyleCatalogEntry, type DocumentStyleSet, type DocxCompatibility, type DocxCompatibilityContext, type DocxCompatibilityIssue, type DocxCompatibilityLocation, type DocxCompatibilityPart, type DocxConformanceClass, type EmbeddedFont, type EmbeddedFontParts, type ExtractDocumentStyleSetOptions, FOLIO_DOCUMENT_OPERATION_BATCH_MODES, FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, FOLIO_DOCUMENT_OPERATION_MODES, FOLIO_DOCUMENT_OPERATION_MODES_BY_TYPE, FOLIO_DOCUMENT_OPERATION_PRECONDITIONS, FOLIO_DOCUMENT_OPERATION_STORIES, FOLIO_DOCUMENT_OPERATION_TYPES, type FolioAIBlock, type FolioAIBlockAnchor, type FolioAIBlockKind, type FolioAIBlockPreviewRun, type FolioAIComment, type FolioAIEditAppliedOperation, type FolioAIEditApplyMode, type FolioAIEditApplyResult, type FolioAIEditOperation, type FolioAIEditPrecondition, type FolioAIEditReviewMeta, type FolioAIEditSeverity, type FolioAIEditSkipReason, type FolioAIEditSkippedOperation, type FolioAIEditSnapshot, type FolioAISignatureParty, type FolioBlockId, type FolioDocumentOperation, type FolioDocumentOperationAffectedTarget, type FolioDocumentOperationBatch, type FolioDocumentOperationCapabilities, type FolioDocumentOperationIssue, type FolioDocumentOperationMode, type FolioDocumentOperationPrecondition, type FolioDocumentOperationReceipt, type FolioDocumentOperationRecovery, type FolioDocumentOperationResult, type FolioDocumentOperationStatus, type FolioDocumentOperationStory, type FolioDocumentOperationType, type FolioDocumentOperationUndoFailureReason, type FolioDocumentOperationUndoHandle, type FolioDocumentOperationUndoResult, type FolioDocxCompatibilityHost, type FolioDocxCompatibilityProfile, type ImageMeta, type ImageRef, type InspectDocxCompatibilityOptions, InvalidFolioDocumentOperationBatchError, type MarkdownOptions, type MarkdownResult, type PositionalText, type ResolvedAnchor, STELLA_STYLE_SET_NAME, type TemplatePreviewSpan, type TemplatePreviewValue, type TemplatePreviewValues, type TemplateSlashMenuKeyAction, type TemplateSlashMenuState, UnsupportedFolioDocumentOperationVersionError, type WordDiffSegment, acceptAutocompleteSuggestion, acceptAutocompleteWord, anonymizationDecorationsKey, appendAutocompleteToken, applyFolioAIEditOperations, applyFolioDocumentOperations, applySuggestions, assertSupportedFolioDocumentOperationVersion, autocompleteSuggestionKey, autocompleteSuggestionPlugin, buildEmbeddedFontFamilyMap, buildPositionalText, clearAutocompleteSuggestion, clearTemplateSlashMenu, consumeTemplateSlashQuery, createAICitationDecorationsPlugin, createDocx, createEmptyDocument, createFolioAIEditSnapshot, createStellaStyleDocumentPreset, createStellaStyleSet, deriveBlockId, diffWordSegments, extractDocumentStyleSet, extractDocumentStyleSetFromDocx, extractEmbeddedFonts, finishAutocompleteSuggestion, fromMarkdown, getAnonymizationMatches, getAutocompleteSuggestion, getEmbeddedFontFaces, getFolioCaretViewportRect, getFolioDocumentOperationCapabilities, getFolioDocumentOperationIssues, getFolioDocumentOperationReceipts, getFolioParaIdFromBlockId, getFolioSelectionViewportRect, getGoogleFontsEnabled, getTemplateDirectives, getTemplateSlashMenu, hashFolioAIBlockText, inspectDocumentStyles, inspectDocumentStylesFromDocx, inspectDocxCompatibility, isFolioBlockId, isFolioDocumentOperationModeSupported, isSequentialFolioBlockId, isSuggestionStale, isSupportedFolioDocumentOperationVersion, mergeDocumentContent, normalizeFolioAIBlockText, parseFolioDocumentOperationBatch, resetTemplateSlashQuery, resolveSuggestionAnchor, scanDirectives, scopeEmbeddedFontFamily, scrollFolioPositionIntoView, setAICitationsMeta, setAISuggestionsMeta, setActiveCitationMeta, setAnonymizationTermsMeta, setEmbeddedFontFamilyMap, setFocusedSuggestionMeta, setGoogleFontsEnabled, setTemplatePreviewValues, shouldTriggerAutocomplete, startAutocompleteSuggestion, templateSlashMenuKey, toMarkdown, toMarkdownResult };
38
+ export { type AIBarStatus, type AIChatMode, type AICitation, type AICitationRange, type AICitationSource, type AIGenerateInput, type AISuggestion, type AISuggestionApplyMode, type AISuggestionPreset, type AISuggestionSeverity, type AISuggestionStatus, type AcceptAutocompleteResult, type AnonymizationMatch, type AnonymizationTerm, type ApplyFolioDocumentOperationsOptions, type ApplyResult, type AutocompleteSuggestionPluginOptions, type AutocompleteSuggestionState, type AutocompleteSuggestionStatus, type AutocompleteTriggerCheck, type AutocompleteTriggerOptions, type AutocompleteTriggerSkipReason, type BlockRect, type CreateEmptyDocumentOptions, DEFAULT_AI_SUGGESTION_PRESETS, DEFAULT_AUTOCOMPLETE_DEAD_ZONE_NODES, DOCUMENT_PRESET_VERSION, DOCUMENT_STYLE_SET_VERSION, DOCX_CONFORMANCE_CLASSES, type DeriveBlockIdInput, type DirectiveKind, type DirectiveRange, type Document, type DocumentPreset, type DocumentStyleCatalog, type DocumentStyleCatalogEntry, type DocumentStyleSet, type DocxCompatibility, type DocxCompatibilityContext, type DocxCompatibilityIssue, type DocxCompatibilityLocation, type DocxCompatibilityPart, type DocxConformanceClass, type EmbeddedFont, type EmbeddedFontParts, type ExtractDocumentStyleSetOptions, FOLIO_DOCUMENT_OPERATION_BATCH_MODES, FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, FOLIO_DOCUMENT_OPERATION_MODES, FOLIO_DOCUMENT_OPERATION_MODES_BY_TYPE, FOLIO_DOCUMENT_OPERATION_PRECONDITIONS, FOLIO_DOCUMENT_OPERATION_STORIES, FOLIO_DOCUMENT_OPERATION_TYPES, type FolioAIBlock, type FolioAIBlockAnchor, type FolioAIBlockKind, type FolioAIBlockPreviewRun, type FolioAIComment, type FolioAIEditAppliedOperation, type FolioAIEditApplyMode, type FolioAIEditApplyResult, type FolioAIEditOperation, type FolioAIEditPrecondition, type FolioAIEditReviewMeta, type FolioAIEditSeverity, type FolioAIEditSkipReason, type FolioAIEditSkippedOperation, type FolioAIEditSnapshot, type FolioAISignatureParty, type FolioBlockId, type FolioDocumentOperation, type FolioDocumentOperationAffectedTarget, type FolioDocumentOperationBatch, type FolioDocumentOperationCapabilities, type FolioDocumentOperationIssue, type FolioDocumentOperationMode, type FolioDocumentOperationPrecondition, type FolioDocumentOperationReceipt, type FolioDocumentOperationRecovery, type FolioDocumentOperationResult, type FolioDocumentOperationStatus, type FolioDocumentOperationStory, type FolioDocumentOperationType, type FolioDocumentOperationUndoFailureReason, type FolioDocumentOperationUndoHandle, type FolioDocumentOperationUndoResult, type FolioDocxCompatibilityHost, type FolioDocxCompatibilityProfile, type ImageMeta, type ImageRef, type InspectDocxCompatibilityOptions, InvalidFolioDocumentOperationBatchError, type MarkdownOptions, type MarkdownResult, type PositionalText, type ResolvedAnchor, STELLA_STYLE_SET_NAME, type TemplatePreviewSpan, type TemplatePreviewValue, type TemplatePreviewValues, type TemplateSlashMenuKeyAction, type TemplateSlashMenuState, UnsupportedFolioDocumentOperationVersionError, type WordDiffSegment, acceptAutocompleteSuggestion, acceptAutocompleteWord, anonymizationDecorationsKey, appendAutocompleteToken, applyFolioAIEditOperations, applyFolioDocumentOperations, applySuggestions, assertSupportedFolioDocumentOperationVersion, autocompleteSuggestionKey, autocompleteSuggestionPlugin, buildEmbeddedFontFamilyMap, buildPositionalText, clearAutocompleteSuggestion, clearTemplateSlashMenu, consumeTemplateSlashQuery, createAICitationDecorationsPlugin, createDocx, createEmptyDocument, createFolioAIEditSnapshot, createStellaStyleDocumentPreset, createStellaStyleSet, deriveBlockId, diffWordSegments, extractDocumentStyleSet, extractDocumentStyleSetFromDocx, extractEmbeddedFonts, finishAutocompleteSuggestion, fromMarkdown, getAnonymizationMatches, getAutocompleteSuggestion, getEmbeddedFontFaces, getFolioCaretViewportRect, getFolioDocumentOperationCapabilities, getFolioDocumentOperationIssues, getFolioDocumentOperationReceipts, getFolioParaIdFromBlockId, getFolioSelectionViewportRect, getGoogleFontsEnabled, getTemplateDirectives, getTemplateSlashMenu, hashFolioAIBlockText, inspectDocumentStyles, inspectDocumentStylesFromDocx, inspectDocxCompatibility, isFolioBlockId, isFolioDocumentOperationModeSupported, isSequentialFolioBlockId, isSuggestionStale, isSupportedFolioDocumentOperationVersion, mergeDocumentContent, normalizeFolioAIBlockText, parseFolioDocumentOperationBatch, resetTemplateSlashQuery, resolveSuggestionAnchor, scanDirectives, scopeEmbeddedFontFamily, scrollFolioPositionIntoView, setAICitationsMeta, setAISuggestionsMeta, setActiveCitationMeta, setAnonymizationTermsMeta, setEmbeddedFontFamilyMap, setFocusedSuggestionMeta, setGoogleFontsEnabled, setTemplatePreviewValues, shouldTriggerAutocomplete, startAutocompleteSuggestion, templateSlashMenuKey, toMarkdown, toMarkdownResult };
@@ -1597,6 +1597,7 @@ function renderPages(pages, container, options = {}) {
1597
1597
  const viewportHeight = window.innerHeight;
1598
1598
  const nearThreshold = viewportHeight * 3;
1599
1599
  const nearIndices = /* @__PURE__ */ new Set();
1600
+ let depopulated = false;
1600
1601
  for (const [el, data] of liveDataMap) {
1601
1602
  if (!data.rendered) continue;
1602
1603
  const rect = el.getBoundingClientRect();
@@ -1609,8 +1610,12 @@ function renderPages(pages, container, options = {}) {
1609
1610
  keepRendered = true;
1610
1611
  break;
1611
1612
  }
1612
- if (!keepRendered && nearIndices.size > 0) depopulatePageShell(el, liveDataMap);
1613
+ if (!keepRendered && nearIndices.size > 0) {
1614
+ depopulatePageShell(el, liveDataMap);
1615
+ depopulated = true;
1616
+ }
1613
1617
  }
1618
+ if (depopulated) emitPainterPainted(container);
1614
1619
  }, {
1615
1620
  root: null,
1616
1621
  rootMargin: `${VIRTUALIZATION_ROOT_MARGIN_PX}px 0px ${VIRTUALIZATION_ROOT_MARGIN_PX}px 0px`
@@ -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
@@ -0,0 +1,32 @@
1
+ import { FolioAIEditSnapshot } from "../ai-edits/types.js";
2
+ //#region src/paged-layout/blockGeometry.d.ts
3
+ /** Painted geometry for one AI-snapshot block in scroll-content coordinates. */
4
+ type BlockRect = {
5
+ blockId: string;
6
+ /** One-based painted page number. */
7
+ page: number;
8
+ /** Pixels from the scroll root's content origin. */
9
+ top: number;
10
+ /** Pixels from the scroll root's content origin. */
11
+ left: number;
12
+ width: number;
13
+ height: number;
14
+ };
15
+ type ReadBlockRectsOptions = {
16
+ blockIds: readonly string[];
17
+ snapshot: FolioAIEditSnapshot;
18
+ pagesContainer: HTMLElement;
19
+ scrollRoot: HTMLElement;
20
+ };
21
+ /**
22
+ * Measure painted snapshot blocks relative to one editor's scroll root.
23
+ *
24
+ * Missing snapshot ids and blocks on unpainted virtual pages are omitted. Each
25
+ * requested block is measured at most once; painted elements are collected in
26
+ * one DOM query per relevant page.
27
+ */
28
+ declare const readBlockRects: ({ blockIds, snapshot, pagesContainer, scrollRoot }: ReadBlockRectsOptions) => ReadonlyMap<string, BlockRect>;
29
+ /** Subscribe to instance-scoped painted-page changes. */
30
+ declare const onPaintedLayoutChange: (pagesContainer: HTMLElement, listener: () => void) => (() => void);
31
+ //#endregion
32
+ export { BlockRect, ReadBlockRectsOptions, onPaintedLayoutChange, readBlockRects };
@@ -0,0 +1,115 @@
1
+ import { resolveSequentialBlockAnchor } from "../ai-edits/blockRange.js";
2
+ import { PAINTER_PAINTED_EVENT, findPageShellForPmPos } from "../layout-painter/renderPage.js";
3
+ //#region src/paged-layout/blockGeometry.ts
4
+ const PAINTED_BLOCK_SELECTOR = "[data-block-id][data-pm-start]";
5
+ const PAGE_SELECTOR = "[data-page-number]";
6
+ const pageNumberOf = (page) => {
7
+ const raw = page.dataset["pageNumber"];
8
+ const pageNumber = raw === void 0 ? NaN : Number(raw);
9
+ return Number.isInteger(pageNumber) && pageNumber > 0 ? pageNumber : null;
10
+ };
11
+ const requestedBlocks = (blockIds, snapshot) => {
12
+ const uniqueIds = new Set(blockIds);
13
+ const requested = [];
14
+ for (const blockId of uniqueIds) {
15
+ const anchor = Object.hasOwn(snapshot.anchors, blockId) ? snapshot.anchors[blockId] : resolveSequentialBlockAnchor(blockId, snapshot);
16
+ if (anchor) requested.push({
17
+ blockId,
18
+ pmStart: anchor.from
19
+ });
20
+ }
21
+ return requested;
22
+ };
23
+ const paintedBlocksByPmStart = (page) => {
24
+ const blocks = /* @__PURE__ */ new Map();
25
+ for (const element of page.querySelectorAll(PAINTED_BLOCK_SELECTOR)) {
26
+ const pmStart = Number(element.dataset["pmStart"]);
27
+ if (Number.isFinite(pmStart) && !blocks.has(pmStart)) blocks.set(pmStart, element);
28
+ }
29
+ return blocks;
30
+ };
31
+ const groupVirtualizedRequestsByPage = (requests, pagesContainer) => {
32
+ const byPage = /* @__PURE__ */ new Map();
33
+ for (const request of requests) {
34
+ const page = findPageShellForPmPos(pagesContainer, request.pmStart)?.element;
35
+ if (!page) return null;
36
+ const pageRequests = byPage.get(page);
37
+ if (pageRequests) pageRequests.push(request);
38
+ else byPage.set(page, [request]);
39
+ }
40
+ return byPage;
41
+ };
42
+ const findPaintedElements = (requests, pagesContainer) => {
43
+ const found = /* @__PURE__ */ new Map();
44
+ const virtualizedRequests = groupVirtualizedRequestsByPage(requests, pagesContainer);
45
+ if (virtualizedRequests) {
46
+ for (const [page, pageRequests] of virtualizedRequests) {
47
+ const pageNumber = pageNumberOf(page);
48
+ if (pageNumber === null) continue;
49
+ const blocks = paintedBlocksByPmStart(page);
50
+ for (const request of pageRequests) {
51
+ const element = blocks.get(request.pmStart);
52
+ if (element) found.set(request.blockId, {
53
+ element,
54
+ page: pageNumber
55
+ });
56
+ }
57
+ }
58
+ return found;
59
+ }
60
+ const requestsByPmStart = new Map(requests.map((request) => [request.pmStart, request]));
61
+ for (const page of pagesContainer.querySelectorAll(PAGE_SELECTOR)) {
62
+ const pageNumber = pageNumberOf(page);
63
+ if (pageNumber === null) continue;
64
+ const blocks = paintedBlocksByPmStart(page);
65
+ for (const [pmStart, request] of requestsByPmStart) {
66
+ const element = blocks.get(pmStart);
67
+ if (element) {
68
+ found.set(request.blockId, {
69
+ element,
70
+ page: pageNumber
71
+ });
72
+ requestsByPmStart.delete(pmStart);
73
+ }
74
+ }
75
+ if (requestsByPmStart.size === 0) break;
76
+ }
77
+ return found;
78
+ };
79
+ /**
80
+ * Measure painted snapshot blocks relative to one editor's scroll root.
81
+ *
82
+ * Missing snapshot ids and blocks on unpainted virtual pages are omitted. Each
83
+ * requested block is measured at most once; painted elements are collected in
84
+ * one DOM query per relevant page.
85
+ */
86
+ const readBlockRects = ({ blockIds, snapshot, pagesContainer, scrollRoot }) => {
87
+ const requests = requestedBlocks(blockIds, snapshot);
88
+ const paintedElements = findPaintedElements(requests, pagesContainer);
89
+ if (paintedElements.size === 0) return /* @__PURE__ */ new Map();
90
+ const rootRect = scrollRoot.getBoundingClientRect();
91
+ const rects = /* @__PURE__ */ new Map();
92
+ for (const request of requests) {
93
+ const painted = paintedElements.get(request.blockId);
94
+ if (!painted) continue;
95
+ const rect = painted.element.getBoundingClientRect();
96
+ rects.set(request.blockId, {
97
+ blockId: request.blockId,
98
+ page: painted.page,
99
+ top: rect.top - rootRect.top - scrollRoot.clientTop + scrollRoot.scrollTop,
100
+ left: rect.left - rootRect.left - scrollRoot.clientLeft + scrollRoot.scrollLeft,
101
+ width: rect.width,
102
+ height: rect.height
103
+ });
104
+ }
105
+ return rects;
106
+ };
107
+ /** Subscribe to instance-scoped painted-page changes. */
108
+ const onPaintedLayoutChange = (pagesContainer, listener) => {
109
+ pagesContainer.addEventListener(PAINTER_PAINTED_EVENT, listener);
110
+ return () => {
111
+ pagesContainer.removeEventListener(PAINTER_PAINTED_EVENT, listener);
112
+ };
113
+ };
114
+ //#endregion
115
+ export { onPaintedLayoutChange, readBlockRects };
@@ -1845,10 +1845,11 @@ function tableCellAttrsToFormatting(attrs) {
1845
1845
  if (attrs.rowspan > 1) result.vMerge = "restart";
1846
1846
  else if (result.vMerge === "restart" && !attrs._preserveVMergeRestart) delete result.vMerge;
1847
1847
  const cellWidth = attrs.width;
1848
- if (cellWidth !== void 0) result.width = {
1848
+ if (typeof cellWidth === "number") result.width = {
1849
1849
  value: cellWidth,
1850
1850
  type: attrs.widthType ?? "dxa"
1851
1851
  };
1852
+ else delete result.width;
1852
1853
  if (attrs.verticalAlign !== (orig.verticalAlign ?? void 0)) if (attrs.verticalAlign) result.verticalAlign = attrs.verticalAlign;
1853
1854
  else delete result.verticalAlign;
1854
1855
  if (backgroundChanged) result.shading = cellShadingFromAttrs(attrs);
@@ -1859,11 +1860,11 @@ function tableCellAttrsToFormatting(attrs) {
1859
1860
  return result;
1860
1861
  }
1861
1862
  const cellWidth = attrs.width;
1862
- if (!(attrs.colspan > 1 || attrs.rowspan > 1 || cellWidth !== void 0 || attrs.verticalAlign || backgroundChanged || attrs.borders || attrs.margins || attrs.textDirection)) return;
1863
+ if (!(attrs.colspan > 1 || attrs.rowspan > 1 || typeof cellWidth === "number" || attrs.verticalAlign || backgroundChanged || attrs.borders || attrs.margins || attrs.textDirection)) return;
1863
1864
  const f = {};
1864
1865
  if (attrs.colspan > 1) f.gridSpan = attrs.colspan;
1865
1866
  if (attrs.rowspan > 1) f.vMerge = "restart";
1866
- if (cellWidth !== void 0) f.width = {
1867
+ if (typeof cellWidth === "number") f.width = {
1867
1868
  value: cellWidth,
1868
1869
  type: attrs.widthType ?? "dxa"
1869
1870
  };
@@ -185,23 +185,11 @@ const createEmptyHeaderFooter = (document, position, isFirstPage) => {
185
185
  type: hdrFtrType,
186
186
  rId
187
187
  };
188
- const usedTargets = /* @__PURE__ */ new Set();
189
- for (const relationship of pkg.relationships?.values() ?? []) if (relationship.target) usedTargets.add(relationship.target);
190
- let targetNumber = 1;
191
- while (usedTargets.has(`${position}${targetNumber}.xml`)) targetNumber++;
192
- const relationshipType = position === "header" ? "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" : "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
193
- const relationships = new Map(pkg.relationships);
194
- relationships.set(rId, {
195
- id: rId,
196
- type: relationshipType,
197
- target: `${position}${targetNumber}.xml`
198
- });
199
188
  return {
200
189
  ...document,
201
190
  package: {
202
191
  ...pkg,
203
192
  [mapKey]: newMap,
204
- relationships,
205
193
  document: {
206
194
  ...pkg.document,
207
195
  finalSectionProperties: {