officeparser 7.6.2 → 7.8.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/README.md CHANGED
@@ -758,7 +758,7 @@ Definition List Node (type: 'definitionList')
758
758
  ```
759
759
 
760
760
  - `admonition` round-trips through both Markdown (`> [!NOTE]` / `:::note ... :::`) and HTML (`<div class="admonition admonition-note" data-type="note">`)
761
- - `embed` currently models YouTube videos; HTML round-trips via `<div data-youtube-video="ID">`, Markdown falls back to a raw HTML block or a plain link
761
+ - `embed` models YouTube videos and generic iframes. Markdown form is selected by `mdConfig.dialect.embeds`: `'html'` (default; the `<div data-youtube-video>` / `<iframe>` block), `'directive'` (a `::youtube[…]{…}` / `::embed[…]{…}` leaf directive), `'link'`, or `'thumbnail'` (YouTube-only clickable preview). A generic iframe is captured only under `htmlParserConfig.preserveIframes` (the trust input) and can be emitted as an inert click-to-load placeholder via `htmlConfig.gatedEmbeds`. The `'directive'` form is an editor round-trip format, not GitHub-rendered
762
762
  - Abbreviations (`*[HTML]: Hypertext Markup Language`) are stored as `TextMetadata.abbreviationTitle` on the abbreviated text node rather than as a separate node type
763
763
 
764
764
  ---
@@ -780,7 +780,10 @@ idempotent and `.md → AST → HTML → AST → .md` survives unchanged.
780
780
  | Attribute lists | `![alt](img.png){width=50% .centered}` | `ImageMetadata.width` / `.align`, `TableMetadata.align` |
781
781
  | Citations | `[@smith2024]` | `TextMetadata.citationKey` |
782
782
  | Wikilinks | `[[Page]]` / `[[Page\|Alias]]` | `TextMetadata.wikilink`, `.link`, `.linkType` |
783
+ | Highlight | `==text==` | `TextMetadata.backgroundColor` |
784
+ | Link/image titles | `[text](url "Title")` / `![alt](img.png "Title")` | `TextMetadata.title` / `ImageMetadata.title` |
783
785
  | Inline/block math | `$E=mc^2$` / `` $$...$$ `` | `type: 'code'`, `CodeMetadata.math` (`'inline' \| 'block'`) |
786
+ | Embeds | `::youtube[Label]{id=… width=… align=…}` / `::embed[Label]{src=… …}` (leaf directive; see `mdConfig.dialect.embeds`) | `type: 'embed'`, `EmbedMetadata` |
784
787
  | Frontmatter arrays | `tags: [a, b]` or `tags: ["a","b"]` | Real array in `metadata.customProperties`/`nativeProperties` |
785
788
  | MDX components (import-only) | `<Component prop="x">...</Component>` | Stripped; inner Markdown is kept. Never generated back. |
786
789
 
@@ -795,7 +798,8 @@ save→reload cycle:
795
798
  | HTML attribute | AST field | Notes |
796
799
  |---|---|---|
797
800
  | `data-width` / `data-align` / inline `style="width:…"` on `<img>` | `ImageMetadata.width` / `.align` | |
798
- | `data-align` on `<table>` | `TableMetadata.align` | |
801
+ | `data-align` on `<table>` | `TableMetadata.align` | Emitted/parsed as per-column GFM markers (`:---`, `:---:`, `---:`); alignment rides `CellMetadata.align` |
802
+ | `title` on `<a>` / `<img>` | `TextMetadata.title` / `ImageMetadata.title` | Survives both directions (`[text](url "Title")` in Markdown) |
799
803
  | `colspan` / `rowspan` on `<td>`/`<th>` | `CellMetadata.colSpan` / `.rowSpan` | Previously dropped on HTML import — merged cells now survive a save→reload cycle |
800
804
  | `<div data-youtube-video="ID">` / `<iframe src="...youtube.com...">` | `type: 'embed'` | |
801
805
  | `<ul data-type="taskList">` / `<li data-checked>` | `ListMetadata.isTask` / `.checked` | |
@@ -985,7 +989,7 @@ Pass as the second argument to `parseOffice(file, config)`.
985
989
  | `fileType` | `SupportedFileType \| null` | `null` | **Required for text-based binary data** (`'md'`, `'html'`, `'csv'`) as these lack magic bytes. |
986
990
  | `csvDelimiter` | `string` | `','` | Input delimiter when parsing CSV files |
987
991
  | `decompressionLimits` | `DecompressionLimits` | `{ maxUncompressedBytes: 512MB, maxZipEntries: 10000, maxTableCells: 1000000 }` | **New**: Limits applied during ZIP extraction (and ODF cell expansion) to protect against excessive memory and resource usage |
988
- | `htmlParserConfig` | `HtmlParserConfig` | `{}` | HTML/XHTML/EPUB parsing options. `preserveAttributes` (`boolean`, default `false`): keep generic source attributes no typed field consumed on `node.htmlAttributes`. `preserveIframes` (`boolean \| string[]`, default `false`): preserve non-YouTube `<iframe>` embeds (otherwise dropped) as `embed` nodes — `true` for any, or a hostname allowlist; the src is scheme-checked on generation |
992
+ | `htmlParserConfig` | `HtmlParserConfig` | `{}` | HTML/XHTML/EPUB parsing options. `preserveAttributes` (`boolean`, default `false`): keep generic source attributes no typed field consumed on `node.htmlAttributes`. `preserveIframes` (`boolean \| string[]`, default `false`): preserve non-YouTube `<iframe>` embeds (otherwise dropped) as `embed` nodes — `true` for any, or a hostname allowlist; the src is scheme-checked on generation. `embedFolkForms` (`boolean`, default `false`): opt in to importing ambiguous folk embed forms (Obsidian `![](youtube-url)`, thumbnail-link) as YouTube embeds |
989
993
  | `pdfWorkerSrc` | `string` | CDN (jsDelivr) | Path/URL to `pdf.worker.min.mjs` (required in browser) |
990
994
  | `onWarning` | `(issue: OfficeIssue) => void` | — | Callback for non-fatal parsing issues |
991
995
  | `abortSignal` | `AbortSignal \| null` | `null` | Optional signal to cancel parsing (rejects with AbortError) |
@@ -1146,7 +1150,8 @@ Pass as `mdConfig` inside `GeneratorConfig`.
1146
1150
 
1147
1151
  | Option | Type | Default | Description |
1148
1152
  |--------|------|---------|-------------|
1149
- | `fallbackToHtml` | `boolean \| FallbackToHtmlConfig` | `true` | Use HTML tags for features Markdown cannot represent (underlines, merged table cells, embeds, etc.). Pass an object for per-feature control. `inlineFormatting` (default `false`, opt-in even when the boolean is `true`) additionally round-trips inline color/highlight/font-size as `<span style="...">` runs. |
1153
+ | `fallbackToHtml` | `boolean \| FallbackToHtmlConfig` | `true` | Use HTML tags for features Markdown cannot represent (underlines, merged table cells, embeds, etc.). Pass an object for per-feature control. `cellLineBreaks`/`itemLineBreaks` (default on) join multi-line table-cell / multi-paragraph list-item content with `<br>` instead of a space. `inlineFormatting` (default `false`, opt-in even when the boolean is `true`) additionally round-trips inline color/highlight/font-size as `<span style="...">` runs. |
1154
+ | `dialect` | `MarkdownDialectPreset \| MarkdownDialectConfig` | `'extended'` | Which native syntax to emit for constructs that differ across targets (GitHub/GitLab/Obsidian/Pandoc/CommonMark). Each capability is typed by the syntax it selects (e.g. `strikethrough: 'tilde'`, `highlight: 'equals'`, `admonitions: 'blockquote'`), with `'none'` to turn it off. See [Markdown Dialect Support](#markdown-dialect-support). The old `boolean` toggles and admonition flavour names (`'github'`/`'gitlab'`/`'pandoc'`) still work but are deprecated. |
1150
1155
 
1151
1156
  ### PdfGeneratorConfig
1152
1157
 
package/dist/defaults.js CHANGED
@@ -41,6 +41,7 @@ const DEFAULT_OCR_CONFIG = {
41
41
  const DEFAULT_HTML_PARSER_CONFIG = {
42
42
  preserveAttributes: false,
43
43
  preserveIframes: false,
44
+ embedFolkForms: false,
44
45
  };
45
46
  /**
46
47
  * Default configuration for the OfficeParser.
@@ -89,6 +90,7 @@ const DEFAULT_HTML_GENERATOR_CONFIG = {
89
90
  bodyEnd: '',
90
91
  },
91
92
  sourceAttributes: false,
93
+ gatedEmbeds: false,
92
94
  };
93
95
  /**
94
96
  * Default configuration for PDF generation.
@@ -446,7 +446,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
446
446
  */
447
447
  async processNodeArray(nodes) {
448
448
  let html = '';
449
- // Stack to track active lists: { indentation, type, isTask }
449
+ // Stack to track active lists. `liClose` is the currently-open item's deferred closing
450
+ // suffix (`</li>`, or `</div></li>` for a task item): a list item is rendered WITHOUT its
451
+ // close so a deeper list can land inside it (spec-valid `<li>a<ul>...</ul></li>` rather
452
+ // than the invalid `<li>a</li><ul>...</ul>` sibling shape). The close is emitted when a
453
+ // same-level sibling arrives, when the level is popped, or at the end.
450
454
  const listStack = [];
451
455
  const openListTag = (type, isTask) => {
452
456
  if (isTask)
@@ -457,7 +461,7 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
457
461
  const closeListsToLevel = (level) => {
458
462
  while (listStack.length > 0 && listStack[listStack.length - 1].indentation > level) {
459
463
  const list = listStack.pop();
460
- html += closeListTag(list.type) + '\n\n';
464
+ html += list.liClose + closeListTag(list.type) + '\n\n';
461
465
  }
462
466
  };
463
467
  for (const node of nodes) {
@@ -492,27 +496,42 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
492
496
  closeListsToLevel(indentation);
493
497
  // Handle current level
494
498
  if (listStack.length > 0 && listStack[listStack.length - 1].indentation === indentation) {
495
- if (listStack[listStack.length - 1].type !== type || listStack[listStack.length - 1].isTask !== isTask) {
496
- // Type changed at same level
499
+ const top = listStack[listStack.length - 1];
500
+ if (top.type !== type || top.isTask !== isTask) {
501
+ // Kind changed at the same level: close the open item and the old list,
502
+ // then open the replacement list.
497
503
  const last = listStack.pop();
498
- html += closeListTag(last.type) + '\n';
504
+ html += last.liClose + closeListTag(last.type) + '\n';
499
505
  html += openListTag(type, isTask) + '\n';
500
- listStack.push({ indentation, type, isTask });
506
+ listStack.push({ indentation, type, isTask, liClose: '' });
507
+ }
508
+ else {
509
+ // Sibling at the same level: close the previous item before this one opens.
510
+ html += top.liClose;
501
511
  }
502
512
  }
503
513
  else {
504
- // Start a new nested list
514
+ // Deeper level (or the first list): open a nested list INSIDE the currently
515
+ // open item, leaving the parent <li>'s close pending on its stack frame.
505
516
  html += openListTag(type, isTask) + '\n';
506
- listStack.push({ indentation, type, isTask });
517
+ listStack.push({ indentation, type, isTask, liClose: '' });
507
518
  }
508
519
  html += await this.processNodeRecursive(node, this.nodeProcessor.bind(this), override);
520
+ // Defer this item's close so a nested list can land inside it. A string override is
521
+ // a complete replacement item that already carries its own close, so add none.
522
+ listStack[listStack.length - 1].liClose = (typeof override === 'string')
523
+ ? ''
524
+ : (isTask ? '</div></li>' : '</li>');
509
525
  }
510
526
  else {
511
527
  // Non-list node closes all active lists
512
528
  closeListsToLevel(-1);
513
529
  let result = await this.processNodeRecursive(node, this.nodeProcessor.bind(this), override);
514
- // Add newlines for readability of the HTML source
515
- if (!result.endsWith('\n\n')) {
530
+ // Add a blank line after BLOCK nodes for readable HTML source. Inline nodes (a
531
+ // paragraph's text/link runs) must concatenate with no separator: adding `\n\n`
532
+ // around an inline <a> put a blank line inside the <p>, which reparsed as a stray
533
+ // space before the following punctuation (`[video](url) .`).
534
+ if (node.type !== 'text' && !result.endsWith('\n\n')) {
516
535
  if (result.endsWith('\n'))
517
536
  result += '\n';
518
537
  else
@@ -712,7 +731,8 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
712
731
  imgStyleParts.push('display: block', `margin-left: ${ml}`, `margin-right: ${mr}`);
713
732
  }
714
733
  const imgStyleAttr = imgStyleParts.length > 0 ? ` style="${imgStyleParts.join('; ')}"` : '';
715
- const img = `<img src="${(0, sanitize_js_1.sanitizeImageUrl)(src)}" alt="${this.escape(node.text || meta?.altText || '')}"${className}${mappedAttrs}${imgDataAttrs}${imgStyleAttr}>`;
734
+ const imgTitle = meta?.title ? ` title="${this.escape(meta.title)}"` : '';
735
+ const img = `<img src="${(0, sanitize_js_1.sanitizeImageUrl)(src)}" alt="${this.escape(node.text || meta?.altText || '')}"${imgTitle}${className}${mappedAttrs}${imgDataAttrs}${imgStyleAttr}>`;
716
736
  const content = this.config.includeFormatting ? `<div class="image-container">${img}<div class="caption">${this.escape(attachmentName || '')}</div></div>` : img;
717
737
  return `${extraAnchors}<div${idAttr}>${content}</div>`;
718
738
  }
@@ -831,7 +851,12 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
831
851
  }
832
852
  const lang = meta?.language ? ` class="language-${this.escape(meta.language)}"` : '';
833
853
  const codeHtml = `<code${lang}>${this.escape(node.text || '')}</code>`;
834
- if (node.text && node.text.includes('\n')) {
854
+ // A `code` node is always block-level (inline code is a monospace text run, emitted
855
+ // as <code> by formatText). Wrap in <pre> whenever it carries a language or spans
856
+ // multiple lines; only a bare single-line, language-less code node stays a <span>.
857
+ // Previously a single-line block (e.g. a one-line ```js) emitted <span><code>, which
858
+ // re-imports as inline code and which strict CodeBlock parsers (only <pre><code>) miss.
859
+ if (meta?.language || (node.text && node.text.includes('\n'))) {
835
860
  return `${extraAnchors}<pre${idAttr}${className}${mappedAttrs}${styleAttr}>${codeHtml}</pre>`;
836
861
  }
