officeparser 7.2.3 → 7.4.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.
Files changed (49) hide show
  1. package/README.md +277 -17
  2. package/dist/OfficeConverter.d.ts +1 -1
  3. package/dist/OfficeConverter.js +3 -0
  4. package/dist/OfficeGenerator.js +4 -0
  5. package/dist/OfficeParser.d.ts +2 -0
  6. package/dist/OfficeParser.js +6 -0
  7. package/dist/cli.d.ts +1 -1
  8. package/dist/cli.js +5 -2
  9. package/dist/defaults.js +12 -0
  10. package/dist/generators/BaseGenerator.d.ts +34 -1
  11. package/dist/generators/BaseGenerator.js +98 -0
  12. package/dist/generators/CsvGenerator.d.ts +9 -1
  13. package/dist/generators/CsvGenerator.js +28 -16
  14. package/dist/generators/EpubGenerator.d.ts +43 -0
  15. package/dist/generators/EpubGenerator.js +312 -0
  16. package/dist/generators/HtmlGenerator.d.ts +12 -0
  17. package/dist/generators/HtmlGenerator.js +378 -61
  18. package/dist/generators/MarkdownGenerator.d.ts +28 -5
  19. package/dist/generators/MarkdownGenerator.js +432 -51
  20. package/dist/generators/PdfGenerator.js +32 -0
  21. package/dist/generators/RtfGenerator.js +47 -22
  22. package/dist/generators/TextGenerator.js +98 -11
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +1 -0
  25. package/dist/officeparser.browser.d.ts +427 -20
  26. package/dist/officeparser.browser.iife.js +338 -206
  27. package/dist/officeparser.browser.mjs +346 -214
  28. package/dist/officeparser.browser.slim.d.ts +427 -20
  29. package/dist/officeparser.browser.slim.iife.js +346 -214
  30. package/dist/officeparser.browser.slim.mjs +346 -214
  31. package/dist/parsers/EpubParser.d.ts +8 -0
  32. package/dist/parsers/EpubParser.js +217 -0
  33. package/dist/parsers/ExcelParser.js +2 -0
  34. package/dist/parsers/HtmlParser.js +507 -48
  35. package/dist/parsers/MarkdownParser.js +704 -92
  36. package/dist/parsers/OpenOfficeParser.js +128 -20
  37. package/dist/parsers/PdfParser.js +4 -1
  38. package/dist/parsers/PowerPointParser.js +1 -0
  39. package/dist/parsers/WordParser.js +1 -0
  40. package/dist/sbom.cdx.json +1695 -0
  41. package/dist/types.d.ts +427 -20
  42. package/dist/types.js +8 -0
  43. package/dist/utils/configUtils.js +53 -4
  44. package/dist/utils/errorUtils.js +7 -3
  45. package/dist/utils/sanitize.d.ts +139 -0
  46. package/dist/utils/sanitize.js +318 -0
  47. package/dist/utils/xmlUtils.js +2 -2
  48. package/dist/utils/zipUtils.js +76 -26
  49. package/package.json +16 -12
@@ -1,7 +1,87 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HtmlGenerator = void 0;
4
+ const types_js_1 = require("../types.js");
4
5
  const BaseGenerator_js_1 = require("./BaseGenerator.js");
6
+ const errorUtils_js_1 = require("../utils/errorUtils.js");
7
+ const sanitize_js_1 = require("../utils/sanitize.js");
8
+ /**
9
+ * Attributes that carry a URL and therefore must go through `sanitizeUrl` rather than plain
10
+ * escaping - an escaped `javascript:` payload is still a `javascript:` payload.
11
+ */
12
+ const URL_BEARING_ATTRS = new Set([
13
+ 'href', 'src', 'srcset', 'action', 'formaction', 'poster', 'cite', 'data', 'background', 'ping',
14
+ ]);
15
+ /**
16
+ * Renders `node.htmlAttributes` (see `BaseContentNode.htmlAttributes`) as an attribute string.
17
+ *
18
+ * This re-applies the parser's filtering rather than trusting it, because an AST can be built
19
+ * programmatically and handed straight to the generator - the parse-side pass is defence in depth,
20
+ * not the only gate. `class` is returned separately so the caller can merge it into the class
21
+ * attribute it already composes: emitting a second `class=` would be invalid HTML and, worse, a
22
+ * *fatal* XML well-formedness error once EpubGenerator converts the output to XHTML.
23
+ */
24
+ function renderHtmlAttributeBag(node, alreadyEmitted = []) {
25
+ const bag = node.htmlAttributes;
26
+ if (!bag)
27
+ return { attrs: '' };
28
+ const taken = new Set([...alreadyEmitted].map(k => k.toLowerCase()));
29
+ let attrs = '';
30
+ let className;
31
+ for (const [rawKey, rawValue] of Object.entries(bag)) {
32
+ const key = rawKey.toLowerCase();
33
+ // Same policy as the parser, restated here because this path is independently reachable.
34
+ if (/^on/i.test(key))
35
+ continue;
36
+ if (key === 'srcdoc' || key === 'style' || key === 'id')
37
+ continue;
38
+ if (!(0, sanitize_js_1.isSafeHtmlAttributeName)(key))
39
+ continue;
40
+ if (taken.has(key))
41
+ continue;
42
+ if (key === 'class') {
43
+ className = String(rawValue);
44
+ continue;
45
+ }
46
+ if (URL_BEARING_ATTRS.has(key)) {
47
+ const safe = (0, sanitize_js_1.sanitizeUrl)(String(rawValue));
48
+ if (!safe)
49
+ continue;
50
+ attrs += ` ${key}="${safe}"`;
51
+ continue;
52
+ }
53
+ attrs += ` ${key}="${(0, sanitize_js_1.escapeHtml)(String(rawValue))}"`;
54
+ }
55
+ return { attrs, className };
56
+ }
57
+ /**
58
+ * Normalizes `HtmlGeneratorConfig.standalone` (`boolean | StandaloneConfig`) into a fully
59
+ * resolved object. `true`/undefined turns every part on (a complete standalone document);
60
+ * `false` turns every part off (a bare content fragment). When an object is passed, any field
61
+ * left unspecified defaults to its "on" value, matching the boolean-shorthand semantics.
62
+ */
63
+ function resolveStandalone(standalone) {
64
+ const uniform = (on) => ({
65
+ document: on,
66
+ metaTags: on,
67
+ styles: on ? 'full' : 'none',
68
+ scripts: on,
69
+ headInjections: on,
70
+ bodyInjections: on,
71
+ });
72
+ if (standalone === undefined || typeof standalone === 'boolean') {
73
+ return uniform(standalone ?? true);
74
+ }
75
+ const on = uniform(true);
76
+ return {
77
+ document: standalone.document ?? on.document,
78
+ metaTags: standalone.metaTags ?? on.metaTags,
79
+ styles: standalone.styles ?? on.styles,
80
+ scripts: standalone.scripts ?? on.scripts,
81
+ headInjections: standalone.headInjections ?? on.headInjections,
82
+ bodyInjections: standalone.bodyInjections ?? on.bodyInjections,
83
+ };
84
+ }
5
85
  /**
6
86
  * Generates semantic, high-fidelity HTML from an AST.
7
87
  */
