officeparser 7.6.2 → 7.7.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
@@ -780,6 +780,8 @@ 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'`) |
784
786
  | Frontmatter arrays | `tags: [a, b]` or `tags: ["a","b"]` | Real array in `metadata.customProperties`/`nativeProperties` |
785
787
  | MDX components (import-only) | `<Component prop="x">...</Component>` | Stripped; inner Markdown is kept. Never generated back. |
@@ -795,7 +797,8 @@ save→reload cycle:
795
797
  | HTML attribute | AST field | Notes |
796
798
  |---|---|---|
797
799
  | `data-width` / `data-align` / inline `style="width:…"` on `<img>` | `ImageMetadata.width` / `.align` | |
798
- | `data-align` on `<table>` | `TableMetadata.align` | |
800
+ | `data-align` on `<table>` | `TableMetadata.align` | Emitted/parsed as per-column GFM markers (`:---`, `:---:`, `---:`); alignment rides `CellMetadata.align` |
801
+ | `title` on `<a>` / `<img>` | `TextMetadata.title` / `ImageMetadata.title` | Survives both directions (`[text](url "Title")` in Markdown) |
799
802
  | `colspan` / `rowspan` on `<td>`/`<th>` | `CellMetadata.colSpan` / `.rowSpan` | Previously dropped on HTML import — merged cells now survive a save→reload cycle |
800
803
  | `<div data-youtube-video="ID">` / `<iframe src="...youtube.com...">` | `type: 'embed'` | |
801
804
  | `<ul data-type="taskList">` / `<li data-checked>` | `ListMetadata.isTask` / `.checked` | |
@@ -1146,7 +1149,8 @@ Pass as `mdConfig` inside `GeneratorConfig`.
1146
1149
 
1147
1150
  | Option | Type | Default | Description |
1148
1151
  |--------|------|---------|-------------|
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. |
1152
+ | `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. |
1153
+ | `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
1154
 
1151
1155
  ### PdfGeneratorConfig
1152
1156
 
@@ -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,20 +496,32 @@ 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
@@ -712,7 +728,8 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
712
728
  imgStyleParts.push('display: block', `margin-left: ${ml}`, `margin-right: ${mr}`);
713
729
  }
714
730
  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}>`;
731
+ const imgTitle = meta?.title ? ` title="${this.escape(meta.title)}"` : '';
732
+ const img = `<img src="${(0, sanitize_js_1.sanitizeImageUrl)(src)}" alt="${this.escape(node.text || meta?.altText || '')}"${imgTitle}${className}${mappedAttrs}${imgDataAttrs}${imgStyleAttr}>`;
716
733
  const content = this.config.includeFormatting ? `<div class="image-container">${img}<div class="caption">${this.escape(attachmentName || '')}</div></div>` : img;
717
734
  return `${extraAnchors}<div${idAttr}>${content}</div>`;
718
735
  }
@@ -831,7 +848,12 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
831
848
  }
832
849
  const lang = meta?.language ? ` class="language-${this.escape(meta.language)}"` : '';
833
850
  const codeHtml = `<code${lang}>${this.escape(node.text || '')}</code>`;
834
- if (node.text && node.text.includes('\n')) {
851
+ // A `code` node is always block-level (inline code is a monospace text run, emitted
852
+ // as <code> by formatText). Wrap in <pre> whenever it carries a language or spans
853
+ // multiple lines; only a bare single-line, language-less code node stays a <span>.
854
+ // Previously a single-line block (e.g. a one-line ```js) emitted <span><code>, which
855
+ // re-imports as inline code and which strict CodeBlock parsers (only <pre><code>) miss.
856
+ if (meta?.language || (node.text && node.text.includes('\n'))) {
835
857
  return `${extraAnchors}<pre${idAttr}${className}${mappedAttrs}${styleAttr}>${codeHtml}</pre>`;
836
858
  }
837
859
  else {
@@ -839,16 +861,19 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
839
861
  }
