officeparser 7.7.0 → 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
  ---
@@ -783,6 +783,7 @@ idempotent and `.md → AST → HTML → AST → .md` survives unchanged.
783
783
  | Highlight | `==text==` | `TextMetadata.backgroundColor` |
784
784
  | Link/image titles | `[text](url "Title")` / `![alt](img.png "Title")` | `TextMetadata.title` / `ImageMetadata.title` |
785
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` |
786
787
  | Frontmatter arrays | `tags: [a, b]` or `tags: ["a","b"]` | Real array in `metadata.customProperties`/`nativeProperties` |
787
788
  | MDX components (import-only) | `<Component prop="x">...</Component>` | Stripped; inner Markdown is kept. Never generated back. |
788
789
 
@@ -988,7 +989,7 @@ Pass as the second argument to `parseOffice(file, config)`.
988
989
  | `fileType` | `SupportedFileType \| null` | `null` | **Required for text-based binary data** (`'md'`, `'html'`, `'csv'`) as these lack magic bytes. |
989
990
  | `csvDelimiter` | `string` | `','` | Input delimiter when parsing CSV files |
990
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 |
991
- | `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 |
992
993
  | `pdfWorkerSrc` | `string` | CDN (jsDelivr) | Path/URL to `pdf.worker.min.mjs` (required in browser) |
993
994
  | `onWarning` | `(issue: OfficeIssue) => void` | — | Callback for non-fatal parsing issues |
994
995
  | `abortSignal` | `AbortSignal \| null` | `null` | Optional signal to cancel parsing (rejects with AbortError) |
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.
@@ -527,8 +527,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
527
527
  // Non-list node closes all active lists
528
528
  closeListsToLevel(-1);
529
529
  let result = await this.processNodeRecursive(node, this.nodeProcessor.bind(this), override);
530
- // Add newlines for readability of the HTML source
531
- 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')) {
532
535
  if (result.endsWith('\n'))
533
536
  result += '\n';
534
537
  else
@@ -1145,6 +1148,15 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1145
1148
  return '';
1146
1149
  const w = meta?.width ? ` width="${this.escape(meta.width)}"` : '';
1147
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
+ }
1148
1160
  return `${extraAnchors}<iframe src="${src}"${w}${h}${idAttr}${mappedAttrs}${styleAttr}></iframe>`;
1149
1161
  }
1150
1162
  // Match the attribute-driven Youtube wrapper shape so a loaded embed re-hydrates
@@ -1157,7 +1169,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1157
1169
  const iframe = id
1158
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>`
1159
1171
  : '';
1160
- 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>`;
1161
1177
  }