@@ -29,11 +109,31 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
29
109
  containerClass = 'pdf-container';
30
110
  let bodyContent = await this.processNodeArray(this.ast.content);
31
111
  if (this.collectedNotes.length > 0) {
32
- let notesHtml = '';
33
- for (const note of this.collectedNotes) {
34
- notesHtml += await this.processNodeRecursive(note, this.nodeProcessor.bind(this));
112
+ // Footnotes/endnotes get their own <section data-footnotes> (the agreed
113
+ // contract with inscript-editor's footnote node); other note types (e.g.
114
+ // slide speaker notes) keep the existing generic notes wrapper.
115
+ const footnotes = this.collectedNotes.filter(n => {
116
+ const t = n.metadata?.noteType;
117
+ return t === 'footnote' || t === 'endnote';
118
+ });
119
+ const otherNotes = this.collectedNotes.filter(n => !footnotes.includes(n));
120
+ if (footnotes.length > 0) {
121
+ let footnotesHtml = '';
122
+ for (const note of footnotes) {
123
+ footnotesHtml += await this.processNodeRecursive(note, this.nodeProcessor.bind(this));
124
+ }
125
+ // data-footnotes carries an explicit empty value (not a bare attribute) so
126
+ // the markup is valid XHTML too - EpubGenerator embeds this verbatim, and
127
+ // XML rejects valueless attributes. HtmlParser only checks for presence.
128
+ bodyContent += `\n<section data-footnotes="">\n${footnotesHtml}\n</section>\n`;
129
+ }
130
+ if (otherNotes.length > 0) {
131
+ let notesHtml = '';
132
+ for (const note of otherNotes) {
133
+ notesHtml += await this.processNodeRecursive(note, this.nodeProcessor.bind(this));
134
+ }
135
+ bodyContent += `\n<div class="document-notes-section">\n<hr class="page-break">\n${notesHtml}\n</div>\n`;
35
136
  }
36
- bodyContent += `\n<div class="document-notes-section">\n<hr class="page-break">\n${notesHtml}\n</div>\n`;
37
137
  }
38
138
  const metadataBlock = this.config.renderMetadata ? this.renderMetadataSummary() : '';
39
139
  let title = 'Document';
@@ -214,28 +314,38 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
214
314
  else window.addEventListener('load', switchSheet);
215
315
  </script>`;
216
316
  }
217
- if (this.config.htmlConfig.standalone) {
218
- title = this.ast.metadata?.title || 'Document';
317
+ const sa = resolveStandalone(this.config.htmlConfig.standalone);
318
+ if (sa.document && sa.metaTags) {
319
+ title = this.effectiveMetadata.title || 'Document';
219
320
  metaTags = this.renderMetaTags();
220
321
  }
221
- const styles = this.config.htmlConfig.standalone ? '' : `<style>${this.getPremiumStyles(this.isSpreadsheetMode, isPresentation, isPdf)}</style>`;
322
+ const styleBlock = sa.styles === 'none' ? '' : `<style>${sa.styles === 'scoped'
323
+ ? this.getScopedPremiumStyles(this.isSpreadsheetMode, isPresentation, isPdf)
324
+ : this.getPremiumStyles(this.isSpreadsheetMode, isPresentation, isPdf)}</style>`;
325
+ // 'scoped' styles are anchored to this wrapper via CSS @scope, so custom properties and
326
+ // base body-level styling attach here instead of leaking onto a host page's real <html>/
327
+ // <body> when the output is embedded as a fragment.
328
+ const scopeOpen = sa.styles === 'scoped' ? '<div class="op-html-scope">' : '';
329
+ const scopeClose = sa.styles === 'scoped' ? '</div>' : '';
222
330
  const inj = this.config.htmlConfig.injections;
223
- const value = this.config.htmlConfig.standalone ? `<!DOCTYPE html>
331
+ const headInjectionsOn = sa.document && sa.headInjections;
332
+ const chartScriptTag = (sa.scripts && this.config.includeCharts) ? `<script src="${this.config.htmlConfig.chartJsSrc}"></script>` : '';
333
+ const spreadsheetScriptOut = sa.scripts ? spreadsheetScript : '';
334
+ const value = sa.document ? `<!DOCTYPE html>
224
335
  <html lang="en">
225
336
  <head>
226
- ${inj.headStart}
337
+ ${headInjectionsOn ? inj.headStart : ''}
227
338
  <meta charset="UTF-8">
228
339
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
229
340
  <title>${this.escape(title)}</title>
230
341
  ${metaTags}
231
- ${this.config.includeCharts ? `<script src="${this.config.htmlConfig.chartJsSrc}"></script>` : ''}
232
- <style>
233
- ${this.getPremiumStyles(this.isSpreadsheetMode, isPresentation, isPdf)}
234
- </style>
235
- ${inj.headEnd}
342
+ ${chartScriptTag}
343
+ ${styleBlock}
344
+ ${headInjectionsOn ? inj.headEnd : ''}
236
345
  </head>
237
346
  <body>
238
- ${inj.bodyStart}
347
+ ${sa.bodyInjections ? inj.bodyStart : ''}
348
+ ${scopeOpen}
239
349
  <div class="${containerClass}">
240
350
  <article>
241
351
  ${metadataBlock}
@@ -243,10 +353,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
243
353
  </article>
244
354
  ${spreadsheetTabs}
245
355
  </div>
246
- ${spreadsheetScript}
247
- ${inj.bodyEnd}
356
+ ${scopeClose}
357
+ ${spreadsheetScriptOut}
358
+ ${sa.bodyInjections ? inj.bodyEnd : ''}
248
359
  </body>
249
- </html>` : `${styles}<div class="${containerClass}">${metadataBlock}${bodyContent}${spreadsheetTabs}</div>${spreadsheetScript}`;
360
+ </html>` : `${styleBlock}${sa.bodyInjections ? inj.bodyStart : ''}${scopeOpen}<div class="${containerClass}">${metadataBlock}${bodyContent}${spreadsheetTabs}</div>${scopeClose}${spreadsheetScriptOut}${sa.bodyInjections ? inj.bodyEnd : ''}`;
250
361
  return {
251
362
  value,
252
363
  messages: this.messages
@@ -255,18 +366,27 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
255
366
  renderMetaTags() {
256
367
  if (!this.ast?.metadata)
257
368
  return '';
258
- const m = this.ast.metadata;
369
+ const m = this.effectiveMetadata;
259
370
  const tags = [];
260
371
  if (m.author)
261
372
  tags.push(`<meta name="author" content="${this.escape(m.author)}">`);
262
373
  if (m.description)
263
374
  tags.push(`<meta name="description" content="${this.escape(m.description)}">`);
264
- if (m.created)
265
- tags.push(`<meta name="dcterms.created" content="${new Date(m.created).toISOString()}">`);
266
- if (m.modified)
267
- tags.push(`<meta name="dcterms.modified" content="${new Date(m.modified).toISOString()}">`);
375
+ const created = this.toIsoDate(m.created);
376
+ const modified = this.toIsoDate(m.modified);
377
+ if (created)
378
+ tags.push(`<meta name="dcterms.created" content="${created}">`);
379
+ if (modified)
380
+ tags.push(`<meta name="dcterms.modified" content="${modified}">`);
268
381
  if (m.lastModifiedBy)
269
382
  tags.push(`<meta name="lastModifiedBy" content="${this.escape(m.lastModifiedBy)}">`);
383
+ // Both are parsed from every OOXML/ODF core-properties block but had no output sink at
384
+ // all, so they were silently dropped on every conversion. `keywords` is the standard HTML
385
+ // meta name; `subject` has no standard equivalent, so it uses its Dublin Core name.
386
+ if (m.keywords)
387
+ tags.push(`<meta name="keywords" content="${this.escape(m.keywords)}">`);
388
+ if (m.subject)
389
+ tags.push(`<meta name="DC.subject" content="${this.escape(m.subject)}">`);
270
390
  if (m.customProperties) {
271
391
  for (const [key, val] of Object.entries(m.customProperties)) {
272
392
  tags.push(`<meta name="custom:${this.escape(key)}" content="${this.escape(String(val))}">`);
@@ -277,7 +397,7 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
277
397
  renderMetadataSummary() {
278
398
  if (!this.ast?.metadata)
279
399
  return '';
280
- const m = this.ast.metadata;
400
+ const m = this.effectiveMetadata;
281
401
  let customPropsHtml = '';
282
402
  if (m.customProperties && Object.keys(m.customProperties).length > 0) {
283
403
  const items = Object.entries(m.customProperties)
@@ -312,12 +432,18 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
312
432
  */
313
433
  async processNodeArray(nodes) {
314
434
  let html = '';
315
- // Stack to track active lists: { indentation, type }
435
+ // Stack to track active lists: { indentation, type, isTask }
316
436
  const listStack = [];
437
+ const openListTag = (type, isTask) => {
438
+ if (isTask)
439
+ return '<ul data-type="taskList">';
440
+ return type === 'ordered' ? '<ol>' : '<ul>';
441
+ };
442
+ const closeListTag = (type) => type === 'ordered' ? '</ol>' : '</ul>';
317
443
  const closeListsToLevel = (level) => {
318
444
  while (listStack.length > 0 && listStack[listStack.length - 1].indentation > level) {
319
445
  const list = listStack.pop();
320
- html += (list?.type === 'ordered' ? '</ol>' : '</ul>') + '\n\n';
446
+ html += closeListTag(list.type) + '\n\n';
321
447
  }
322
448
  };
323
449
  for (const node of nodes) {
@@ -329,23 +455,24 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
329
455
  if (node.type === 'list') {
330
456
  const meta = node.metadata;
331
457
  const type = meta?.listType === 'ordered' ? 'ordered' : 'unordered';
458
+ const isTask = !!meta?.isTask;
332
459
  const indentation = meta?.indentation || 0;
333
460
  // Close deeper lists
334
461
  closeListsToLevel(indentation);
335
462
  // Handle current level
336
463
  if (listStack.length > 0 && listStack[listStack.length - 1].indentation === indentation) {
337
- if (listStack[listStack.length - 1].type !== type) {
464
+ if (listStack[listStack.length - 1].type !== type || listStack[listStack.length - 1].isTask !== isTask) {
338
465
  // Type changed at same level
339
466
  const last = listStack.pop();
340
- html += (last?.type === 'ordered' ? '</ol>' : '</ul>') + '\n';
341
- html += (type === 'ordered' ? '<ol>' : '<ul>') + '\n';
342
- listStack.push({ indentation, type });
467
+ html += closeListTag(last.type) + '\n';
468
+ html += openListTag(type, isTask) + '\n';
469
+ listStack.push({ indentation, type, isTask });
343
470
  }
344
471
  }
345
472
  else {
346
473
  // Start a new nested list
347
- html += (type === 'ordered' ? '<ol>' : '<ul>') + '\n';
348
- listStack.push({ indentation, type });
474
+ html += openListTag(type, isTask) + '\n';
475
+ listStack.push({ indentation, type, isTask });
349
476
  }
350
477
  html += await this.processNodeRecursive(node, this.nodeProcessor.bind(this), override);
351
478
  }
@@ -371,6 +498,10 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
371
498
  */
372
499
  tableNestingLevel = 0;
373
500
  async processNodeRecursive(node, processor, override) {
501
+ // Mirrors the check in BaseGenerator.processNodeRecursive. This override replaces that
502
+ // method entirely, so without repeating the check here the signal would be silently
503
+ // inert for this generator - which is exactly how it was missed.
504
+ (0, errorUtils_js_1.checkAbortSignal)(this.config.abortSignal);
374
505
  // Use pre-evaluated override if provided, otherwise call handleOnNode
375
506
  const actualOverride = override !== undefined ? override : await this.handleOnNode(node);
376
507
  // Returning false skips the node and its children
@@ -405,6 +536,18 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
405
536
  result += await this.processNodeRecursive(note, processor);
406
537
  }
407
538
  }
539
+ else if (node.notes && node.notes.length > 0) {
540
+ // Emit the reference marker at the point of citation. Without this, a
541
+ // footnote/endnote would only ever show up in the collected footnotes
542
+ // section, with no indication of where it was originally cited.
543
+ for (const note of node.notes) {
544
+ const meta = note.metadata;
545
+ if (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') {
546
+ const key = this.escape(this.getFootnoteKey(note));
547
+ result += `<sup data-footnote-ref="${key}" id="footnote-ref-${key}"><a href="#footnote-${key}">${key}</a></sup>`;
548
+ }
549
+ }
550
+ }
408
551
  return result;
409
552
  }
410
553
  /**
@@ -413,17 +556,48 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
413
556
  async nodeProcessor(node, childrenOutput) {
414
557
  // Handle Style Mapping using the semantic mapping helper
415
558
  const mapping = this.getSemanticMapping(node);
416
- let tag = mapping?.tag || this.getDefaultTag(node);
417
- // Combine classes from mapping and defaults
418
- const classes = mapping?.classes ? [...mapping.classes] : [];
419
- const className = classes.length > 0 ? ` class="${classes.join(' ')}"` : '';
559
+ // A styleMap's `output.tag` was previously written here and then shadowed by a `const tag`
560
+ // in every switch branch, so HtmlGenerator silently ignored it - while MarkdownGenerator
561
+ // and RtfGenerator both honoured it and the README documented it as working. Honoured now,
562
+ // but only through the allowlist: the tag is interpolated into both `<TAG>` and `</TAG>`,
563
+ // and the shadowing bug was the sole reason a hostile value could not inject. A rejected
564
+ // tag falls back to the default rather than being emitted.
565
+ const mappedTag = (0, sanitize_js_1.isSafeStyleMapTag)(mapping?.tag) ? mapping.tag.toLowerCase() : undefined;
566
+ if (mapping?.tag && !mappedTag) {
567
+ this.warn(types_js_1.OfficeWarningType.INVALID_STYLE_MAP_TAG, mapping.tag, node);
568
+ }
420
569
  // Handle Attributes from mapping
421
570
  let mappedAttrs = '';
422
571
  if (mapping?.attributes) {
423
572
  for (const [key, val] of Object.entries(mapping.attributes)) {
573
+ // The value is escaped, but the NAME needs validating too: a key containing a
574
+ // quote closes the attribute and opens another, so `x" onmouseover="alert(1)" z`
575
+ // yields a live event handler however carefully the value is escaped. The
576
+ // attribute bag and the parser already apply this policy; this path did not.
577
+ if (!(0, sanitize_js_1.isSafeHtmlAttributeName)(key))
578
+ continue;
424
579
  mappedAttrs += ` ${key}="${this.escape(val)}"`;
425
580
  }