840
862
  }
841
863
  case 'list': {
864
+ // The closing suffix (`</div></li>` for a task item, `</li>` otherwise) is emitted
865
+ // by processNodeArray's list stack, not here, so a nested list can be placed inside
866
+ // this item before it closes. See `listStack`/`liClose` there.
842
867
  const meta = node.metadata;
843
868
  if (meta?.isTask) {
844
869
  const checkedAttr = ` data-checked="${meta.checked ? 'true' : 'false'}"`;
845
870
  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>`;
871
+ return `${extraAnchors}<li${checkedAttr}${idAttr}${className}${mappedAttrs}${styleAttr}><label><input type="checkbox"${checkedBool}><span></span></label><div>${childrenOutput}`;
847
872
  }
848
873
  const value = (meta?.listType === 'ordered' && typeof meta.itemIndex === 'number')
849
874
  ? ` value="${meta.itemIndex + 1}"`
850
875
  : '';
851
- return `${extraAnchors}<li${value}${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}</li>`;
876
+ return `${extraAnchors}<li${value}${idAttr}${className}${mappedAttrs}${styleAttr}>${childrenOutput}`;
852
877
  }
853
878
  case 'table': {
854
879
  // Smart Table Header Detection
@@ -1166,6 +1191,11 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1166
1191
  let result = this.escape(text);
1167
1192
  const f = node.formatting;
1168
1193
  if (this.config.includeFormatting && f) {
1194
+ // Inline code: a monospace run becomes `<code>`, not a `font-family: monospace` span, so
1195
+ // an editor keying on <code> sees it and it re-imports as inline code (HtmlParser maps
1196
+ // <code> back to a monospace run). Innermost, so bold/italic wrap it (`<b><code>…`).
1197
+ if (f.font === 'monospace')
1198
+ result = `<code>${result}</code>`;
1169
1199
  // Inside an `<hN>`, the heading's own styling is authoritative. A run that also carries
1170
1200
  // bold and a font size - the normal case for ODF, where a heading's paragraph style is
1171
1201
  // inherited by its runs - would wrap the text in `<b>` the heading already implies and,
@@ -1220,7 +1250,8 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1220
1250
  else if (meta?.link) {
1221
1251
  const isInternal = meta.linkType !== 'external';
1222
1252
  if (!this.config.ignoreInternalLinks || !isInternal) {
1223
- result = `<a href="${(0, sanitize_js_1.sanitizeUrl)(meta.link)}"${meta.linkType === 'external' ? ' target="_blank"' : ''}>${result}</a>`;
1253
+ const linkTitle = meta.title ? ` title="${this.escape(meta.title)}"` : '';
1254
+ result = `<a href="${(0, sanitize_js_1.sanitizeUrl)(meta.link)}"${linkTitle}${meta.linkType === 'external' ? ' target="_blank"' : ''}>${result}</a>`;
1224
1255
  }
1225
1256
  }
1226
1257
  if (meta?.abbreviationTitle) {
@@ -1250,6 +1281,12 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1250
1281
  const meta = node.metadata;
1251
1282
  if (meta.alignment)
1252
1283
  pushSafe('text-align', meta.alignment);
1284
+ // A table cell's column alignment (GFM `:---`/`:---:`/`---:`) lives on
1285
+ // `CellMetadata.align`, not `alignment`. Emit it as `text-align` on the `<th>`/`<td>`
1286
+ // so `HtmlParser` reads it back and the pipe-table markers survive AST -> HTML -> AST.
1287
+ // An unaligned cell (no `align`) adds nothing, keeping its HTML byte-identical.
1288
+ if (node.type === 'cell' && meta.align)
1289
+ pushSafe('text-align', meta.align);
1253
1290
  if (meta.backgroundColor)
1254
1291
  pushSafe('background-color', meta.backgroundColor);
1255
1292
  if (meta.verticalAlign)
@@ -1274,7 +1311,9 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1274
1311
  pushSafe('background-color', f.backgroundColor);
1275
1312
  if (f.size && !options.skipFontSize)
1276
1313
  pushSafe('font-size', f.size);
1277
- if (f.font) {
1314
+ // A monospace run is emitted as <code> by formatText, so it must not also become a
1315
+ // font-family style here (that was the old, non-semantic inline-code shape).
1316
+ if (f.font && f.font !== 'monospace') {
1278
1317
  const safeFont = (0, sanitize_js_1.sanitizeCssValue)(f.font);
1279
1318
  if (safeFont)
1280
1319
  styles.push(`font-family: ${safeFont}, sans-serif`);
@@ -27,12 +27,12 @@ const foldLines = (value) => String(value ?? '').replace(/[\r\n]+/g, ' ');
27
27
  * exactly (every feature on, GitHub-style admonitions) - the backward-compatibility anchor.
28
28
  */
29
29
  const MARKDOWN_DIALECT_PRESETS = {
30
- extended: { admonitions: 'github', definitionLists: true, footnotes: true, citations: true, wikilinks: true, math: 'dollar', attributeLists: true, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
31
- github: { admonitions: 'github', definitionLists: false, footnotes: true, citations: false, wikilinks: false, math: 'dollar', attributeLists: false, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
32
- gitlab: { admonitions: 'gitlab', definitionLists: false, footnotes: true, citations: false, wikilinks: false, math: 'dollar', attributeLists: false, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
33
- obsidian: { admonitions: 'github', definitionLists: false, footnotes: true, citations: false, wikilinks: true, math: 'dollar', attributeLists: false, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
34
- pandoc: { admonitions: 'pandoc', definitionLists: true, footnotes: true, citations: true, wikilinks: false, math: 'dollar', attributeLists: true, strikethrough: true, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
35
- commonmark: { admonitions: 'none', definitionLists: false, footnotes: false, citations: false, wikilinks: false, math: 'none', attributeLists: false, strikethrough: false, bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'html' },
30
+ extended: { admonitions: 'blockquote', definitionLists: 'colon', footnotes: 'caret', citations: 'at', wikilinks: 'double-bracket', math: 'dollar', attributeLists: 'brace', strikethrough: 'tilde', highlight: 'equals', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
31
+ github: { admonitions: 'blockquote', definitionLists: 'none', footnotes: 'caret', citations: 'none', wikilinks: 'none', math: 'dollar', attributeLists: 'none', strikethrough: 'tilde', highlight: 'none', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
32
+ gitlab: { admonitions: 'fence', definitionLists: 'none', footnotes: 'caret', citations: 'none', wikilinks: 'none', math: 'dollar', attributeLists: 'none', strikethrough: 'tilde', highlight: 'none', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
33
+ obsidian: { admonitions: 'blockquote', definitionLists: 'none', footnotes: 'caret', citations: 'none', wikilinks: 'double-bracket', math: 'dollar', attributeLists: 'none', strikethrough: 'tilde', highlight: 'equals', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
34
+ pandoc: { admonitions: 'fence-attribute', definitionLists: 'colon', footnotes: 'caret', citations: 'at', wikilinks: 'none', math: 'dollar', attributeLists: 'brace', strikethrough: 'tilde', highlight: 'none', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'native' },
35
+ commonmark: { admonitions: 'none', definitionLists: 'none', footnotes: 'none', citations: 'none', wikilinks: 'none', math: 'none', attributeLists: 'none', strikethrough: 'none', highlight: 'none', bulletListMarker: '-', orderedListMarker: '.', emphasisMarker: 'asterisk', tables: 'html' },
36
36
  };
37
37
  /**
38
38
  * Normalizes `MdGeneratorConfig.dialect` into a fully-resolved preset. A string names a preset
@@ -40,6 +40,30 @@ const MARKDOWN_DIALECT_PRESETS = {
40
40
  * omitted field falls back to - NOT "whatever preset was ambient before", since config merging
41
41
  * replaces the whole `dialect` field rather than layering an object on top of a prior string.
42
42
  */
43
+ /**
44
+ * Coerces a per-capability field to its canonical syntax variant. An omitted value inherits `base`;
45
+ * a deprecated boolean maps `true` -> `onValue` and `false` -> `'none'` (the two are the only legacy
46
+ * inputs, dropped next major); an explicit syntax string passes through unchanged.
47
+ */
48
+ function resolveToggle(value, onValue, base) {
49
+ if (value === undefined)
50
+ return base;
51
+ if (value === true)
52
+ return onValue;
53
+ if (value === false)
54
+ return 'none';
55
+ return value;
56
+ }
57
+ /** Maps the deprecated admonition flavor aliases to their syntax names; passes syntax names through. */
58
+ function resolveAdmonitions(value, base) {
59
+ switch (value) {
60
+ case undefined: return base;
61
+ case 'github': return 'blockquote';
62
+ case 'gitlab': return 'fence';
63
+ case 'pandoc': return 'fence-attribute';
64
+ default: return value;
65
+ }
66
+ }
43
67
  function resolveDialect(dialect) {
44
68
  if (dialect === undefined)
45
69
  return MARKDOWN_DIALECT_PRESETS.extended;
@@ -47,14 +71,15 @@ function resolveDialect(dialect) {
47
71
  return MARKDOWN_DIALECT_PRESETS[dialect] ?? MARKDOWN_DIALECT_PRESETS.extended;
48
72
  const base = MARKDOWN_DIALECT_PRESETS[dialect.extends ?? 'extended'] ?? MARKDOWN_DIALECT_PRESETS.extended;
49
73
  return {
50
- admonitions: dialect.admonitions ?? base.admonitions,
51
- definitionLists: dialect.definitionLists ?? base.definitionLists,
52
- footnotes: dialect.footnotes ?? base.footnotes,
53
- citations: dialect.citations ?? base.citations,
54
- wikilinks: dialect.wikilinks ?? base.wikilinks,
74
+ admonitions: resolveAdmonitions(dialect.admonitions, base.admonitions),
75
+ definitionLists: resolveToggle(dialect.definitionLists, 'colon', base.definitionLists),
76
+ footnotes: resolveToggle(dialect.footnotes, 'caret', base.footnotes),
77
+ citations: resolveToggle(dialect.citations, 'at', base.citations),
78
+ wikilinks: resolveToggle(dialect.wikilinks, 'double-bracket', base.wikilinks),
55
79
  math: dialect.math ?? base.math,
56
- attributeLists: dialect.attributeLists ?? base.attributeLists,
57
- strikethrough: dialect.strikethrough ?? base.strikethrough,
80
+ attributeLists: resolveToggle(dialect.attributeLists, 'brace', base.attributeLists),
81
+ strikethrough: resolveToggle(dialect.strikethrough, 'tilde', base.strikethrough),
82
+ highlight: dialect.highlight ?? base.highlight,
58
83
  bulletListMarker: dialect.bulletListMarker ?? base.bulletListMarker,
59
84
  orderedListMarker: dialect.orderedListMarker ?? base.orderedListMarker,
60
85
  emphasisMarker: dialect.emphasisMarker ?? base.emphasisMarker,
@@ -71,6 +96,7 @@ function resolveFallbackToHtml(fallbackToHtml) {
71
96
  // inlineFormatting is opt-in only: it is never enabled by the boolean form, since it changes
72
97
  // default output. Every other field follows the boolean.
73
98
  textFormatting: on, alignment: on, anchors: on, tables: on, embeds: on, cellLineBreaks: on,
99
+ itemLineBreaks: on,
74
100
  inlineFormatting: false,
75
101
  });
76
102
  if (fallbackToHtml === undefined || typeof fallbackToHtml === 'boolean')
@@ -83,6 +109,7 @@ function resolveFallbackToHtml(fallbackToHtml) {
83
109
  tables: fallbackToHtml.tables ?? on.tables,
84
110
  embeds: fallbackToHtml.embeds ?? on.embeds,
85
111
  cellLineBreaks: fallbackToHtml.cellLineBreaks ?? on.cellLineBreaks,
112
+ itemLineBreaks: fallbackToHtml.itemLineBreaks ?? on.itemLineBreaks,
86
113
  inlineFormatting: fallbackToHtml.inlineFormatting ?? on.inlineFormatting,
87
114
  };
88
115
  }
@@ -163,10 +190,13 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
163
190
  * ImageMetadata/TableMetadata's width/align fields - the canonical form is always
164
191
  * `key=value`, matching MarkdownParser's own vocabulary (MARKDOWN_DIALECT.md §15).
165
192
  */
166
- renderAttributeList(meta) {
167
- if (!this.resolvedDialect.attributeLists)
193
+ renderAttributeList(meta, options = {}) {
194
+ if (this.resolvedDialect.attributeLists === 'none')
195
+ return '';
196
+ if (!meta)
168
197
  return '';
169
- if (!meta?.width && !meta?.align)
198
+ const align = options.skipAlign ? undefined : meta.align;
199
+ if (!meta.width && !align)
170
200
  return '';
171
201
  const parts = [];
172
202
  // Allowlist, not escape. These land in `metadata.width`/`align` on reparse, which the
@@ -182,8 +212,8 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
182
212
  if (meta.width && MD_LENGTH_PATTERN.test(String(meta.width).trim())) {
183
213
  parts.push(`width=${String(meta.width).trim()}`);
184
214
  }
185
- if (meta.align && MD_ALIGN_VALUES.has(String(meta.align).trim().toLowerCase())) {
186
- parts.push(`align=${String(meta.align).trim().toLowerCase()}`);
215
+ if (align && MD_ALIGN_VALUES.has(String(align).trim().toLowerCase())) {
216
+ parts.push(`align=${String(align).trim().toLowerCase()}`);
187
217
  }
188
218
  if (parts.length === 0)
189
219
  return '';
@@ -283,8 +313,19 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
283
313
  text = emphasisAsterisk ? `**${text}**` : `__${text}__`;
284
314
  if (node.formatting.italic)
285
315
  text = emphasisAsterisk ? `*${text}*` : `_${text}_`;
286
- if (node.formatting.strikethrough && this.resolvedDialect.strikethrough)
316
+ if (node.formatting.strikethrough && this.resolvedDialect.strikethrough !== 'none')
287
317
  text = `~~${text}~~`;
318
+ // `==text==` highlight, in dialects that define it (Obsidian/extended). A plain
319
+ // highlight (the default yellow) always becomes `==text==`; a highlight carrying
320
+ // a SPECIFIC colour stays a background-color <span> when `inlineFormatting` is on,
321
+ // so its exact colour survives. With `inlineFormatting` off (no span to hold it)
322
+ // even a coloured highlight degrades to `==` rather than being dropped. In
323
+ // GFM/CommonMark `==` is literal, so a highlight falls through to the <span> path.
324
+ const isDefaultHighlight = node.formatting.backgroundColor === '#ffff00';
325
+ const emitHighlightMark = !!node.formatting.backgroundColor && this.resolvedDialect.highlight !== 'none'
326
+ && (isDefaultHighlight || !this.resolvedFallbackToHtml.inlineFormatting);
327
+ if (emitHighlightMark)
328
+ text = `==${text}==`;
288
329
  // Use HTML tags for formatting not natively supported by standard Markdown
289
330
  if (this.resolvedFallbackToHtml.textFormatting) {
290
331
  if (node.formatting.underline)
@@ -307,14 +348,18 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
307
348
  styles.push(`${prop}: ${safe}`);
308
349
  };
309
350
  pushStyle('color', node.formatting.color);
310
- pushStyle('background-color', node.formatting.backgroundColor);
351
+ // Skip the background-color only when it was already emitted as `==text==`
352
+ // above; a specific-colour highlight in a highlight dialect still keeps its
353
+ // exact colour here.
354
+ if (!emitHighlightMark)
355
+ pushStyle('background-color', node.formatting.backgroundColor);
311
356
  pushStyle('font-size', node.formatting.size);
312
357
  if (styles.length)
313
358
  text = `<span style="${styles.join('; ')}">${text}</span>`;
314
359
  }
315
360
  }
316
361
  const meta = node.metadata;
317
- if (meta?.wikilink && this.resolvedDialect.wikilinks) {
362
+ if (meta?.wikilink && this.resolvedDialect.wikilinks !== 'none') {
318
363
  // Obsidian syntax: bare page name, or page|alias when the display
319
364
  // text differs from the page name. Strip the `[]|`/newline chars
320
365
  // that would break out of the `[[...]]` wrapper.
@@ -340,8 +385,11 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
340
385
  link = '#' + this.slugify(target);
341
386
  }
342
387
  // Reject javascript:/data: schemes and encode `()`/whitespace so the
343
- // URL can't break out of `](...)` or inject a script link.
344
- text = `[${text}](${(0, sanitize_js_1.sanitizeMarkdownUrl)(link)})`;
388
+ // URL can't break out of `](...)` or inject a script link. An advisory
389
+ // title follows as `"title"` (quotes inside it escaped), matching what
390
+ // the parser reads back.
391
+ const linkTitle = meta.title ? ` "${meta.title.replace(/"/g, '\\"')}"` : '';
392
+ text = `[${text}](${(0, sanitize_js_1.sanitizeMarkdownUrl)(link)}${linkTitle})`;
345
393
  }