1162
1178
  case 'admonition': {
1163
1179
  // Match the attribute-driven admonition wrapper so a loaded admonition
@@ -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.
@@ -160,10 +160,19 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
160
160
  collectedAbbreviations = new Map();
161
161
  resolvedDialect;
162
162
  resolvedFallbackToHtml;
163
+ resolvedEmbeds;
163
164
  constructor(ast, config) {
164
165
  super('md', ast, config);
165
166
  this.resolvedDialect = resolveDialect(this.config.mdConfig.dialect);
166
167
  this.resolvedFallbackToHtml = resolveFallbackToHtml(this.config.mdConfig.fallbackToHtml);
168
+ // `dialect.embeds` is the authority for embed form. It lives in a different config object
169
+ // than the deprecated `fallbackToHtml.embeds` boolean, and an explicit boolean `false` must
170
+ // still win over the preset default, so it is resolved here rather than in `resolveDialect`:
171
+ // an embeds value set on the dialect OBJECT wins; otherwise the boolean maps (`true`/unset ->
172
+ // `'html'`, `false` -> `'link'`); otherwise the default `'html'`.
173
+ const dialectCfg = this.config.mdConfig.dialect;
174
+ const explicitEmbeds = (dialectCfg && typeof dialectCfg === 'object') ? dialectCfg.embeds : undefined;
175
+ this.resolvedEmbeds = explicitEmbeds ?? (this.resolvedFallbackToHtml.embeds ? 'html' : 'link');
167
176
  }
168
177
  /**
169
178
  * Renders anchor tags if HTML fallback is allowed.
@@ -603,32 +612,67 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
603
612
  return `> **Note:** ${childrenOutput.trim()}\n\n`;
604
613
  }
605
614
  case 'embed': {
606
- // Markdown has no native embed syntax. When fallbackToHtml.embeds is on (our
607
- // save default), emit the exact single-line div MarkdownParser recognises on
608
- // reimport; otherwise degrade to a plain link.
615
+ // Markdown has no native embed syntax. `this.resolvedEmbeds` (from
616
+ // `dialect.embeds`, honoring the deprecated `fallbackToHtml.embeds` boolean)
617
+ // selects the form: 'html' (the single-line block this library has always
618
+ // emitted and re-recognises), 'directive' (a remark-directive leaf), 'link'
619
+ // (a plain link), 'thumbnail' (YouTube-only clickable preview).
609
620
  const meta = node.metadata;
621
+ const mode = this.resolvedEmbeds;
622
+ // A directive label sits inside `::name[...]`; strip the `[]`/newline chars that
623
+ // would break out of it. An attribute value sits inside `{...}`; percent-encode
624
+ // the space/brace chars that would break out (widths/aligns/ids never contain
625
+ // them, but a src can).
626
+ const dirLabel = (meta?.label || '').replace(/[[\]\r\n]+/g, ' ').trim();
627
+ const dirUrl = (u) => (0, sanitize_js_1.sanitizeMarkdownUrl)(u).replace(/[{}\s]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
628
+ const attrList = (pairs) => {
629
+ const kv = pairs.filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`);
630
+ return kv.length ? `{${kv.join(' ')}}` : '';
631
+ };
610
632
  if (meta?.embedType === 'iframe') {
611
- // sanitizeUrl scheme-checks and HTML-escapes the src (hostile schemes drop
612
- // the node). The single-line <iframe> is what MarkdownParser recognises on
613
- // reimport, gated there on preserveIframes.
614
- const safe = (0, sanitize_js_1.sanitizeUrl)(meta?.url || '');
615
- if (!safe)
616
- return '';
617
- if (this.resolvedFallbackToHtml.embeds) {
633
+ const rawUrl = meta?.url || '';
634
+ if (mode === 'directive') {
635
+ const src = dirUrl(rawUrl);
636
+ if (!src)
637
+ return '';
638
+ const lbl = dirLabel ? `[${dirLabel}]` : '';
639
+ return `::embed${lbl}${attrList([['src', src], ['width', meta?.width], ['height', meta?.height], ['align', meta?.align]])}\n\n`;
640
+ }
641
+ if (mode === 'html') {
642
+ // sanitizeUrl scheme-checks and HTML-escapes the src (hostile schemes drop
643
+ // the node). The single-line <iframe> is what MarkdownParser recognises on
644
+ // reimport, gated there on preserveIframes.
645
+ const safe = (0, sanitize_js_1.sanitizeUrl)(rawUrl);
646
+ if (!safe)
647
+ return '';
618
648
  const w = meta?.width ? ` width="${(0, sanitize_js_1.escapeHtml)(meta.width)}"` : '';
619
649
  const h = meta?.height ? ` height="${(0, sanitize_js_1.escapeHtml)(meta.height)}"` : '';
620
650
  return `\n<iframe src="${safe}"${w}${h}></iframe>\n\n`;
621
651
  }
622
- return `[Embed](${(0, sanitize_js_1.sanitizeMarkdownUrl)(meta?.url || '')})\n\n`;
652
+ // 'link' and 'thumbnail' (thumbnail is YouTube-only, so a generic iframe
653
+ // degrades to a link) both emit a plain link.
654
+ const safe = (0, sanitize_js_1.sanitizeMarkdownUrl)(rawUrl);
655
+ return safe ? `[${meta?.label || 'Embed'}](${safe})\n\n` : '';
623
656
  }
624
657
  const id = meta?.videoId || '';
625
- if (this.resolvedFallbackToHtml.embeds) {
658
+ if (mode === 'directive') {
659
+ const lbl = dirLabel ? `[${dirLabel}]` : '';
660
+ return `::youtube${lbl}${attrList([['id', id], ['width', meta?.width], ['align', meta?.align]])}\n\n`;
661
+ }
662
+ if (mode === 'html') {
626
663
  const width = meta?.width ? ` data-width="${(0, sanitize_js_1.escapeHtml)(meta.width)}"` : '';
627
664
  const align = meta?.align ? ` data-align="${(0, sanitize_js_1.escapeHtml)(meta.align)}"` : '';
628
- return `\n<div data-youtube-video="${(0, sanitize_js_1.escapeHtml)(id)}"${width}${align}></div>\n\n`;
665
+ const lbl = meta?.label ? ` data-embed-label="${(0, sanitize_js_1.escapeHtml)(meta.label)}"` : '';
666
+ return `\n<div data-youtube-video="${(0, sanitize_js_1.escapeHtml)(id)}"${width}${align}${lbl}></div>\n\n`;
667
+ }
668
+ if (mode === 'thumbnail' && id) {
669
+ const watch = (0, sanitize_js_1.sanitizeMarkdownUrl)(`https://www.youtube.com/watch?v=${id}`);
670
+ const thumb = (0, sanitize_js_1.sanitizeMarkdownUrl)(`https://img.youtube.com/vi/${id}/hqdefault.jpg`);
671
+ return `[![${meta?.label || 'YouTube'}](${thumb})](${watch})\n\n`;
629
672
  }
673
+ // 'link' (and 'thumbnail' with no id): a plain link.
630
674
  const url = meta?.url || (id ? `https://youtu.be/${id}` : '');
631
- return url ? `[YouTube](${(0, sanitize_js_1.sanitizeMarkdownUrl)(url)})\n\n` : '';
675
+ return url ? `[${meta?.label || 'YouTube'}](${(0, sanitize_js_1.sanitizeMarkdownUrl)(url)})\n\n` : '';
632
676
  }
633
677
  case 'admonition': {
634
678
  const meta = node.metadata;
@@ -415,6 +415,18 @@ export interface HtmlParserConfig {
415
415
  * Defaults to false.
416
416
  */
417
417
  preserveIframes?: boolean | string[];
418
+ /**
419
+ * Import ambiguous "folk" embed forms in Markdown as embeds: a standalone Obsidian-style image
420
+ * whose URL is a YouTube link (`![](https://youtube.com/watch?v=ID)`), and the clickable
421
+ * thumbnail-link (`[![alt](https://img.youtube.com/vi/ID/…)](watch-url)`). Both become a
422
+ * `embedType: 'youtube'` embed (rendered from the validated id, so it is safe). Off by default:
423
+ * auto-upgrading an image/link to an embed is a heuristic that could mangle a genuinely-intended
424
+ * image link, so a consumer opts in. The unambiguous forms (`<div data-youtube-video>`, a bare
425
+ * YouTube `<iframe>`, the `::youtube` directive) are always recognized, independent of this flag.
426
+ *
427
+ * Defaults to false.
428
+ */
429
+ embedFolkForms?: boolean;
418
430
  }