837
862
  else {
@@ -839,16 +864,19 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
839
864
  }
840
865
  }
841
866
  case 'list': {
867
+ // The closing suffix (`</div></li>` for a task item, `</li>` otherwise) is emitted
868
+ // by processNodeArray's list stack, not here, so a nested list can be placed inside
869
+ // this item before it closes. See `listStack`/`liClose` there.
842
870
  const meta = node.metadata;
843
871
  if (meta?.isTask) {
844
872
  const checkedAttr = ` data-checked="${meta.checked ? 'true' : 'false'}"`;
845
873
  const checkedBool = meta.checked ? ' checked' : '';
846
- return `${extraAnchors}<li${checkedAttr}${idAttr}${className}${mappedAttrs}${styleAttr}><label><input type="checkbox"${checkedBool}><span></span></label><div>${childrenOutput}</div></li>`;
874
+ return `${extraAnchors}<li${checkedAttr}${idAttr}${className}${mappedAttrs}${styleAttr}><label><input type="checkbox"${checkedBool}><span></span></label><div>${childrenOutput}`;
847
875
  }
848
876
  const value = (meta?.listType === 'ordered' && typeof meta.itemIndex === 'number')
849
877
  ? ` value="${meta.itemIndex + 1}"`
850
878
  : '';
851
- return `${extraAnchors}<li${value}${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}</li>`;
879
+ return `${extraAnchors}<li${value}${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}`;
852
880
  }