346
394
  }
347
395
  if (meta?.abbreviationTitle) {
@@ -358,7 +406,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
358
406
  // this branch also replaces `text` wholesale, so a strip that left `<`
359
407
  // behind discarded the escaping applied earlier.
360
408
  const key = String(meta.citationKey).replace(/[^a-zA-Z0-9_:.-]/g, '');
361
- text = this.resolvedDialect.citations ? `[@${key}]` : `[${key}]`;
409
+ text = this.resolvedDialect.citations !== 'none' ? `[@${key}]` : `[${key}]`;
362
410
  }
363
411
  return text;
364
412
  }
@@ -408,7 +456,17 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
408
456
  ? (meta.checked ? `${bullet}[x] ` : `${bullet}[ ] `)
409
457
  : (meta?.listType === 'ordered' ? `${(meta.itemIndex ?? 0) + 1}${this.resolvedDialect.orderedListMarker} ` : bullet);
410
458
  const anchors = this.renderAnchors(meta);
411
- return `${indent}${marker}${anchors}${childrenOutput}\n`;
459
+ // A list item is a single Markdown line. HTML-origin items carry `paragraph`
460
+ // children (e.g. `<li><p>a</p><ul>...`), whose renderer appends `\n\n`; dumped
461
+ // verbatim that produces `- a\n\n\n - a1`, whose blank line splits the list
462
+ // apart and whose indent is then stripped on reparse, flattening the nesting.
463
+ // Collapse the item's internal breaks the same way table cells do (see the
464
+ // `cellLineBreaks` handling in renderMarkdownTable): join with `<br>` when the
465
+ // fallback is on, a space when off. Block children (code fences, tables) inside
466
+ // an item degrade under this join, exactly as they do inside a cell.
467
+ const br = this.resolvedFallbackToHtml.itemLineBreaks ? '<br>' : ' ';
468
+ const content = childrenOutput.trim().replace(/[ \t]*\n+/g, br);
469
+ return `${indent}${marker}${anchors}${content}\n`;
412
470
  }
