officeparser 7.4.0 → 7.5.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
@@ -128,7 +128,7 @@ npx officeparser my_document --fileType=docx --to=json
128
128
  | `--includeRawContent` | boolean | `false` | Include raw XML/RTF in nodes |
129
129
  | `--serializeRawContent` | boolean | `true` | Include stringified XML in metadata |
130
130
  | `--preserveXmlWhitespace` | boolean | `false` | Keep raw formatting space |
131
- | `--includeBreakNodes` | boolean | `false` | Include break nodes (DOCX only) |
131
+ | `--includeBreakNodes` | boolean | `false` | Include break nodes (DOCX and ODF) |
132
132
  | `--verbose` | boolean | `false` | Show full error stack traces and warning logs |
133
133
  | `--includeFormatting` | boolean | `true` | Include formatting style map matching |
134
134
  | `--renderMetadata` | boolean | `false` | Render metadata as visible content in the generated output |
@@ -608,7 +608,14 @@ formatting: {
608
608
  }
609
609
  ```
610
610
 
611
- ### 6. Break Nodes (DOCX only)
611
+ > [!NOTE]
612
+ > On a **content node**, an absent flag and `false` mean the same thing — the flag is simply not
613
+ > applied. On **`ast.metadata.styleMap`**, they differ: an absent flag means the style says nothing
614
+ > about that property (so it inherits), while `false` means the style explicitly turns it off
615
+ > (ODF's `fo:font-weight="normal"`, DOCX's `<w:b w:val="0"/>`). Code resolving inheritance itself
616
+ > must test `=== undefined`, not truthiness, or it will treat "explicitly off" as "unspecified".
617
+
618
+ ### 6. Break Nodes (DOCX and ODF)
612
619
 
613
620
  When `includeBreakNodes: true`, break elements appear as nodes:
614
621
 
@@ -623,6 +630,43 @@ Break Node (type: 'break')
623
630
  > [!NOTE]
624
631
  > Break nodes have no `text` property, but `ast.toText()` and `ast.to('text')` automatically convert them to the configured newline delimiter.
625
632
 
633
+ > [!NOTE]
634
+ > DOCX writes breaks inline (`w:br`/`w:cr`), so they land as children of the paragraph. ODF instead
635
+ > carries page and column breaks on the paragraph *style* (`fo:break-before`/`fo:break-after`), so those
636
+ > are emitted as siblings around the paragraph rather than inside it. `<text:soft-page-break/>` maps onto
637
+ > `lastRenderedPage`, the same type as DOCX's `w:lastRenderedPageBreak`.
638
+
639
+ ### 6b. Equations
640
+
641
+ Equations are extracted from every format that can carry them and normalized to **LaTeX**, so a
642
+ formula means the same thing whichever format it arrived in:
643
+
644
+ | Source format | Markup in the file |
645
+ |---|---|
646
+ | DOCX, PPTX | OOXML `<m:oMath>` / `<m:oMathPara>` |
647
+ | ODT, ODP, ODS | MathML inside the embedded formula object |
648
+ | HTML, EPUB | native MathML `<math>` |
649
+ | Markdown | `$inline$` / `$$block$$` |
650
+
651
+ They all land as the same node:
652
+
653
+ ```text
654
+ Code Node (type: 'code')
655
+ ├── text: '\\frac{1}{2}' // LaTeX, whatever the source markup was
656
+ └── metadata: { math: 'inline' | 'block' }
657
+ ```
658
+
659
+ Fractions, sub/superscripts, radicals, delimiters, n-ary operators (sums, integrals), named
660
+ functions, accents, bars, matrices and math alphabets (`ℝ`, `𝒜`, …) are all preserved. When a
661
+ document supplies its own TeX source in an `<annotation encoding="application/x-tex">`, that is
662
+ used verbatim in preference to anything reconstructed from the presentation markup.
663
+
664
+ > [!NOTE]
665
+ > Equation text is *structure*, not prose: a fraction whose numerator and denominator are simply
666
+ > concatenated reads as a different number rather than as obviously-missing content. Consumers that
667
+ > index document text should treat `code` nodes carrying `math` as opaque LaTeX rather than
668
+ > splitting them as words.
669
+
626
670
  ### 7. Document Metadata
627
671
 
628
672
  ```ts