853
881
  case 'table': {
854
882
  // Smart Table Header Detection
@@ -1120,6 +1148,15 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1120
1148
  return '';
1121
1149
  const w = meta?.width ? ` width="${this.escape(meta.width)}"` : '';
1122
1150
  const h = meta?.height ? ` height="${this.escape(meta.height)}"` : '';
1151
+ if (this.config.htmlConfig.gatedEmbeds) {
1152
+ // Inert, never-auto-loading placeholder: the editor renders click-to-load from
1153
+ // it, and HtmlParser reads it back to the same embed node. src already sanitized.
1154
+ const a = meta?.align ? ` data-embed-align="${this.escape(meta.align)}"` : '';
1155
+ const l = meta?.label ? ` data-embed-label="${this.escape(meta.label)}"` : '';
1156
+ const dw = meta?.width ? ` data-embed-width="${this.escape(meta.width)}"` : '';
1157
+ const dh = meta?.height ? ` data-embed-height="${this.escape(meta.height)}"` : '';
1158
+ return `${extraAnchors}<div data-embed-gated data-embed-src="${src}"${dw}${dh}${a}${l}${idAttr}${mappedAttrs}${styleAttr}></div>`;
1159
+ }
1123
1160
  return `${extraAnchors}<iframe src="${src}"${w}${h}${idAttr}${mappedAttrs}${styleAttr}></iframe>`;
1124
1161
  }