413
471
  case 'image': {
414
472
  if (!this.config.includeImages)
@@ -427,7 +485,8 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
427
485
  // Strip `[]` from alt (would close the `![...]`) and neutralize the URL scheme.
428
486
  const safeAlt = (0, sanitize_js_1.markdownEscapeText)(alt).replace(/[[\]]/g, '');
429
487
  const safeSrc = (0, sanitize_js_1.sanitizeMarkdownUrl)(src, { allowDataImage: true });
430
- return `${anchors}${anchors ? '\n' : ''}![${safeAlt}](${safeSrc})${this.renderAttributeList(meta)}`;
488
+ const imgTitle = meta?.title ? ` "${meta.title.replace(/"/g, '\\"')}"` : '';
489
+ return `${anchors}${anchors ? '\n' : ''}![${safeAlt}](${safeSrc}${imgTitle})${this.renderAttributeList(meta)}`;
431
490
  }
432
491
  case 'table': {
433
492
  const anchors = this.renderAnchors(node.metadata);
@@ -437,7 +496,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
437
496
  // only the plain pipe-table form needs the attribute-list syntax for alignment.
438
497
  const usedHtmlFallback = this.resolvedDialect.tables === 'html' ||
439
498
  (this.resolvedFallbackToHtml.tables && (this.hasNestedTable(node) || this.hasColspanOrRowspan(node)));
440
- const attrList = usedHtmlFallback ? '' : this.renderAttributeList(node.metadata);
499
+ const attrList = usedHtmlFallback ? '' : this.renderAttributeList(node.metadata, { skipAlign: true });
441
500
  if (attrList) {
442
501
  // Must glue directly below the last row with no blank line, or
443
502
  // MarkdownParser's block splitter won't see it as part of the same block.
@@ -530,7 +589,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
530
589
  case 'note': {
531
590
  const meta = node.metadata;
532
591
  if (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') {
533
- if (!this.resolvedDialect.footnotes) {
592
+ if (this.resolvedDialect.footnotes === 'none') {
534
593
  // Dialect has no footnote syntax - the caller inlines this bare body
535
594
  // as a parenthetical at the reference point instead of collecting it
536
595
  // into an end-of-document "### Notes" section under a [^id] marker.
@@ -586,12 +645,12 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
586
645
  const title = meta?.title ? (0, sanitize_js_1.markdownEscapeText)(foldLines(meta.title)) : '';
587
646
  const body = childrenOutput.trim();
588
647
  switch (this.resolvedDialect.admonitions) {
589
- case 'gitlab':
648
+ case 'fence':
590
649
  // GLFM fenced-div: no dedicated title syntax, so a custom title (if
591
650
  // any) is folded into the body as a bold first line.
592
651
  return `:::${type}\n${title ? `**${title}**\n\n` : ''}${body}\n:::\n\n`;
593
- case 'pandoc':
594
- // Pandoc's own fenced-div-with-class syntax; same title handling as gitlab.
652
+ case 'fence-attribute':
653
+ // Pandoc's own fenced-div-with-class syntax; same title handling as fence.
595
654
  return `::: {.${type}}\n${title ? `**${title}**\n\n` : ''}${body}\n:::\n\n`;
596
655
  case 'none': {
597
656
  // Degrade to a plain bold-labeled blockquote, no special marker.
@@ -599,7 +658,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
599
658
  const heading = title || label.charAt(0) + label.slice(1).toLowerCase();
600
659
  return `> **${heading}:**\n${quotedLines}\n\n`;
601
660
  }
602
- case 'github':
661
+ case 'blockquote':
603
662
  default: {
604
663
  // Canonical GitHub blockquote form. No dedicated title syntax either
605
664
  // (matches this library's historical output).
@@ -609,15 +668,15 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
609
668
  }
610
669
  }
611
670
  case 'definitionList':
612
- if (!this.resolvedDialect.definitionLists)
671
+ if (this.resolvedDialect.definitionLists === 'none')
613
672
  return `${childrenOutput}\n`;
614
673
  return `${childrenOutput}\n`;
615
674
  case 'definitionTerm':
616
- if (!this.resolvedDialect.definitionLists)
675
+ if (this.resolvedDialect.definitionLists === 'none')
617
676
  return `**${childrenOutput}**\n\n`;
618
677
  return `${childrenOutput}\n`;
619
678
  case 'definitionDescription':
620
- if (!this.resolvedDialect.definitionLists)
679
+ if (this.resolvedDialect.definitionLists === 'none')
621
680
  return `${childrenOutput}\n\n`;
622
681
  return `: ${childrenOutput}\n`;
623
682
  case 'chart':
@@ -732,7 +791,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
732
791
  // end-of-document "### Notes" section, or its content would be duplicated.
733
792
  const isInlinedFootnote = (note) => {
734
793
  const meta = note.metadata;
735
- return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && !this.resolvedDialect.footnotes;
794
+ return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && this.resolvedDialect.footnotes === 'none';
736
795
  };
737
796
  this.collectNotesFrom(node);
738
797
  let result = await processor(node, childrenOutput);
@@ -848,7 +907,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
848
907
  return;
849
908
  const isInlinedFootnote = (note) => {
850
909
  const meta = note.metadata;
851
- return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && !this.resolvedDialect.footnotes;
910
+ return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && this.resolvedDialect.footnotes === 'none';
852
911
  };
853
912
  this.collectedNotes.push(...node.notes.filter(note => !isInlinedFootnote(note)));
854
913
  }
@@ -892,7 +951,9 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
892
951
  let cellContent = await this.processNodeRecursive(cellNode, processor);
893
952
  // Use <br> fallback only if allowed, otherwise space
894
953
  const br = this.resolvedFallbackToHtml.cellLineBreaks ? '<br>' : ' ';
895
- cellContent = cellContent.trim().replace(/\n+/g, br).replace(/\|/g, '\\|');
954
+ // Consume any trailing spaces before the newline(s) too, so a hard-break's
955
+ // ` \n` collapses to a single `<br>` instead of leaving ` <br>` in the cell.
956
+ cellContent = cellContent.trim().replace(/[ \t]*\n+/g, br).replace(/\|/g, '\\|');
896
957
  rowCells.push(cellContent);
897
958
  // Handle colspan by adding empty cells
898
959
  const colSpan = cellNode.metadata?.colSpan || 1;
@@ -909,7 +970,17 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
909
970
  this.inImplicitBold = wasInImplicitBold;
910
971
  }
911
972
  }
912
- // Second pass: Build table string with separator
973
+ // Second pass: Build table string with separator. The separator carries standard GFM
974
+ // per-column alignment (`:---`/`:---:`/`---:`) from columnAlignments, or the single table-level
975
+ // align applied to every column, rather than a non-standard trailing `{align}` attribute list.
976
+ const tableMeta = node.metadata;
977
+ // Column alignment lives on each cell (CellMetadata.align); read it off the header row.
978
+ // Fall back to the single table-level align (an editor's data-align) for every column.
979
+ const headerCells = (node.children?.[0]?.children || []).filter(c => c.type === 'cell');
980
+ const alignMarker = (i) => {
981
+ const a = headerCells[i]?.metadata?.align ?? tableMeta?.align;
982
+ return a === 'center' ? ':---:' : a === 'left' ? ':---' : a === 'right' ? '---:' : '---';
983
+ };
913
984
  for (let i = 0; i < processedRows.length; i++) {
914
985
  const row = processedRows[i];
915
986
  // Pad row with empty cells if it has fewer than maxCols
@@ -918,7 +989,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
918
989
  tableOutput += `| ${row.join(' | ')} |\n`;
919
990
  if (i === 0) {
920
991
  // Header separator
921
- tableOutput += `| ${Array(maxCols).fill(' --- ').join(' | ')} |\n`;
992
+ tableOutput += `| ${Array.from({ length: maxCols }, (_, i) => alignMarker(i)).join(' | ')} |\n`;
922
993
  }
923
994
  }
924
995
  return `\n${tableOutput}\n`;