@@ -108,8 +108,8 @@ const NON_CONTENT_SELECTOR = 'script, style, noscript, meta, link, iframe, objec
108108/** Block elements inside a table cell; `.text()` would otherwise glue their words together. */
109109const CELL_BLOCK_SELECTOR = 'p, div, li, br, h1, h2, h3, h4, h5, h6, tr'
110110
111- /** mammoth renders a footnote's return link as `<a href="#footnote-ref-N">↑</a>`. */
112- const FOOTNOTE_BACKLINK_SELECTOR = 'a[href^="#footnote-ref"]'
111+ /** mammoth renders a footnote's or endnote's return link as `<a href="#footnote-ref-N">↑</a>`. */
112+ const FOOTNOTE_BACKLINK_SELECTOR = 'a[href^="#footnote-ref"], a[href^="#endnote-ref"] '
113113
114114/**
115115 * Strips the non-content markup and HTML comments from a loaded document so the
@@ -318,7 +318,9 @@ function processListItem(
318318 return
319319 }
320320 }
321- const text = $ ( child ) . text ( ) . replace ( / \s + / g, ' ' ) . trim ( )
321+ const $child = $ ( child )
322+ $child . find ( CELL_BLOCK_SELECTOR ) . after ( ' ' )
323+ const text = $child . text ( ) . replace ( / \s + / g, ' ' ) . trim ( )
322324 if ( text ) ownText . push ( text )
323325 } )
324326
@@ -335,8 +337,69 @@ function processListItem(
335337 }
336338}
337339
340+ /** A table's own rows: direct `<tr>` children and those under its section elements. */
341+ function directRows (
342+ $ : cheerio . CheerioAPI ,
343+ table : cheerio . Cheerio < AnyNode >
344+ ) : cheerio . Cheerio < AnyNode > {
345+ return table . children ( 'thead, tbody, tfoot' ) . children ( 'tr' ) . add ( table . children ( 'tr' ) )
346+ }
347+
348+ /** Nested tables whose nearest enclosing table is the cell's own. */
349+ function topLevelNestedTables (
350+ $ : cheerio . CheerioAPI ,
351+ cell : cheerio . Cheerio < AnyNode >
352+ ) : cheerio . Cheerio < AnyNode > {
353+ const own = cell . closest ( 'table' ) . get ( 0 )
354+ return cell . find ( 'table' ) . filter ( ( _ , nested ) => $ ( nested ) . parents ( 'table' ) . get ( 0 ) === own )
355+ }
356+
357+ /**
358+ * The cells of a table in reading order, each rendered with {@link cellText},
359+ * for a table nested inside another table's cell.
360+ */
361+ function flattenedTableCells ( $ : cheerio . CheerioAPI , table : cheerio . Cheerio < AnyNode > ) : string [ ] {
362+ const cells : string [ ] = [ ]
363+ directRows ( $ , table ) . each ( ( _ , row ) => {
364+ $ ( row )
365+ . children ( 'td, th' )
366+ . each ( ( _ , cell ) => {
367+ const text = cellText ( $ , $ ( cell ) )
368+ if ( text ) cells . push ( text )
369+ } )
370+ } )
371+ return cells
372+ }
373+
374+ /**
375+ * One cell's text on a single line. Block elements inside the cell get a space
376+ * so adjacent paragraphs do not glue together — this mutates the live DOM, and
377+ * runs before `extractHeadings`/`extractLinks`, so heading or link text inside a
378+ * cell gains those spaces too. A nested table is rendered on a clone of the cell
379+ * as its cells joined with ` / `, without `[Table]` markers or pipes, so the
380+ * outer row stays one line and the inner text appears exactly once.
381+ */
382+ function cellText ( $ : cheerio . CheerioAPI , cell : cheerio . Cheerio < AnyNode > ) : string {
383+ const nested = topLevelNestedTables ( $ , cell )
384+ if ( nested . length === 0 ) {
385+ cell . find ( CELL_BLOCK_SELECTOR ) . after ( ' ' )
386+ return cell . text ( ) . replace ( / \s + / g, ' ' ) . trim ( )
387+ }
388+
389+ const clone = cell . clone ( )
390+ topLevelNestedTables ( $ , clone ) . each ( ( _ , table ) => {
391+ const $table = $ ( table )
392+ $table . replaceWith ( ` ${ flattenedTableCells ( $ , $table ) . join ( ' / ' ) } ` )
393+ } )
394+ clone . find ( CELL_BLOCK_SELECTOR ) . after ( ' ' )
395+ return clone . text ( ) . replace ( / \s + / g, ' ' ) . trim ( )
396+ }
397+
338398/**
339- * Process table elements to extract structured data
399+ * Renders a table as `[Table]`, one `| a | b |` line per row, `[/Table]`. Only
400+ * the table's own rows and each row's own cells are visited, so a nested table
401+ * contributes to its containing cell (see {@link cellText}) and is never
402+ * emitted a second time as rows of its own.
340403 */
341404function processTable (
342405 $ : cheerio . CheerioAPI ,
@@ -345,16 +408,14 @@ function processTable(
345408) : void {
346409 contentParts . push ( '\n[Table]' )
347410
348- table . find ( 'tr' ) . each ( ( _ , row ) => {
349- const $row = $ ( row )
411+ directRows ( $ , table ) . each ( ( _ , row ) => {
350412 const cells : string [ ] = [ ]
351413
352- $row . find ( 'td, th' ) . each ( ( _ , cell ) => {
353- const $cell = $ ( cell )
354- $cell . find ( CELL_BLOCK_SELECTOR ) . after ( ' ' )
355- const cellText = $cell . text ( ) . replace ( / \s + / g, ' ' ) . trim ( )
356- cells . push ( cellText || '' )
357- } )
414+ $ ( row )
415+ . children ( 'td, th' )
416+ . each ( ( _ , cell ) => {
417+ cells . push ( cellText ( $ , $ ( cell ) ) )
418+ } )
358419
359420 if ( cells . length > 0 ) {
360421 contentParts . push ( `| ${ cells . join ( ' | ' ) } |` )
@@ -397,7 +458,6 @@ export class HtmlParser implements FileParser {
397458 const htmlContent = decoded . text
398459 const $ = cheerio . load ( htmlContent )
399460
400- // Extract meta information before removing tags
401461 const title = $ ( 'title' ) . text ( ) . trim ( )
402462 const metaDescription = $ ( 'meta[name="description"]' ) . attr ( 'content' ) || ''
403463
0 commit comments