1125
1162
  // Match the attribute-driven Youtube wrapper shape so a loaded embed re-hydrates
@@ -1132,7 +1169,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1132
1169
  const iframe = id
1133
1170
  ? `<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>`
1134
1171
  : '';
1135
- 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>`;
1172
+ // Carry the human label so it survives AST -> editor-HTML -> AST, at parity with the
1173
+ // generic gated path's data-embed-label. Only emitted when a label exists (from a
1174
+ // `::youtube[Label]` directive), so unlabeled youtube embeds are byte-identical.
1175
+ const ytLabel = meta?.label ? ` data-embed-label="${this.escape(meta.label)}"` : '';
1176
+ return `${extraAnchors}<div data-youtube-video="${this.escape(id)}" data-width="${this.escape(width)}" data-align="${this.escape(align)}"${ytLabel} class="youtube-embed"${idAttr}${mappedAttrs} style="width: ${(0, sanitize_js_1.sanitizeCssValue)(width)}; margin-left: ${ml}; margin-right: ${mr};">${iframe}</div>`;
1136
1177
  }
1137
1178
  case 'admonition': {
1138
1179
  // Match the attribute-driven admonition wrapper so a loaded admonition
@@ -1166,6 +1207,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1166
1207
  let result = this.escape(text);
1167
1208
  const f = node.formatting;
1168
1209
  if (this.config.includeFormatting && f) {
1210
+ // Inline code: a monospace run becomes `<code>`, not a `font-family: monospace` span, so
1211
+ // an editor keying on <code> sees it and it re-imports as inline code (HtmlParser maps
1212
+ // <code> back to a monospace run). Innermost, so bold/italic wrap it (`<b><code>…`).
1213
+ if (f.font === 'monospace')
1214
+ result = `<code>${result}</code>`;
1169
1215
  // Inside an `<hN>`, the heading's own styling is authoritative. A run that also carries
1170
1216
  // bold and a font size - the normal case for ODF, where a heading's paragraph style is
1171
1217
  // inherited by its runs - would wrap the text in `<b>` the heading already implies and,
@@ -1220,7 +1266,8 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1220
1266
  else if (meta?.link) {
1221
1267
  const isInternal = meta.linkType !== 'external';
1222
1268
  if (!this.config.ignoreInternalLinks || !isInternal) {
1223
- result = `<a href="${(0, sanitize_js_1.sanitizeUrl)(meta.link)}"${meta.linkType === 'external' ? ' target="_blank"' : ''}>${result}</a>`;
1269
+ const linkTitle = meta.title ? ` title="${this.escape(meta.title)}"` : '';
1270
+ result = `<a href="${(0, sanitize_js_1.sanitizeUrl)(meta.link)}"${linkTitle}${meta.linkType === 'external' ? ' target="_blank"' : ''}>${result}</a>`;
1224
1271
  }
1225
1272
  }
1226
1273
  if (meta?.abbreviationTitle) {
@@ -1250,6 +1297,12 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1250
1297
  const meta = node.metadata;
1251
1298
  if (meta.alignment)
1252
1299
  pushSafe('text-align', meta.alignment);
1300
+ // A table cell's column alignment (GFM `:---`/`:---:`/`---:`) lives on
1301
+ // `CellMetadata.align`, not `alignment`. Emit it as `text-align` on the `<th>`/`<td>`
1302
+ // so `HtmlParser` reads it back and the pipe-table markers survive AST -> HTML -> AST.
1303
+ // An unaligned cell (no `align`) adds nothing, keeping its HTML byte-identical.
1304
+ if (node.type === 'cell' && meta.align)
1305
+ pushSafe('text-align', meta.align);
1253
1306
  if (meta.backgroundColor)
1254
1307
  pushSafe('background-color', meta.backgroundColor);
1255
1308
  if (meta.verticalAlign)
@@ -1274,7 +1327,9 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1274
1327
  pushSafe('background-color', f.backgroundColor);
1275
1328
  if (f.size && !options.skipFontSize)
1276
1329
  pushSafe('font-size', f.size);
1277
- if (f.font) {
1330
+ // A monospace run is emitted as <code> by formatText, so it must not also become a
1331
+ // font-family style here (that was the old, non-semantic inline-code shape).
1332
+ if (f.font && f.font !== 'monospace') {
1278
1333
  const safeFont = (0, sanitize_js_1.sanitizeCssValue)(f.font);
1279
1334
  if (safeFont)
1280
1335
  styles.push(`font-family: ${safeFont}, sans-serif`);
@@ -47,6 +47,7 @@ export declare class MarkdownGenerator extends BaseGenerator<'md'> {
47
47
  private collectedAbbreviations;
48
48
  private resolvedDialect;
49
49
  private resolvedFallbackToHtml;
50
+ private resolvedEmbeds;
50
51
  constructor(ast: OfficeParserAST, config?: GeneratorConfig<'md'>);
51
52
  /**
52
53
  * Renders anchor tags if HTML fallback is allowed.