From 0cdbfe8e9a1347d0e21515aad1f6333cba124d0c Mon Sep 17 00:00:00 2001 From: Mimi <1119186082@qq.com> Date: Fri, 7 Aug 2026 09:47:20 +0800 Subject: [PATCH] fix(tocObj): extract semantic math text --- lib/toc_obj.ts | 35 ++++++++++++++++++++++++++++++++--- test/toc_obj.spec.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/lib/toc_obj.ts b/lib/toc_obj.ts index 478669b6..05dfa089 100644 --- a/lib/toc_obj.ts +++ b/lib/toc_obj.ts @@ -1,5 +1,5 @@ import { DomHandler, DomUtils, Parser } from 'htmlparser2'; -import type { Element } from 'domhandler'; +import type { AnyNode, Element } from 'domhandler'; import escapeHTML from './escape_html'; const nonWord = /^\s*[^a-zA-Z0-9]\s*$/; @@ -20,6 +20,35 @@ const isUnnumbered = ({ attribs = {} }) => { return attribs['data-toc-unnumbered'] === 'true'; }; +const isElement = (node: AnyNode): node is Element => 'tagName' in node; + +/** + * Extract text from the semantic representation of a DOM subtree. + */ +const semanticText = (node: AnyNode): string => { + if (node.type === 'text') return node.data; + if (!('children' in node)) return ''; + + if (isElement(node)) { + const { attribs, name } = node; + if (attribs['aria-hidden']?.toLowerCase() === 'true' || 'hidden' in attribs) return ''; + + if (name === 'semantics') { + const tex = node.children.find(child => isElement(child) + && child.name === 'annotation' + && child.attribs.encoding?.toLowerCase() === 'application/x-tex'); + if (tex) return semanticText(tex); + + const presentation = node.children.find(child => isElement(child) + && child.name !== 'annotation' + && child.name !== 'annotation-xml'); + return presentation ? semanticText(presentation) : ''; + } + } + + return node.children.map(semanticText).join(''); +}; + interface Result { text: string; id: string; @@ -48,7 +77,7 @@ function tocObj(str: string, options = {}) { const unnumbered = isUnnumbered(el); let text = ''; for (const element of el.children) { - const elText = DomUtils.textContent(element); + const elText = semanticText(element); // Skip permalink symbol wrapped in // permalink is a single non-word character, word = [a-Z0-9] // permalink may be wrapped in whitespace(s) @@ -56,7 +85,7 @@ function tocObj(str: string, options = {}) { text += escapeHTML(elText); } } - if (!text) text = escapeHTML(DomUtils.textContent(el)); + if (!text) text = escapeHTML(semanticText(el)); const res: Result = { text, id, level }; if (unnumbered) res.unnumbered = true; diff --git a/test/toc_obj.spec.ts b/test/toc_obj.spec.ts index 40ad65c1..2a9b886a 100644 --- a/test/toc_obj.spec.ts +++ b/test/toc_obj.spec.ts @@ -164,6 +164,39 @@ describe('tocObj', () => { result.forEach(str => str[0].text.should.eql('foobarbaz')); }); + it('hidden elements', () => { + const input = '

Visible

'; + const result = tocObj(input); + + result[0].text.should.eql('Visible'); + }); + + it('KaTeX MathML', () => { + const input = [ + '

Energy ', + '', + 'E=mc2', + 'E=mc^2', + '', + '', + '

' + ].join(''); + const result = tocObj(input); + + result[0].text.should.eql('Energy E=mc^2'); + }); + + it('MathML without TeX annotation', () => { + const input = [ + '

x+1', + 'duplicate', + '

' + ].join(''); + const result = tocObj(input); + + result[0].text.should.eql('x+1'); + }); + it('unnumbered headings', () => { const input = [ '

Title 1

',