@@ -688,7 +732,7 @@ idempotent and `.md → AST → HTML → AST → .md` survives unchanged.
688
732
  | Attribute lists | `![alt](img.png){width=50% .centered}` | `ImageMetadata.width` / `.align`, `TableMetadata.align` |
689
733
  | Citations | `[@smith2024]` | `TextMetadata.citationKey` |
690
734
  | Wikilinks | `[[Page]]` / `[[Page\|Alias]]` | `TextMetadata.wikilink`, `.link`, `.linkType` |
691
- | Inline/block math | `$E=mc^2$` / `` $$...$$ `` | `TextMetadata.math` (`'inline' \| 'block'`) |
735
+ | Inline/block math | `$E=mc^2$` / `` $$...$$ `` | `type: 'code'`, `CodeMetadata.math` (`'inline' \| 'block'`) |
692
736
  | Frontmatter arrays | `tags: [a, b]` or `tags: ["a","b"]` | Real array in `metadata.customProperties`/`nativeProperties` |
693
737
  | MDX components (import-only) | `<Component prop="x">...</Component>` | Stripped; inner Markdown is kept. Never generated back. |
694
738
 
@@ -888,7 +932,7 @@ Pass as the second argument to `parseOffice(file, config)`.
888
932
  | `includeRawContent` | `boolean` | `false` | Attach raw XML/RTF source to each node |
889
933
  | `serializeRawContent` | `boolean` | `true` | Re-serialize XML to clean strings (only if `includeRawContent: true`) |
890
934
  | `preserveXmlWhitespace` | `boolean` | `false` | Preserve original XML whitespace during serialization |
891
- | `includeBreakNodes` | `boolean` | `false` | Include `w:br` / `w:cr` as typed break nodes (DOCX only) |
935
+ | `includeBreakNodes` | `boolean` | `false` | Include typed break nodes: DOCX `w:br`/`w:cr`, ODF `fo:break-before`/`fo:break-after` and `text:soft-page-break` |
892
936
  | `ignoreInternalLinks` | `boolean` | `false` | Strip bookmarks and internal cross-references from AST |
893
937
  | `fileType` | `SupportedFileType \| null` | `null` | **Required for text-based binary data** (`'md'`, `'html'`, `'csv'`) as these lack magic bytes. |
894
938
  | `csvDelimiter` | `string` | `','` | Input delimiter when parsing CSV files |
@@ -81,6 +81,21 @@ export declare abstract class BaseGenerator<D extends UniversalGeneratorFormat =
81
81
  * hasn't already been claimed; otherwise a sequential counter guarantees uniqueness.
82
82
  */
83
83
  protected getFootnoteKey(note: OfficeContentNode): string;
84
+ /**
85
+ * True when every content-bearing text descendant satisfies `test` - i.e. the property is
86
+ * uniform across the whole node and therefore says nothing the node type does not already say.
87
+ *
88
+ * Used to decide whether a heading's or header row's inherited formatting can be dropped. The
89
+ * distinction matters: an ODF heading whose paragraph style is bold and 14pt yields a heading
90
+ * where *every* run is bold and 14pt, and re-emitting that gives `# **Heading**` in Markdown
91
+ * and, worse in RTF/HTML, an inner font-size that overrides the heading's own and visibly
92
+ * shrinks it. But `# Normal **Bold** Normal` is an author contrasting one word against the
93
+ * rest, and dropping that would discard real meaning. Only the uniform case is safe.
94
+ *
95
+ * Returns false when there is no text to judge, so an empty or image-only node never triggers
96
+ * suppression.
97
+ */
98
+ protected hasUniformFormatting(node: OfficeContentNode, test: (formatting: OfficeContentNode['formatting']) => boolean): boolean;
84
99
  /**
85
100
  * Recursively extracts plain text from a node and its children.
86
101
  */
@@ -187,6 +187,37 @@ class BaseGenerator {
187
187
  this.noteFootnoteKeys.set(note, key);
188
188
  return key;
189
189
  }