419
431
  /**
420
432
  * Maps an input format string to its corresponding format-specific parser configuration, mirroring
@@ -883,6 +895,15 @@ export interface HtmlGeneratorConfig {
883
895
  * `HtmlParser` reads every shape this emits, so output stays self-round-trippable.
884
896
  */
885
897
  sourceAttributes?: boolean;
898
+ /**
899
+ * Emit a generic (non-YouTube) iframe embed as a gated placeholder,
900
+ * `<div data-embed-gated data-embed-src="…" …>`, instead of a live `<iframe>`. The gated shape
901
+ * never auto-loads its src: an editor renders a click-to-load placeholder from it, and
902
+ * `HtmlParser` reads it back to the same `embed` node. The src is scheme-checked (`sanitizeUrl`)
903
+ * on emit. Off by default; the default output (a live `<iframe>`) is unchanged. YouTube embeds
904
+ * are unaffected (they already render from a validated id).
905
+ */
906
+ gatedEmbeds?: boolean;
886
907
  }
887
908
  /**
888
909
  * Configuration options for PDF generation.
@@ -1056,6 +1077,20 @@ export type CitationSyntax = "at" | "none";
1056
1077
  export type WikilinkSyntax = "double-bracket" | "none";
1057
1078
  /** `{width=50%}` attribute lists (`'brace'`), or `'none'`. */
1058
1079
  export type AttributeListSyntax = "brace" | "none";