426
581
  }
582
+ // Preserved source attributes (opt-in; absent unless htmlParserConfig.preserveAttributes).
583
+ // Dedupe against whatever the mapping already emitted so a typed field always wins, and
584
+ // take the bag's `class` back as a value to merge below rather than a second attribute.
585
+ const bag = renderHtmlAttributeBag(node, Object.keys(mapping?.attributes || {}));
586
+ // Fold into mappedAttrs rather than threading a separate fragment through all ~20 emission
587
+ // sites: every site already interpolates mappedAttrs, so this cannot miss one (a miss would
588
+ // silently drop preserved attributes), and an empty bag contributes nothing, so output for
589
+ // nodes without one stays byte-identical.
590
+ mappedAttrs += bag.attrs;
591
+ // Combine classes from mapping, defaults, and any preserved source class. Merging here is
592
+ // what keeps `<p class="lead">` from either losing "lead" or emitting a duplicate `class`.
593
+ const classes = mapping?.classes ? [...mapping.classes] : [];
594
+ if (bag.className) {
595
+ for (const c of bag.className.split(/\s+/).filter(Boolean)) {
596
+ if (!classes.includes(c))
597
+ classes.push(c);
598
+ }
599
+ }
600
+ const className = classes.length > 0 ? ` class="${this.escape(classes.join(' '))}"` : '';
427
601
  // Handle ID and Anchors