190
+ /**
191
+ * True when every content-bearing text descendant satisfies `test` - i.e. the property is
192
+ * uniform across the whole node and therefore says nothing the node type does not already say.
193
+ *
194
+ * Used to decide whether a heading's or header row's inherited formatting can be dropped. The
195
+ * distinction matters: an ODF heading whose paragraph style is bold and 14pt yields a heading
196
+ * where *every* run is bold and 14pt, and re-emitting that gives `# **Heading**` in Markdown
197
+ * and, worse in RTF/HTML, an inner font-size that overrides the heading's own and visibly
198
+ * shrinks it. But `# Normal **Bold** Normal` is an author contrasting one word against the
199
+ * rest, and dropping that would discard real meaning. Only the uniform case is safe.
200
+ *
201
+ * Returns false when there is no text to judge, so an empty or image-only node never triggers
202
+ * suppression.
203
+ */
204
+ hasUniformFormatting(node, test) {
205
+ let sawText = false;
206
+ const walk = (n) => {
207
+ if (n.type === 'text') {
208
+ // Whitespace-only runs carry no visible formatting either way, so they neither
209
+ // count as evidence nor veto - otherwise a stray unformatted space between two
210
+ // bold runs would defeat the check on almost every real heading.
211
+ if (!(n.text || '').trim())
212
+ return true;
213
+ sawText = true;
214
+ return test(n.formatting);
215
+ }
216
+ return (n.children ?? []).every(walk);
217
+ };
218
+ const uniform = (node.children ?? []).every(walk);
219
+ return sawText && uniform;
220
+ }
190
221
  /**
191
222
  * Recursively extracts plain text from a node and its children.
192
223
  */
