0 directories, 46 files

util

Home / tinai / util
/**
 * Converts rendered HTML into plain text suitable for read-aloud (TTS).
 * Tables are spoken row by row, MathJax equations through their assistive
 * MathML with spoken fraction/root/power structure, and mermaid diagrams as
 * a list of their text labels. Other content contributes its text with
 * block-level elements separated by pauses.
 */

/**
 * Returns the text with all whitespace runs collapsed to single spaces.
 * @param {string} text - The raw text.
 * @returns {string} The normalized text.
 */
function normalize(text) {
	return String(text).replace(/\s+/g, ' ');
}

/**
 * Returns the visible text of a node with normalized whitespace.
 * @param {Element} el - The element.
 * @returns {string} The normalized text.
 */
function inline_text(el) {
	return normalize(el.textContent).trim();
}

/**
 * Converts the assistive MathML of a rendered equation into spoken words,
 * giving fractions, roots, subscripts, and superscripts their spoken names.
 * @param {Node} node - The MathML node to convert.
 * @returns {string} The spoken form.
 */
function mathml_to_text(node) {
	if (node.nodeType === Node.TEXT_NODE) {
		return normalize(node.textContent);
	}
	if (node.nodeType !== Node.ELEMENT_NODE) {
		return '';
	}
	const tag = node.tagName.toLowerCase();
	const children = () => [...node.childNodes].map(mathml_to_text).filter(Boolean).join(' ');
	const parts = () => [...node.children].map(mathml_to_text);
	switch (tag) {
		case 'mfrac': {
			const p = parts();
			return p.length === 2 ? `fraction numerator ${p[0]} denominator ${p[1]}` : children();
		}
		case 'msqrt':
			return `square root of ${children()}`;
		case 'mroot': {
			const p = parts();
			return p.length === 2 ? `the ${p[1]} root of ${p[0]}` : children();
		}
		case 'msup': {
			const p = parts();
			return p.length === 2 ? `${p[0]} raised to the power ${p[1]}` : children();
		}
		case 'msub': {
			const p = parts();
			return p.length === 2 ? `${p[0]} sub ${p[1]}` : children();
		}
		case 'msubsup': {
			const p = parts();
			return p.length === 3 ? `${p[0]} sub ${p[1]} raised to the power ${p[2]}` : children();
		}
		default:
			return children();
	}
}

/**
 * Extracts the spoken form of a rendered MathJax equation from its container:
 * prefers MathJax's own speech label and falls back to its assistive MathML.
 * @param {Element} el - The mjx-container element.
 * @returns {string} The spoken equation, or an empty string when unavailable.
 */
function math_to_text(el) {
	const speech = el.querySelector('mjx-speech[aria-label]');
	if (speech) {
		return normalize(speech.getAttribute('aria-label')).replace(/,\s*(display\s+)?math$/i, '').trim();
	}
	const math = el.querySelector('math');
	return math ? mathml_to_text(math) : '';
}

/**
 * Returns whether an svg element is a rendered mermaid diagram.
 * @param {Element} svg - The svg element.
 * @returns {boolean} True when it looks like a mermaid diagram.
 */
function is_mermaid_svg(svg) {
	const role = svg.getAttribute('aria-roledescription') || '';
	return svg.id.startsWith('mermaid') || role.toLowerCase().includes('mermaid');
}

/**
 * Extracts the spoken form of a rendered mermaid diagram: its text labels in
 * document order.
 * @param {Element} el - The diagram container or svg element.
 * @returns {string} The spoken diagram description.
 */
function diagram_to_text(el) {
	const isSvg = el.tagName.toLowerCase() === 'svg';
	const svg = isSvg ? el : el.querySelector('svg');
	if (!svg || (isSvg && !is_mermaid_svg(svg))) return '';
	const labels = [...svg.querySelectorAll('text')]
		.map(text => inline_text(text))
		.filter(Boolean)
		.filter((label, i, all) => i === 0 || label !== all[i - 1]);
	return labels.length ? `Diagram: ${labels.join(', ')}.` : 'Diagram.';
}

/**
 * Converts a table into spoken text: the caption, then each row's cells
 * prefixed with "Headers" for header rows and "Row" for the others.
 * @param {Element} table - The table element.
 * @param {string[]} out - The output chunk list.
 */
function table_to_text(table, out) {
	const caption = table.querySelector(':scope > caption');
	if (caption) {
		out.push(`Table: ${inline_text(caption)}. `);
	}
	const rows = table.querySelectorAll(':scope > thead > tr, :scope > tbody > tr, :scope > tfoot > tr, :scope > tr');
	rows.forEach((tr) => {
		const cells = [...tr.querySelectorAll(':scope > th, :scope > td')];
		if (cells.length === 0) return;
		const isHeader = tr.parentElement.tagName === 'THEAD' || cells.every(cell => cell.tagName === 'TH');
		const prefix = isHeader ? 'Headers: ' : 'Row: ';
		out.push(`${prefix}${cells.map(cell => to_speech_text(cell)).join(', ')}. `);
	});
}

/**
 * Returns whether the element is a block-level element, so following content
 * starts a new phrase.
 * @param {Element} el - The element.
 * @returns {boolean} True when block-level.
 */
function is_block(el) {
	const tag = el.tagName;
	return /^(P|DIV|SECTION|ARTICLE|UL|OL|LI|TABLE|THEAD|TBODY|TFOOT|TR|PRE|BLOCKQUOTE|FIGURE|FIGCAPTION|DL|DT|DD|H[1-6]|DETAILS|SUMMARY|HR)$/.test(tag);
}

/**
 * Appends the spoken text of an element's children to the output list.
 * @param {Element} el - The element to walk.
 * @param {string[]} out - The output chunk list.
 */
function walk_element(el, out) {
	const tag = el.tagName.toLowerCase();
	if (tag === 'script' || tag === 'style' || tag === 'template') return;
	if (tag === 'mjx-container') {
		const text = math_to_text(el);
		if (text) out.push(`${text} `);
		return;
	}
	if (el.matches('.mermaid, .div-diagram-mermaid')) {
		out.push(`${diagram_to_text(el)} `);
		return;
	}
	if (tag === 'svg' && is_mermaid_svg(el)) {
		out.push(`${diagram_to_text(el)} `);
		return;
	}
	if (tag === 'table') {
		table_to_text(el, out);
		out.push(' ');
		return;
	}
	if (tag === 'br' || tag === 'hr') {
		out.push(' ');
		return;
	}
	walk_children(el, out);
	if (is_block(el)) {
		out.push(' ');
	}
}

/**
 * Appends the spoken text of a node's children to the output list.
 * @param {Node} node - The node to walk.
 * @param {string[]} out - The output chunk list.
 */
function walk_children(node, out) {
	for (const child of node.childNodes) {
		if (child.nodeType === Node.TEXT_NODE) {
			out.push(normalize(child.textContent));
		} else if (child.nodeType === Node.ELEMENT_NODE) {
			walk_element(child, out);
		}
	}
}

/**
 * Converts rendered HTML into plain text suitable for read-aloud: tables,
 * rendered equations, and diagrams are spoken in a structured form, and
 * block-level boundaries become pauses.
 * @param {Element|null} root - The element to convert.
 * @returns {string} The speech text.
 */
export function to_speech_text(root) {
	if (!root) return '';
	const out = [];
	walk_children(root, out);
	return normalize(out.join('')).trim();
}
🌐
speech-text.js ×
Type: Web, text/plain
6.60 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File