428
602
  let idAttr = '';
429
603
  let extraAnchors = '';
@@ -442,9 +616,9 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
442
616
  }
443
617
  }
444
618
  if (anchorIds.length > 0) {
445
- idAttr = ` id="${anchorIds[0]}"`;
619
+ idAttr = ` id="${this.escape(anchorIds[0])}"`;
446
620
  if (anchorIds.length > 1) {
447
- extraAnchors = anchorIds.slice(1).map(aid => `<a id="${aid}" name="${aid}"></a>`).join('');
621
+ extraAnchors = anchorIds.slice(1).map(aid => `<a id="${this.escape(aid)}" name="${this.escape(aid)}"></a>`).join('');
448
622
  }
449
623
  }
450
624
  // Inline Styles for structural nodes
@@ -469,7 +643,30 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
469
643
  src = `data:${attachment.mimeType || 'image/png'};base64,${attachment.data}`;
470
644
  }
471
645
  }
472
- const img = `<img src="${this.escape(src)}" alt="${this.escape(node.text || meta?.altText || '')}"${className}${mappedAttrs}${styleAttr}>`;
646
+ // Match CustomImage's exact data-width/data-align + style contract so a loaded
647
+ // image re-hydrates the editor node without losing size/alignment.
648
+ let imgDataAttrs = '';
649
+ const imgStyleParts = [];
650
+ const baseImgStyle = this.getInlineStyles(node);
651
+ if (baseImgStyle)
652
+ imgStyleParts.push(baseImgStyle);
653
+ if (meta?.width) {
654
+ imgDataAttrs += ` data-width="${this.escape(meta.width)}"`;
655
+ // Sanitize before it enters the style="" attribute: an unescaped width
656
+ // (e.g. `1px" onerror="alert(1)`) would otherwise break out and inject an
657
+ // event handler, and a CSS `url(...)` would fetch a remote resource.
658
+ const safeWidth = (0, sanitize_js_1.sanitizeCssValue)(meta.width);
659
+ if (safeWidth)
660
+ imgStyleParts.push(`width: ${safeWidth}`);
661
+ }
662
+ if (meta?.align) {
663
+ imgDataAttrs += ` data-align="${this.escape(meta.align)}"`;
664
+ const ml = meta.align === 'left' ? '0' : 'auto';
665
+ const mr = meta.align === 'right' ? '0' : 'auto';
666
+ imgStyleParts.push('display: block', `margin-left: ${ml}`, `margin-right: ${mr}`);
667
+ }
668
+ const imgStyleAttr = imgStyleParts.length > 0 ? ` style="${imgStyleParts.join('; ')}"` : '';
669
+ const img = `<img src="${(0, sanitize_js_1.sanitizeImageUrl)(src)}" alt="${this.escape(node.text || meta?.altText || '')}"${className}${mappedAttrs}${imgDataAttrs}${imgStyleAttr}>`;
473
670
  const content = this.config.includeFormatting ? `<div class="image-container">${img}<div class="caption">${this.escape(attachmentName || '')}</div></div>` : img;