1080
+ /**
1081
+ * How an `embed` node is written to Markdown:
1082
+ * - `'html'` (default): the single-line `<div data-youtube-video="ID">` / `<iframe src=...>` block
1083
+ * this library has always emitted. Round-trips through officeParser, but renders as an invisible
1084
+ * empty box on GitHub.
1085
+ * - `'directive'`: a remark-directive leaf, `::youtube[Label]{id=... width=... align=...}` /
1086
+ * `::embed[Label]{src=... width=... height=... align=...}`. Round-trips within an editor that
1087
+ * understands it; renders verbatim (not just the label) on GitHub, so it is an editor format, not
1088
+ * a GitHub-interop one.
1089
+ * - `'link'`: a plain `[YouTube](url)` / `[Embed](url)`.
1090
+ * - `'thumbnail'`: a YouTube-only clickable thumbnail `[![Label](.../vi/ID/hqdefault.jpg)](watch)`,
1091
+ * the best GitHub degrade; a non-YouTube embed falls back to `'link'`.
1092
+ */
1093
+ export type EmbedSyntax = "html" | "directive" | "link" | "thumbnail";
1059
1094
  /**
1060
1095
  * @deprecated Legacy flavor names for `MarkdownDialectConfig.admonitions`. Use the syntax names
1061
1096
  * instead: `'github'` -> `'blockquote'`, `'gitlab'` -> `'fence'`, `'pandoc'` -> `'fence-attribute'`.
@@ -1142,6 +1177,13 @@ export interface MarkdownDialectConfig {
1142
1177
  /** Table syntax: native GFM pipe tables, or forced HTML `<table>` (required for strict
1143
1178
  * CommonMark, which has no table syntax of its own). */
1144
1179
  tables?: "native" | "html";
1180
+ /**
1181
+ * How an `embed` node is written to Markdown (`'html'` | `'directive'` | `'link'` |
1182
+ * `'thumbnail'`; see `EmbedSyntax`). This is the authority for embed form. When omitted, the
1183
+ * deprecated `fallbackToHtml.embeds` boolean is honored (`true`/unset maps to `'html'`, `false`
1184
+ * to `'link'`), then the default `'html'`.
1185
+ */
1186
+ embeds?: EmbedSyntax;
1145
1187
  }
1146
1188
  /**
1147
1189
  * Granular control over when the Markdown generator falls back to raw HTML tags for features
@@ -1158,7 +1200,12 @@ export interface FallbackToHtmlConfig {
1158
1200
  anchors?: boolean;
1159
1201
  /** Nested-table and merged-cell (colspan/rowspan) HTML `<table>` fallback. */
1160
1202
  tables?: boolean;
1161
- /** YouTube embed `<div data-youtube-video>` vs. a plain link. */
1203
+ /**
1204
+ * YouTube embed `<div data-youtube-video>` vs. a plain link.
1205
+ * @deprecated Use `mdConfig.dialect.embeds` (`EmbedSyntax`) instead, which also selects the
1206
+ * `'directive'` and `'thumbnail'` forms. When `dialect.embeds` is unset this boolean is still
1207
+ * honored (`true` maps to `'html'`, `false` to `'link'`); it will be removed in the next major.
1208
+ */
1162
1209
  embeds?: boolean;
1163
1210
  /** Multi-line table cell content joined with `<br>` instead of a space. */
1164
1211
  cellLineBreaks?: boolean;
@@ -1778,10 +1825,13 @@ export interface EmbedMetadata {
1778
1825
  url?: string;
1779
1826
  /** Display width, as a CSS length or percentage. */
1780
1827
  width?: string;
1781
- /** Display height, as a CSS length or percentage (generic iframes). */
1828
+ /** Display height, as a CSS length or percentage. */
1782
1829
  height?: string;
1783
1830
  /** Layout alignment of the embed. */
1784
1831
  align?: "left" | "center" | "right";
1832
+ /** Human-readable label for the embed (e.g. the `[Label]` of a `::youtube[Label]{...}` leaf
1833
+ * directive, or a gated embed's caption). Purely descriptive; never a trust or render input. */
1834
+ label?: string;
1785
1835
  }
1786
1836
  /**
1787
1837
  * Metadata for an admonition/alert node (e.g. GitHub's `> [!NOTE]` or GLFM's `:::note`).