@@ -6,6 +6,14 @@ import { BaseGenerator } from './BaseGenerator.js';
6
6
  export declare class HtmlGenerator extends BaseGenerator<'html'> {
7
7
  private chartCounter;
8
8
  private isSpreadsheetMode;
9
+ /**
10
+ * Set while rendering a heading's children, so `formatText` can drop the run-level bold and
11
+ * font-size the `<hN>` already establishes. See the note there, and the identical flag in
12
+ * `RtfGenerator`, where the same inherited size actively shrinks the heading.
13
+ */
14
+ private inHeading;
15
+ /** As `inHeading`, but for the inherited font size - see `hasUniformFormatting`. */
16
+ private headingUniformSize;
9
17
  constructor(ast: OfficeParserAST, config?: GeneratorConfig<'html'>);
10
18
  /**
11
19
  * Generates HTML string from the provided AST.
@@ -24,6 +32,7 @@ export declare class HtmlGenerator extends BaseGenerator<'html'> {
24
32
  */
25
33
  private tableNestingLevel;
26
34
  protected processNodeRecursive(node: OfficeContentNode, processor: (node: OfficeContentNode, childrenOutput: string) => string | Promise<string>, override?: string | boolean | void): Promise<string>;
35
+ private processNodeRecursiveInner;
27
36
  /**
28
37
  * Internal processor for individual nodes.
29
38
  */
@@ -88,6 +88,14 @@ function resolveStandalone(standalone) {
88
88
  class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
89
89
  chartCounter = 0;
90
90
  isSpreadsheetMode = false;
91
+ /**
92
+ * Set while rendering a heading's children, so `formatText` can drop the run-level bold and
93
+ * font-size the `<hN>` already establishes. See the note there, and the identical flag in
94
+ * `RtfGenerator`, where the same inherited size actively shrinks the heading.
95
+ */
96
+ inHeading = false;
97
+ /** As `inHeading`, but for the inherited font size - see `hasUniformFormatting`. */
98
+ headingUniformSize = false;
91
99
  constructor(ast, config) {
92
100
  super('html', ast, config);
93
101
  }
@@ -502,6 +510,21 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
502
510
  // method entirely, so without repeating the check here the signal would be silently
503
511
  // inert for this generator - which is exactly how it was missed.
504
512
  (0, errorUtils_js_1.checkAbortSignal)(this.config.abortSignal);
513
+ const wasInHeading = this.inHeading;
514
+ const wasHeadingSize = this.headingUniformSize;
515
+ if (node.type === 'heading') {
516
+ this.inHeading = this.hasUniformFormatting(node, f => f?.bold === true);
517
+ this.headingUniformSize = this.hasUniformFormatting(node, f => !!f?.size);
518
+ }
519
+ try {
520
+ return await this.processNodeRecursiveInner(node, processor, override);
521
+ }
522
+ finally {
523
+ this.inHeading = wasInHeading;
524
+ this.headingUniformSize = wasHeadingSize;
525
+ }
526
+ }
527
+ async processNodeRecursiveInner(node, processor, override) {
505
528
  // Use pre-evaluated override if provided, otherwise call handleOnNode
506
529
  const actualOverride = override !== undefined ? override : await this.handleOnNode(node);
507
530
  // Returning false skips the node and its children
@@ -1076,7 +1099,12 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1076
1099
  let result = this.escape(text);
1077
1100
  const f = node.formatting;
1078
1101
  if (this.config.includeFormatting && f) {
1079
- if (f.bold)
1102
+ // Inside an `<hN>`, the heading's own styling is authoritative. A run that also carries
1103
+ // bold and a font size - the normal case for ODF, where a heading's paragraph style is
1104
+ // inherited by its runs - would wrap the text in `<b>` the heading already implies and,
1105
+ // worse, in a `<span style="font-size: 14pt">` that *shrinks* the heading to the size
1106
+ // its paragraph style happened to name. See the same suppression in RtfGenerator.
1107
+ if (f.bold && !this.inHeading)
1080
1108
  result = `<b>${result}</b>`;
1081
1109
  if (f.italic)
1082
1110
  result = `<i>${result}</i>`;
@@ -1088,7 +1116,9 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1088
1116
  result = `<sub>${result}</sub>`;
1089
1117
  if (f.superscript)
1090
1118
  result = `<sup>${result}</sup>`;
1091
- const styles = this.getInlineStyles(node);
1119
+ const styles = this.headingUniformSize
1120
+ ? this.getInlineStyles(node, { skipFontSize: true })
1121
+ : this.getInlineStyles(node);
1092
1122
  if (styles) {
1093
1123
  result = `<span style="${styles}">${result}</span>`;
1094
1124
  }
@@ -1120,7 +1150,7 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1120
1150
  }
1121
1151
  return result;
1122
1152
  }
1123
- getInlineStyles(node) {
1153
+ getInlineStyles(node, options = {}) {
1124
1154
  const styles = [];
1125
1155
  // Colors/sizes/fonts/alignments are free strings from an untrusted document;
1126
1156
  // run each through sanitizeCssValue so it can't break out of the style="" attribute
@@ -1154,7 +1184,7 @@ class HtmlGenerator extends BaseGenerator_js_1.BaseGenerator {
1154
1184
  pushSafe('color', f.color);
1155
1185
  if (f.backgroundColor)
1156
1186
  pushSafe('background-color', f.backgroundColor);
1157
- if (f.size)
1187
+ if (f.size && !options.skipFontSize)
1158
1188
  pushSafe('font-size', f.size);
1159
1189
  if (f.font) {
1160
1190
  const safeFont = (0, sanitize_js_1.sanitizeCssValue)(f.font);
@@ -31,6 +31,18 @@ import { BaseGenerator } from './BaseGenerator.js';
31
31
  */
32
32
  export declare class MarkdownGenerator extends BaseGenerator<'md'> {
33
33
  private isInsideTable;
34
+ /**
35
+ * Set while rendering the children of a heading, or the cells of a table's header row.
36
+ *
37
+ * Markdown already conveys "this is a heading" with `#` and "this is a header row" with the
38
+ * separator line, so a run inside one that also carries bold - the normal case for ODF, whose
39
+ * heading and header-row paragraph styles are bold and are now inherited by their runs - would
40
+ * render as `# **Heading**` and `| **ITEM** |`. That is redundant rather than wrong, but it
41
+ * also round-trips back into bold text nodes nested inside a heading, so the noise compounds
42
+ * on every parse/generate cycle. Emphasis the node type already implies is dropped; every
43
+ * other formatting flag still comes through.
44
+ */
45
+ private inImplicitBold;
34
46
  private hoistedContent;
35
47
  private collectedAbbreviations;
36
48
  private resolvedDialect;
@@ -72,6 +84,7 @@ export declare class MarkdownGenerator extends BaseGenerator<'md'> {
72
84
  private optimizeNodes;
73
85
  private areFormattingEqual;
74
86
  private renderMarkdownTable;
87
+ private collectNotesFrom;
75
88
  private renderMarkdownTableInternal;
76
89
  private hasNestedTable;
77
90
  private hasColspanOrRowspan;
@@ -113,6 +113,18 @@ function resolveFallbackToHtml(fallbackToHtml) {
113
113
  */
114
114
  class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
115
115
  isInsideTable = false;
116
+ /**
117
+ * Set while rendering the children of a heading, or the cells of a table's header row.
118
+ *
119
+ * Markdown already conveys "this is a heading" with `#` and "this is a header row" with the
120
+ * separator line, so a run inside one that also carries bold - the normal case for ODF, whose
121
+ * heading and header-row paragraph styles are bold and are now inherited by their runs - would
122
+ * render as `# **Heading**` and `| **ITEM** |`. That is redundant rather than wrong, but it
123
+ * also round-trips back into bold text nodes nested inside a heading, so the noise compounds
124
+ * on every parse/generate cycle. Emphasis the node type already implies is dropped; every
125
+ * other formatting flag still comes through.
126
+ */
127
+ inImplicitBold = false;
116
128
  hoistedContent = [];
117
129
  collectedAbbreviations = new Map();
118
130
  resolvedDialect;
@@ -245,7 +257,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
245
257
  let text = (0, sanitize_js_1.markdownEscapeText)(node.text || '');
246
258
  if (this.config.includeFormatting && node.formatting) {
247
259
  const emphasisAsterisk = this.resolvedDialect.emphasisMarker === 'asterisk';
248
- if (node.formatting.bold)
260
+ if (node.formatting.bold && !this.inImplicitBold)
249
261
  text = emphasisAsterisk ? `**${text}**` : `__${text}__`;
250
262
  if (node.formatting.italic)
251
263
  text = emphasisAsterisk ? `*${text}*` : `_${text}_`;
@@ -544,7 +556,12 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
544
556
  if (!this.resolvedDialect.definitionLists)
545
557
  return `${childrenOutput}\n\n`;
546
558
  return `: ${childrenOutput}\n`;
547
- default:
559
+ case 'chart':
560
+ case 'drawing':
561
+ case 'comment':
562
+ case 'header':
563
+ case 'footer':
564
+ case 'slideMaster':
548
565
  return childrenOutput;
549
566
  }
550
567
  };
@@ -612,14 +629,19 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
612
629
  if (typeof override === 'string') {
613
630
  return override;
614
631
  }
632
+ const walkedByProcessor = node.type === 'table' || node.type === 'sheet';
633
+ const wasInImplicitBold = this.inImplicitBold;
634
+ if (node.type === 'heading' && this.hasUniformFormatting(node, f => f?.bold === true))
635
+ this.inImplicitBold = true;
615
636
  let childrenOutput = '';
616
- if (node.children && node.children.length > 0) {
637
+ if (!walkedByProcessor && node.children && node.children.length > 0) {
617
638
  // Optimization: Merge adjacent text nodes with identical formatting
618
639
  const optimizedChildren = this.optimizeNodes(node.children);
619
640
  for (const child of optimizedChildren) {
620
641
  childrenOutput += await this.processNodeRecursive(child, processor);
621
642
  }
622
643
  }
644
+ this.inImplicitBold = wasInImplicitBold;
623
645
  // When the dialect has no footnote syntax, a footnote/endnote is inlined right at its
624
646
  // reference point instead (see below) - so it must not also be collected into the
625
647
  // end-of-document "### Notes" section, or its content would be duplicated.
@@ -627,11 +649,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
627
649
  const meta = note.metadata;
628
650
  return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && !this.resolvedDialect.footnotes;
629
651
  };
630
- if (node.notes && node.notes.length > 0) {
631
- if (node.type !== 'slide') {
632
- this.collectedNotes.push(...node.notes.filter(note => !isInlinedFootnote(note)));
633
- }
634
- }
652
+ this.collectNotesFrom(node);
635
653
  let result = await processor(node, childrenOutput);
636
654
  if (node.type === 'slide' && node.notes && node.notes.length > 0) {
637
655
  for (const note of node.notes) {
@@ -732,46 +750,73 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
732
750
  this.isInsideTable = false;
733
751
  return result;
734
752
  }
753
+ collectNotesFrom(node) {
754
+ if (!node.notes || node.notes.length === 0)
755
+ return;
756
+ if (node.type === 'slide')
757
+ return;
758
+ const isInlinedFootnote = (note) => {
759
+ const meta = note.metadata;
760
+ return (meta?.noteType === 'footnote' || meta?.noteType === 'endnote') && !this.resolvedDialect.footnotes;
761
+ };
762
+ this.collectedNotes.push(...node.notes.filter(note => !isInlinedFootnote(note)));
763
+ }
735
764
  async renderMarkdownTableInternal(node, processor) {
736
765
  let tableOutput = '';
737
766
  let maxCols = 0;
738
767
  // First pass: Process rows and determine max columns (accounting for colspans)
739
768
  const processedRows = [];
740
769
  for (const rowNode of (node.children ?? [])) {
741
- const override = await this.handleOnNode(rowNode);
742
- if (override === false)
743
- continue;
744
- if (typeof override === 'string') {
745
- processedRows.push([override]);
746
- continue;
747
- }
748
- const rowCells = [];
749
- let lastCol = -1;
750
- if (rowNode.children) {
751
- const cellNodes = rowNode.children.filter(c => c.type === 'cell');
752
- for (const cellNode of cellNodes) {
753
- const currentCol = cellNode.metadata?.col ?? (lastCol + 1);
754
- // Fill gaps with empty cells
755
- while (lastCol < currentCol - 1) {
756
- rowCells.push(' ');
757
- lastCol++;
758
- }
759
- // Process cell content
760
- let cellContent = await this.processNodeRecursive(cellNode, processor);
761
- // Use <br> fallback only if allowed, otherwise space
762
- const br = this.resolvedFallbackToHtml.cellLineBreaks ? '<br>' : ' ';
763
- cellContent = cellContent.trim().replace(/\n+/g, br).replace(/\|/g, '\\|');
764
- rowCells.push(cellContent);
765
- // Handle colspan by adding empty cells
766
- const colSpan = cellNode.metadata?.colSpan || 1;
767
- for (let i = 1; i < colSpan; i++) {
768
- rowCells.push(' ');
770
+ // The first row becomes the header row - the `| --- |` separator emitted below marks
771
+ // it as such - so bold inside it is already implied.
772
+ const wasInImplicitBold = this.inImplicitBold;
773
+ if (processedRows.length === 0 && this.hasUniformFormatting(rowNode, f => f?.bold === true))
774
+ this.inImplicitBold = true;
775
+ try {
776
+ const override = await this.handleOnNode(rowNode);
777
+ if (override === false)
778
+ continue;
779
+ if (typeof override === 'string') {
780
+ processedRows.push([override]);
781
+ continue;
782
+ }
783
+ // After the override checks, not before: a row the caller skipped via `onNode` must not
784
+ // still contribute its footnote to the end-of-document Notes section, where it would
785
+ // appear with no `[^id]` marker anywhere in the document pointing at it.
786
+ // `renderTableAsHtml` gets this right by returning early, so collecting here keeps the
787
+ // pipe and HTML paths agreeing on what a skipped row means.
788
+ this.collectNotesFrom(rowNode);
789
+ const rowCells = [];
790
+ let lastCol = -1;
791
+ if (rowNode.children) {
792
+ const cellNodes = rowNode.children.filter(c => c.type === 'cell');
793
+ for (const cellNode of cellNodes) {
794
+ const currentCol = cellNode.metadata?.col ?? (lastCol + 1);
795
+ // Fill gaps with empty cells
796
+ while (lastCol < currentCol - 1) {
797
+ rowCells.push(' ');
798
+ lastCol++;
799
+ }
800
+ // Process cell content
801
+ let cellContent = await this.processNodeRecursive(cellNode, processor);
802
+ // Use <br> fallback only if allowed, otherwise space
803
+ const br = this.resolvedFallbackToHtml.cellLineBreaks ? '<br>' : ' ';
804
+ cellContent = cellContent.trim().replace(/\n+/g, br).replace(/\|/g, '\\|');
805
+ rowCells.push(cellContent);
806
+ // Handle colspan by adding empty cells
807
+ const colSpan = cellNode.metadata?.colSpan || 1;
808
+ for (let i = 1; i < colSpan; i++) {
809
+ rowCells.push(' ');
810
+ }
811
+ lastCol = currentCol + colSpan - 1;
769
812
  }
770
- lastCol = currentCol + colSpan - 1;
771
813
  }
814
+ processedRows.push(rowCells);
815
+ maxCols = Math.max(maxCols, rowCells.length);
816
+ }
817
+ finally {
818
+ this.inImplicitBold = wasInImplicitBold;
772
819
  }
773
- processedRows.push(rowCells);
774
- maxCols = Math.max(maxCols, rowCells.length);
775
820
  }
776
821
  // Second pass: Build table string with separator
777
822
  for (let i = 0; i < processedRows.length; i++) {
@@ -842,6 +887,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
842
887
  return `<table${alignAttr}>\n${rows}</table>\n`;
843
888
  }
844
889
  else if (node.type === 'row') {
890
+ this.collectNotesFrom(node);
845
891
  let cells = '';
846
892
  if (node.children) {
847
893
  for (const cell of node.children) {
@@ -851,6 +897,7 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
851
897
  return ` <tr>\n${cells} </tr>\n`;
852
898
  }
853
899
  else if (node.type === 'cell') {
900
+ this.collectNotesFrom(node);
854
901
  const meta = node.metadata;
855
902
  const rs = meta?.rowSpan > 1 ? ` rowspan="${meta.rowSpan}"` : '';
856
903
  const cs = meta?.colSpan > 1 ? ` colspan="${meta.colSpan}"` : '';
@@ -861,10 +908,16 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
861
908
  content += await this.processNodeRecursive(child, async (n, co) => {
862
909
  switch (n.type) {
863
910
  case 'text': {
911
+ if (n.metadata) {
912
+ const m = n.metadata;
913
+ if (m.abbreviationTitle) {
914
+ this.collectedAbbreviations.set(n.text || '', m.abbreviationTitle);
915
+ }
916
+ }
864
917
  // Inside HTML table cells, entity-encode angle brackets so cell
865
918
  // text can't inject a raw tag (e.g. </td><script>).
866
919
  let text = (0, sanitize_js_1.markdownEscapeText)(n.text || '');
867
- if (n.formatting?.bold)
920
+ if (n.formatting?.bold && !this.inImplicitBold)
868
921
  text = `<b>${text}</b>`;
869
922
  if (n.formatting?.italic)
870
923
  text = `<i>${text}</i>`;
@@ -882,7 +935,28 @@ class MarkdownGenerator extends BaseGenerator_js_1.BaseGenerator {
882
935
  return `<h${level}>${co}</h${level}>`;
883
936
  }
884
937
  case 'table': return await this.renderTableAsHtml(n);
885
- default: return co;
938
+ case 'list':
939
+ case 'image':
940
+ case 'chart':
941
+ case 'drawing':
942
+ case 'slide':
943
+ case 'note':
944
+ case 'sheet':
945
+ case 'row':
946
+ case 'cell':
947
+ case 'page':
948
+ case 'break':
949
+ case 'code':
950
+ case 'comment':
951
+ case 'header':
952
+ case 'footer':
953
+ case 'slideMaster':
954
+ case 'embed':
955
+ case 'admonition':
956
+ case 'definitionList':
957
+ case 'definitionTerm':
958
+ case 'definitionDescription':
959
+ return co;
886
960
  }
887
961
  });
888
962
  }
@@ -6,6 +6,19 @@ import { BaseGenerator } from './BaseGenerator.js';
6
6
  export declare class RtfGenerator extends BaseGenerator<'rtf'> {
7
7
  private colorTable;
8
8
  private inTable;
9
+ /**
10
+ * Set while rendering a heading's children.
11
+ *
12
+ * A heading emits its own `{\\b\\fs44 ...}` wrapper, so a run inside it that also carries bold
13
+ * and a size - which is now the normal case for ODF, where the heading's paragraph style is
14
+ * inherited by its runs - would emit a nested `\\fs28` that *overrides* the outer `\\fs44`.
15
+ * The heading then renders at the body-text size it was styled with rather than at heading
16
+ * size. Suppressing the inherited weight and size inside a heading keeps the heading's own
17
+ * wrapper authoritative; every other property (colour, font) still comes through.
18
+ */
19
+ private inHeading;
20
+ /** As `inHeading`, but for the inherited font size - see `hasUniformFormatting`. */
21
+ private headingUniformSize;
9
22
  constructor(ast: OfficeParserAST, config?: GeneratorConfig<'rtf'>);
10
23
  generate(): Promise<ConversionResult<'rtf'>>;
11
24
  protected processNodeRecursive(node: OfficeContentNode, processor: (node: OfficeContentNode, childrenOutput: string) => Promise<string>): Promise<string>;
@@ -10,6 +10,19 @@ const errorUtils_js_1 = require("../utils/errorUtils.js");
10
10
  class RtfGenerator extends BaseGenerator_js_1.BaseGenerator {
11
11
  colorTable = [];
12
12
  inTable = false;
13
+ /**
14
+ * Set while rendering a heading's children.
15
+ *
16
+ * A heading emits its own `{\\b\\fs44 ...}` wrapper, so a run inside it that also carries bold
17
+ * and a size - which is now the normal case for ODF, where the heading's paragraph style is
18
+ * inherited by its runs - would emit a nested `\\fs28` that *overrides* the outer `\\fs44`.
19
+ * The heading then renders at the body-text size it was styled with rather than at heading
20
+ * size. Suppressing the inherited weight and size inside a heading keeps the heading's own
21
+ * wrapper authoritative; every other property (colour, font) still comes through.
22
+ */
23
+ inHeading = false;
24
+ /** As `inHeading`, but for the inherited font size - see `hasUniformFormatting`. */
25
+ headingUniformSize = false;
13
26
  constructor(ast, config) {
14
27
  super('rtf', ast, config);
15
28
  }
@@ -68,8 +81,16 @@ class RtfGenerator extends BaseGenerator_js_1.BaseGenerator {
68
81
  const wasInTable = this.inTable;
69
82
  if (node.type === 'table')
70
83
  this.inTable = true;
84
+ const wasInHeading = this.inHeading;
85
+ const wasHeadingSize = this.headingUniformSize;
86
+ if (node.type === 'heading') {
87
+ this.inHeading = this.hasUniformFormatting(node, f => f?.bold === true);
88
+ this.headingUniformSize = this.hasUniformFormatting(node, f => !!f?.size);
89
+ }
71
90
  const result = await super.processNodeRecursive(node, processor);
72
91
  this.inTable = wasInTable;
92
+ this.inHeading = wasInHeading;
93
+ this.headingUniformSize = wasHeadingSize;
73
94
  return result;
74
95
  }
75
96
  async renderBody(ast) {
@@ -98,7 +119,7 @@ class RtfGenerator extends BaseGenerator_js_1.BaseGenerator {
98
119
  if (this.config.includeFormatting && f) {
99
120
  let prefix = '';
100
121
  let suffix = '';
101
- if (f.bold) {
122
+ if (f.bold && !this.inHeading) {
102
123
  prefix += '\\b ';
103
124
  suffix = '\\b0 ' + suffix;
104
125
  }
@@ -127,7 +148,7 @@ class RtfGenerator extends BaseGenerator_js_1.BaseGenerator {
127
148
  const idx = this.getColorIndex(f.backgroundColor);
128
149
  prefix += `\\highlight${idx + 1} `;
129
150
  }
130
- if (f.size) {
151
+ if (f.size && !this.headingUniformSize) {
131
152
  let pt = 12; // default
132
153
  const val = parseFloat(f.size);
133
154
  if (!isNaN(val)) {