474
671
  return `${extraAnchors}<div${idAttr}>${content}</div>`;
475
672
  }
@@ -489,7 +686,7 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
489
686
  (function() {
490
687
  const initChart = () => {
491
688
  const ctx = document.getElementById('${chartId}').getContext('2d');
492
- const chartData = ${JSON.stringify(chartData)};
689
+ const chartData = ${(0, sanitize_js_1.serializeForInlineScript)(chartData)};
493
690
  const getRandomColor = (index, alpha) => {
494
691
  const colors = [
495
692
  'rgba(255, 99, 132, ' + alpha + ')',
@@ -556,6 +753,15 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
556
753
  return node.metadata?.breakType === 'page' ? '<hr class="page-break">' : '<br>';
557
754
  case 'code': {
558
755
  const meta = node.metadata;
756
+ if (meta?.math) {
757
+ // No pinned editor contract yet (inscript-editor's math node is v1.2,
758
+ // not built) - this is the proposed shape: data-math signals the
759
+ // display mode, and the visible text keeps its $ delimiters so the
760
+ // raw LaTeX degrades gracefully without a KaTeX renderer.
761
+ const delimited = meta.math === 'block' ? `$$${node.text || ''}$$` : `$${node.text || ''}$`;
762
+ const tag = meta.math === 'block' ? 'div' : 'span';
763
+ return `${extraAnchors}<${tag} class="math math-${this.escape(meta.math)}" data-math="${this.escape(meta.math)}"${idAttr}${mappedAttrs}${styleAttr}>${this.escape(delimited)}</${tag}>`;
764
+ }
559
765
  const lang = meta?.language ? ` class="language-${this.escape(meta.language)}"` : '';
560
766
  const codeHtml = `<code${lang}>${this.escape(node.text || '')}</code>`;
561
767
  if (node.text && node.text.includes('\n')) {
@@ -567,6 +773,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
567
773
  }
568
774
  case 'list': {
569
775
  const meta = node.metadata;
776
+ if (meta?.isTask) {
777
+ const checkedAttr = ` data-checked="${meta.checked ? 'true' : 'false'}"`;
778
+ const checkedBool = meta.checked ? ' checked' : '';
779
+ return `${extraAnchors}<li${checkedAttr}${idAttr}${className}${mappedAttrs}${styleAttr}><label><input type="checkbox"${checkedBool}><span></span></label><div>${childrenOutput}</div></li>`;
780
+ }
570
781
  const value = (meta?.listType === 'ordered' && typeof meta.itemIndex === 'number')
571
782
  ? ` value="${meta.itemIndex + 1}"`
572
783
  : '';
@@ -595,7 +806,21 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
595
806
  finalChildren = `<thead>${headOutput}</thead><tbody>${bodyOutput}</tbody>`;
596
807
  }
597
808
  }
598
- const tableHtml = `<table${idAttr}${className}${mappedAttrs}${styleAttr}>${finalChildren}</table>`;
809
+ // Match CustomTable's exact data-align + margin style contract so a loaded
810
+ // table re-hydrates the editor node without losing its layout alignment.
811
+ const tableMeta = node.metadata;
812
+ let tableDataAttrs = '';
813
+ let tableStyleAttr = styleAttr;
814
+ if (tableMeta?.align) {
815
+ tableDataAttrs = ` data-align="${this.escape(tableMeta.align)}"`;
816
+ const ml = tableMeta.align === 'left' ? '0' : 'auto';
817
+ const mr = tableMeta.align === 'right' ? '0' : 'auto';
818
+ const marginStyle = `margin-left: ${ml}; margin-right: ${mr}`;
819
+ tableStyleAttr = styleAttr
820
+ ? ` style="${styleAttr.replace(/^ style="|"$/g, '')}; ${marginStyle}"`
821
+ : ` style="${marginStyle}"`;
822
+ }
823
+ const tableHtml = `<table${idAttr}${className}${mappedAttrs}${tableDataAttrs}${tableStyleAttr}>${finalChildren}</table>`;
599
824
  const result = this.tableNestingLevel > 1 ? tableHtml : `<div class="table-container">${tableHtml}</div>`;
600
825
  return `${extraAnchors}${result}`;
601
826
  }
@@ -706,15 +931,20 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
706
931
  const rClasses = ['excel-row'];
707
932
  if (mapping?.classes)
708
933
  rClasses.push(...mapping.classes);
709
- trAttrs += ` class="${rClasses.join(' ')}"`;
934
+ // Escaped like the `className` built for every other node type. This
935
+ // path rebuilds the class attribute from the raw mapping array rather
936
+ // than reusing that value, and was the only place it went out unescaped.
937
+ trAttrs += ` class="${this.escape(rClasses.join(' '))}"`;
710
938
  if (mapping?.attributes) {
711
939
  for (const [key, val] of Object.entries(mapping.attributes)) {
940
+ if (!(0, sanitize_js_1.isSafeHtmlAttributeName)(key))
941
+ continue;
712
942
  trAttrs += ` ${key}="${this.escape(val)}"`;
713
943
  }
714
944
  }
715
945
  const rAnchorIds = this.config.ignoreInternalLinks ? [] : [...(rowNode.metadata?.anchorIds || [])];
716
946
  if (rAnchorIds.length > 0) {
717
- trAttrs += ` id="${rAnchorIds[0]}"`;
947
+ trAttrs += ` id="${this.escape(rAnchorIds[0])}"`;
718
948
  }
719
949
  if (this.config.includeFormatting) {
720
950
  const styles = this.getInlineStyles(rowNode);
@@ -760,14 +990,18 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
760
990
  mergedClasses.push('active');
761
991
  if (classes.length > 0)
762
992
  mergedClasses.push(...classes);
763
- const classAttr = ` class="${mergedClasses.join(' ')}"`;
993
+ // Escaped for the same reason as the row above; `classes` here also carries the
994
+ // attribute bag's raw className, so escaping at the join covers both sources.
995
+ const classAttr = ` class="${this.escape(mergedClasses.join(' '))}"`;
764
996
  // Ensure we don't have duplicate IDs
765
997
  const finalIdAttr = ` id="${sheetId}"`;
766
998
  return `${extraAnchors}<div${finalIdAttr}${classAttr}${mappedAttrs}${styleAttr}>${tableHtml}${nonRowHtml}</div>`;
767
999
  }
768
1000
  case 'paragraph':
769
1001
  case 'heading': {
770
- const tag = node.type === 'heading' ? `h${node.metadata?.level || 1}` : 'p';
1002
+ // The styleMap tag wins over the structural default; that is the whole point of
1003
+ // mapping "Heading 1"/"Intense Quote" onto a semantic element.
1004
+ const tag = mappedTag ?? (node.type === 'heading' ? `h${node.metadata?.level || 1}` : 'p');
771
1005
  // Normalize empty paragraphs so DOCX and PPTX empty cells render with consistent height
772
1006
  // Strip tags to check if it's purely empty or just contains non-breaking spaces (like PPTX)
773
1007
  const textOnly = childrenOutput.replace(/<[^>]+>/g, '').trim();
@@ -779,19 +1013,50 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
779
1013
  }
780
1014
  case 'slide': {
781
1015
  const meta = node.metadata;
782
- const slideNum = meta?.slideNumber || '';
1016
+ const slideNum = this.escape(String(meta?.slideNumber || ''));
783
1017
  return `${extraAnchors}<section class="slide" data-slide-num="${slideNum}"${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}</section>`;
784
1018
  }
785
1019
  case 'page': {
786
1020
  const meta = node.metadata;
787
- const pageNum = meta?.pageNumber || '';
1021
+ const pageNum = this.escape(String(meta?.pageNumber || ''));
788
1022
  return `${extraAnchors}<section class="page" data-page-num="${pageNum}"${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}</section>`;
789
1023
  }
790
1024
  case 'note': {
791
1025
  const meta = node.metadata;
792
- const noteClass = meta?.noteType ? ` note-${meta.noteType}` : '';
1026
+ if (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') {
1027
+ const key = this.escape(this.getFootnoteKey(node));
1028
+ return `<p id="footnote-${key}" data-footnote-id="${key}">${childrenOutput} <a href="#footnote-ref-${key}">↩</a></p>`;
1029
+ }
1030
+ const noteClass = meta?.noteType ? ` note-${this.escape(meta.noteType)}` : '';
793
1031
  return `${extraAnchors}<div class="slide-note${noteClass}"${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}</div>`;
794
1032
  }
1033
+ case 'embed': {
1034
+ // Match the Youtube extension's exact wrapper shape so a loaded embed
1035
+ // re-hydrates the editor's Youtube node.
1036
+ const meta = node.metadata;
1037
+ const id = meta?.videoId || '';
1038
+ const width = meta?.width || '100%';
1039
+ const align = meta?.align || 'center';
1040
+ const ml = align === 'left' ? '0' : 'auto';
1041
+ const mr = align === 'right' ? '0' : 'auto';
1042
+ const iframe = id
1043
+ ? `<iframe src="https://www.youtube.com/embed/${this.escape(id)}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
1044
+ : '';
1045
+ return `${extraAnchors}<div data-youtube-video="${this.escape(id)}" data-width="${this.escape(width)}" data-align="${this.escape(align)}" class="youtube-embed"${idAttr}${mappedAttrs} style="width: ${(0, sanitize_js_1.sanitizeCssValue)(width)}; margin-left: ${ml}; margin-right: ${mr};">${iframe}</div>`;
1046
+ }
1047
+ case 'admonition': {
1048
+ // Match inscript-editor's Admonition node wrapper so a loaded admonition
1049
+ // reaches the editor as that node instead of a plain blockquote.
1050
+ const meta = node.metadata;
1051
+ const admonitionType = this.escape(meta?.admonitionType || 'note');
1052
+ return `${extraAnchors}<div class="admonition admonition-${admonitionType}" data-type="${admonitionType}"${idAttr}${mappedAttrs}${styleAttr}>${childrenOutput}</div>`;
1053
+ }
1054
+ case 'definitionList':
1055
+ return `${extraAnchors}<dl${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}</dl>`;
1056
+ case 'definitionTerm':
1057
+ return `${extraAnchors}<dt${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}</dt>`;
1058
+ case 'definitionDescription':
1059
+ return `${extraAnchors}<dd${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}</dd>`;
795
1060
  default:
796
1061
  return childrenOutput;
797
1062
  }
@@ -829,24 +1094,50 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
829
1094
  }
830
1095
  }
831
1096
  const meta = node.metadata;
832
- if (meta?.link) {
1097
+ if (meta?.wikilink) {
1098
+ // No pinned editor contract yet for wikilinks - data-wikilink-page preserves the
1099
+ // exact page name separately from the display text/alias and from href (which
1100
+ // the host app's resolver may rewrite to a real URL).
1101
+ if (!this.config.ignoreInternalLinks) {
1102
+ result = `<a href="#${this.escape(this.slugify(meta.link || ''))}" data-wikilink-page="${this.escape(meta.link || '')}">${result}</a>`;
1103
+ }
1104
+ }
1105
+ else if (meta?.link) {
833
1106
  const isInternal = meta.linkType !== 'external';
834
1107
  if (!this.config.ignoreInternalLinks || !isInternal) {
835
- result = `<a href="${meta.link}"${meta.linkType === 'external' ? ' target="_blank"' : ''}>${result}</a>`;
1108
+ result = `<a href="${(0, sanitize_js_1.sanitizeUrl)(meta.link)}"${meta.linkType === 'external' ? ' target="_blank"' : ''}>${result}</a>`;
836
1109
  }
837
1110
  }
1111
+ if (meta?.abbreviationTitle) {
1112
+ result = `<abbr title="${this.escape(meta.abbreviationTitle)}">${result}</abbr>`;
1113
+ }
1114
+ if (meta?.citationKey) {
1115
+ // No pinned editor contract yet for citations (unlike footnotes/admonitions) -
1116
+ // this is the proposed shape: a <cite> carrying the bare key, matching Pandoc's
1117
+ // [@citekey] on the Markdown side. Keep in sync if inscript-editor's citation
1118
+ // node lands with a different attribute name.
1119
+ result = `<cite data-citation-key="${this.escape(meta.citationKey)}">[@${this.escape(meta.citationKey)}]</cite>`;
1120
+ }
838
1121
  return result;
839
1122
  }
840
1123
  getInlineStyles(node) {
841
1124
  const styles = [];
1125
+ // Colors/sizes/fonts/alignments are free strings from an untrusted document;
1126
+ // run each through sanitizeCssValue so it can't break out of the style="" attribute
1127
+ // or inject a resource-fetching CSS construct. Drop the declaration if nothing survives.
1128
+ const pushSafe = (prop, value) => {
1129
+ const safe = (0, sanitize_js_1.sanitizeCssValue)(value);
1130
+ if (safe)
1131
+ styles.push(`${prop}: ${safe}`);
1132
+ };
842
1133
  if (node.metadata) {
843
1134
  const meta = node.metadata;
844
1135
  if (meta.alignment)
845
- styles.push(`text-align: ${meta.alignment}`);
1136
+ pushSafe('text-align', meta.alignment);
846
1137
  if (meta.backgroundColor)
847
- styles.push(`background-color: ${meta.backgroundColor}`);
1138
+ pushSafe('background-color', meta.backgroundColor);
848
1139
  if (meta.verticalAlign)
849
- styles.push(`vertical-align: ${meta.verticalAlign}`);
1140
+ pushSafe('vertical-align', meta.verticalAlign);
850
1141
  if (meta.paragraphIndentation) {
851
1142
  const ind = meta.paragraphIndentation;
852
1143
  if (ind.left)
@@ -860,13 +1151,16 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
860
1151
  if (node.formatting) {
861
1152
  const f = node.formatting;
862
1153
  if (f.color)
863
- styles.push(`color: ${f.color}`);
1154
+ pushSafe('color', f.color);
864
1155
  if (f.backgroundColor)
865
- styles.push(`background-color: ${f.backgroundColor}`);
1156
+ pushSafe('background-color', f.backgroundColor);
866
1157
  if (f.size)
867
- styles.push(`font-size: ${f.size}`);
868
- if (f.font)
869
- styles.push(`font-family: ${f.font}, sans-serif`);
1158
+ pushSafe('font-size', f.size);
1159
+ if (f.font) {
1160
+ const safeFont = (0, sanitize_js_1.sanitizeCssValue)(f.font);
1161
+ if (safeFont)
1162
+ styles.push(`font-family: ${safeFont}, sans-serif`);
1163
+ }
870
1164
  }
871
1165
  return styles.join('; ');
872
1166
  }
@@ -1431,6 +1725,20 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1431
1725
  ${this.config.htmlConfig.customCss}
1432
1726
  `;
1433
1727
  }
1728
+ /**
1729
+ * Same as `getPremiumStyles()`, but wrapped in a CSS `@scope` block anchored to the
1730
+ * `.op-html-scope` wrapper so the rules only apply within the generated fragment - they
1731
+ * cannot leak onto a host page's own elements. `:root` and `body` selectors specifically
1732
+ * target the real page root/body, so they're remapped to `:scope` (the scope root, i.e. the
1733
+ * `.op-html-scope` wrapper) first; every other selector is naturally confined by `@scope`
1734
+ * without needing per-selector rewriting. `customCss` is included in this scoping too.
1735
+ */
1736
+ getScopedPremiumStyles(isSpreadsheet = false, isPresentation = false, isPdf = false) {
1737
+ const css = this.getPremiumStyles(isSpreadsheet, isPresentation, isPdf)
1738
+ .replace(/:root(\s*\{)/g, ':scope$1')
1739
+ .replace(/(^|\n)(\s*)body(\s*\{)/g, '$1$2:scope$3');
1740
+ return `@scope (.op-html-scope) {\n${css}\n}`;
1741
+ }
1434
1742
  slugify(text) {
1435
1743
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
1436
1744
  }
@@ -1443,10 +1751,19 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1443
1751
  }
1444
1752
  return letter;
1445
1753
  }
1754
+ // Attribute/text escaping, URL sanitizing, and inline-script serialization all
1755
+ // live in ../utils/sanitize.js so every generator shares one implementation.
1756
+ // escape() stays as a thin wrapper because it has many call sites here.
1446
1757
  escape(text) {
1447
- if (typeof text !== 'string')
1448
- return text;
1449
- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1758
+ return (0, sanitize_js_1.escapeHtml)(text);
1759
+ }
1760
+ /** Converts a document-supplied date to an ISO string, or '' if it is invalid
1761
+ * (a malformed date would otherwise throw a RangeError and abort generation). */
1762
+ toIsoDate(value) {
1763
+ if (value === undefined || value === null || value === '')
1764
+ return '';
1765
+ const d = new Date(value);
1766
+ return isNaN(d.getTime()) ? '' : d.toISOString();
1450
1767
  }
1451
1768
  }
1452
1769
  exports.HtmlGenerator = HtmlGenerator;