Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/elements/equation-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { isElement, isTextNode } from '../utils';
import { isDirectTableChild } from '../utils/dom';

export const EQUATION_TABLE_SELECTOR = 'table.ltx_equation, table.ltx_eqn_table, table.ltx_equationgroup';

/** Only unwrap a whole equation, never cell fragments or alternative MathML. */
export function getSingleEquationMath(table: Element): Element | null {
const maths = Array.from(table.querySelectorAll('math'))
.filter(math => isDirectTableChild(math, table) && !math.parentElement?.closest('math, annotation-xml'));
if (maths.length !== 1) return null;
const math = maths[0];
// Keep unconvertible sources and XML alternatives in their table context.
if (!math.getAttribute('data-latex')?.trim() || math.querySelector('annotation-xml, math')) return null;
// A generated text fallback is not evidence for promoting native inline math.
if (math.getAttribute('display') !== 'block' && !math.querySelector('annotation')) return null;
const rows = Array.from(table.querySelectorAll('tr')).filter(row => isDirectTableChild(row, table));
if (rows.length !== 1) return null;

const isEquationLayout = (node: Node): boolean => {
if (node === math) return true;
if (isTextNode(node)) return !node.textContent?.trim();
if (!isElement(node)) return node.nodeType === 8;
// Equation numbers are dropped, as for unwrapped equations before
if (node.classList.contains('ltx_eqn_eqno')) return true;
if (!['table', 'tbody', 'tr', 'td'].includes(node.nodeName.toLowerCase())) return false;
if (node !== table && node.nodeName.toLowerCase() === 'table') return false;
if (node.hasAttribute('colspan') || node.hasAttribute('rowspan')) return false;
return Array.from(node.childNodes).every(isEquationLayout);
};
return isEquationLayout(table) ? math : null;
}
17 changes: 16 additions & 1 deletion src/elements/math.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,17 @@ export function wrapRawLatexDelimiters(element: Element, doc: Document): void {

export type LatexToMathML = (latex: string, isBlock: boolean, doc: Document) => Element | null;

const hasPresentationalMathContent = (math: Element): boolean => {
const isPresentationalNode = (node: Node): boolean => {
if (isTextNode(node)) return Boolean(node.textContent?.trim());
if (!isElement(node)) return false;
const tag = node.tagName.toLowerCase();
if (tag === 'annotation' || tag === 'annotation-xml') return false;
return tag !== 'semantics' || Array.from(node.childNodes).some(isPresentationalNode);
};
return Array.from(math.childNodes).some(isPresentationalNode);
};

export const createMathElement = (
mathData: MathData | null,
latex: string | null,
Expand All @@ -676,7 +687,11 @@ export const createMathElement = (
const fragment = parseHTML(doc, mathData.mathml);
const mathContent = fragment.querySelector('math');
if (mathContent) {
transferContent(mathContent, cleanMathEl);
if (latex && !hasPresentationalMathContent(mathContent) && !mathContent.querySelector('annotation-xml')) {
cleanMathEl.textContent = latex;
} else {
transferContent(mathContent, cleanMathEl);
}
}
}
// If no MathML but we have LaTeX, convert it when possible or keep it as text
Expand Down
20 changes: 9 additions & 11 deletions src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,18 +814,16 @@ export function createMarkdownContent(content: string, url: string) {
});

function handleNestedEquations(element: GenericElement): string {
const mathElements = element.querySelectorAll('math');
if (mathElements.length === 0) return '';

return Array.from(mathElements).map(mathElement => {
const annotation = mathElement.querySelector('annotation[encoding="application/x-tex"]');
const latex = annotation?.textContent?.trim() || mathElement.getAttribute('alttext')?.trim();
if (latex) {
const isInline = mathElement.closest('.ltx_eqn_inline, .mwe-math-element-inline') !== null;
return Array.from(element.querySelectorAll('math'))
.filter(math => !math.parentElement?.closest('math'))
.map(math => {
const latex = extractLatex(asGenericElement(math));
if (!latex) return '';
const isInline = math.closest('.ltx_eqn_inline, .mwe-math-element-inline') !== null;
return formatMath(latex, !isInline);
}
return '';
}).join('\n\n');
})
.filter(Boolean)
.join('\n\n');
}

function cleanupTableHTML(element: GenericElement): string {
Expand Down
63 changes: 17 additions & 46 deletions src/standardize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {

import { DefuddleMetadata } from './types';
import { mathRules, createCleanMathEl } from './elements/math';
import { EQUATION_TABLE_SELECTOR, getSingleEquationMath } from './elements/equation-table';
import { wrapRawLatexDelimiters, extractLatexFromImageSrc, LOOKS_LIKE_LATEX_RE } from './elements/math.base';
import { codeBlockRules } from './elements/code';
import { headingRules, removePermalinkAnchors, isPermalinkAnchor } from './elements/headings';
Expand Down Expand Up @@ -499,6 +500,7 @@ function stripUnwantedAttributes(element: Element, debug: boolean, extractorOutp
)) ||
// Preserve code block language classes, footnote backref class, and callout classes
(attrName === 'class' && (
(tag === 'table' && attrValue === 'ltx_eqn_table') ||
(tag === 'code' && attrValue.startsWith('language-')) ||
attrValue === 'footnote-backref' ||
/^callout(?:-|$)/.test(attrValue)
Expand Down Expand Up @@ -1381,54 +1383,22 @@ function standardizeElements(element: Element, doc: Document, subProfile?: Recor
outerCode.replaceWith(pre);
});

// arXiv LaTeXML: Convert equation tables to <math> elements before attribute stripping
const equationTables = Array.from(element.querySelectorAll('table.ltx_equation, table.ltx_eqn_table, table.ltx_equationgroup'));
const hasPresentationalMathContent = (mathEl: Element): boolean => {
const isPresentationalNode = (node: Node): boolean => {
if (isTextNode(node)) return Boolean(node.textContent?.trim());
if (!isElement(node)) return false;

const tag = node.tagName.toLowerCase();
if (tag === 'annotation' || tag === 'annotation-xml') return false;
if (tag === 'semantics') {
return Array.from(node.childNodes).some(isPresentationalNode);
// Math rules own normalization. The table adapter only unwraps a single
// whole equation; labels, fragments, alternatives and mixed content stay put.
const equationTables = Array.from(element.querySelectorAll(EQUATION_TABLE_SELECTOR));
equationTables.reverse().forEach(table => {
if (table.parentElement?.closest('math, annotation-xml')) return;
const math = getSingleEquationMath(table);
if (math) {
// Retain the existing standalone equation display policy.
if (table.classList.contains('ltx_equation') || table.classList.contains('ltx_equationgroup')) {
math.setAttribute('display', 'block');
}

return true;
};

return Array.from(mathEl.childNodes).some(isPresentationalNode);
};
equationTables.forEach(table => {
const mathElements = table.querySelectorAll('math');
if (mathElements.length === 0) return;

const fragment = doc.createDocumentFragment();
mathElements.forEach(mathEl => {
// Extract LaTeX from alttext or annotation
const alttext = mathEl.getAttribute('alttext');
const annotation = mathEl.querySelector('annotation[encoding="application/x-tex"]');
const latex = alttext || annotation?.textContent?.trim() || '';

if (!latex) return;

const isBlock = mathEl.getAttribute('display') === 'block' ||
table.classList.contains('ltx_equation') ||
table.classList.contains('ltx_equationgroup');

const cleanMath = mathEl.cloneNode(true) as Element;
cleanMath.setAttribute('xmlns', 'http://www.w3.org/1998/Math/MathML');
cleanMath.setAttribute('display', isBlock ? 'block' : 'inline');
cleanMath.setAttribute('data-latex', latex);
if (!hasPresentationalMathContent(cleanMath)) {
cleanMath.textContent = latex;
}
fragment.appendChild(cleanMath);
});

if (fragment.childNodes.length > 0) {
table.replaceWith(fragment);
table.replaceWith(math);
processedCount++;
} else {
// A narrow marker survives attribute stripping and layout cleanup.
table.setAttribute('class', 'ltx_eqn_table');
}
});

Expand Down Expand Up @@ -1472,6 +1442,7 @@ function standardizeElements(element: Element, doc: Document, subProfile?: Recor
const tables = Array.from(element.querySelectorAll('table'));
tables.forEach(table => {
if (!table.parentNode) return;
if (table.classList.contains('ltx_eqn_table')) return;

const directCells = Array.from(table.querySelectorAll('td, th'))
.filter(cell => isDirectTableChild(cell, table));
Expand Down
2 changes: 1 addition & 1 deletion tests/expected/math--equation-table-mathml.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<article> <p>This article describes three mathematical expressions. Each equation includes its structured representation or its original source.</p> <math data-latex="x" display="block" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>x</mi> <annotation>x</annotation></semantics></math> <math data-latex="y" display="block" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>y</mi> <annotation>y</annotation></semantics></math> <math data-latex="z" display="block" xmlns="http://www.w3.org/1998/Math/MathML">z</math> <p>The expressions appear in order, with each equation occurring once. Structured MathML should remain available, while annotation-only equations retain a visible fallback.</p> </article>
<article> <p>This article describes three mathematical expressions. Each equation includes its structured representation or its original source.</p> <table class="ltx_eqn_table"><tbody> <tr><td><math data-latex="x" display="block" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>x</mi> <annotation>x</annotation></semantics></math></td></tr> <tr><td><math data-latex="y" display="block" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>y</mi> <annotation>y</annotation></semantics></math></td></tr> <tr><td><math data-latex="z" display="block" xmlns="http://www.w3.org/1998/Math/MathML">z</math></td></tr> </tbody></table> <p>The expressions appear in order, with each equation occurring once. Structured MathML should remain available, while annotation-only equations retain a visible fallback.</p> </article>
6 changes: 3 additions & 3 deletions tests/expected/math--equation-table-mathml.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ This article describes three mathematical expressions. Each equation includes it
$$
x
$$

$$
y
$$

$$
z
$$

The expressions appear in order, with each equation occurring once. Structured MathML should remain available, while annotation-only equations retain a visible fallback.
The expressions appear in order, with each equation occurring once. Structured MathML should remain available, while annotation-only equations retain a visible fallback.
1 change: 1 addition & 0 deletions tests/expected/math--equation-table-ownership.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<article> <p>Equations may use tables to arrange a whole expression, individual fragments, operators and labels. Extraction must not confuse a second representation with an independent equation.</p> <h2>Single equation</h2> <math data-latex="a" display="block" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>a</mi> <annotation>a</annotation></semantics></math> <h2>Label</h2> <table class="ltx_eqn_table"><tbody><tr><td><math data-latex="b" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>b</mi> <annotation>b</annotation></semantics></math></td> <td>(7)</td></tr></tbody></table> <h2>Rows and operators</h2> <table class="ltx_eqn_table"><tbody><tr><td><math data-latex="x" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>x</mi> <annotation>x</annotation></semantics></math></td> <td>=</td> <td><math data-latex="y" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>y</mi> <annotation>y</annotation></semantics></math></td> <td>(8a)</td></tr> <tr><td><math data-latex="z" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>z</mi> <annotation>z</annotation></semantics></math></td> <td>=</td> <td><math data-latex="w" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>w</mi> <annotation>w</annotation></semantics></math></td> <td>(8b)</td></tr></tbody></table> <h2>Native source without annotation</h2> <table class="ltx_eqn_table"><tbody><tr><td><math data-latex="c" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mi>c</mi> <annotation>c</annotation></semantics></math></td></tr> <tr><td><math data-latex="d" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><mi>d</mi></math></td></tr></tbody></table> <h2>Header and mixed content</h2> <table class="ltx_eqn_table"><tbody><tr><th>Expression</th> <th>Meaning</th></tr> <tr><td><math data-latex="g" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><mi>g</mi></math></td> <td>Measured value</td></tr></tbody></table> <h2>Nested table</h2> <table class="ltx_eqn_table"><tbody><tr><td><math data-latex="h" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><mi>h</mi></math></td> <td><table class="ltx_eqn_table"><tbody><tr><td><math data-latex="i" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><mi>i</mi></math></td></tr></tbody></table></td> <td>(9)</td></tr></tbody></table> <h2>Ordinary data table</h2> <table><tbody><tr><th>Item</th> <th>Value</th></tr> <tr><td>Sample</td> <td>10</td></tr></tbody></table> <h2>Sanitized label</h2> <table class="ltx_eqn_table"><tbody><tr><td><math data-latex="j" display="inline" xmlns="http://www.w3.org/1998/Math/MathML"><mi>j</mi></math></td> <td><span title="label&quot; onmouseover=&quot;alert(1)">(10)</span></td></tr></tbody></table> <p>The single standalone equation retains the existing promotion policy. Tables containing fragments retain their cell boundaries rather than implying that every fragment is a complete display equation.</p> </article>
80 changes: 80 additions & 0 deletions tests/expected/math--equation-table-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
```json
{
"title": "Equation table ownership",
"author": "",
"site": "example.com",
"published": ""
}
```

Equations may use tables to arrange a whole expression, individual fragments, operators and labels. Extraction must not confuse a second representation with an independent equation.

## Single equation

$$
a
$$

## Label

$$
b
$$

## Rows and operators

$$
x
$$

$$
y
$$

$$
z
$$

$$
w
$$

## Native source without annotation

$$
c
$$

$$
d
$$

## Header and mixed content

$$
g
$$

## Nested table

$$
h
$$

$$
i
$$

## Ordinary data table

| Item | Value |
| --- | --- |
| Sample | 10 |

## Sanitized label

$$
j
$$

The single standalone equation retains the existing promotion policy. Tables containing fragments retain their cell boundaries rather than implying that every fragment is a complete display equation.
4 changes: 2 additions & 2 deletions tests/fixtures/math--equation-table-mathml.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<article>
<h1>Equation representations</h1>
<p>This article describes three mathematical expressions. Each equation includes its structured representation or its original source.</p>
<table class="ltx_equationgroup">
<table class="ltx_equationgroup"><tbody>
<tr><td><table class="ltx_equation"><tr><td>
<math display="block" alttext="x"><semantics><mi>x</mi><annotation encoding="application/x-tex">x</annotation></semantics></math>
</td></tr></table></td></tr>
Expand All @@ -16,7 +16,7 @@ <h1>Equation representations</h1>
<tr><td><table class="ltx_equation"><tr><td>
<math display="block"><semantics><annotation encoding="application/x-tex">z</annotation></semantics></math>
</td></tr></table></td></tr>
</table>
</tbody></table>
<p>The expressions appear in order, with each equation occurring once. Structured MathML should remain available, while annotation-only equations retain a visible fallback.</p>
</article>
</body>
Expand Down
23 changes: 23 additions & 0 deletions tests/fixtures/math--equation-table-ownership.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!-- {"url":"https://example.com/equation-tables"} -->
<!doctype html>
<html><head><title>Equation table ownership</title></head><body><article>
<h1>Equation table ownership</h1>
<p>Equations may use tables to arrange a whole expression, individual fragments, operators and labels. Extraction must not confuse a second representation with an independent equation.</p>
<h2>Single equation</h2>
<table class="ltx_equation"><tbody><tr><td><math display="inline"><semantics><mi>a</mi><annotation encoding="application/x-tex">a</annotation></semantics></math></td></tr></tbody></table>
<h2>Label</h2>
<table class="ltx_equation"><tbody><tr><td><math display="inline"><semantics><mi>b</mi><annotation encoding="application/x-tex">b</annotation></semantics></math></td><td>(7)</td></tr></tbody></table>
<h2>Rows and operators</h2>
<table class="ltx_equationgroup"><tbody><tr><td><math display="inline"><semantics><mi>x</mi><annotation encoding="application/x-tex">x</annotation></semantics></math></td><td>=</td><td><math display="inline"><semantics><mi>y</mi><annotation encoding="application/x-tex">y</annotation></semantics></math></td><td>(8a)</td></tr><tr><td><math display="inline"><semantics><mi>z</mi><annotation encoding="application/x-tex">z</annotation></semantics></math></td><td>=</td><td><math display="inline"><semantics><mi>w</mi><annotation encoding="application/x-tex">w</annotation></semantics></math></td><td>(8b)</td></tr></tbody></table>
<h2>Native source without annotation</h2>
<table class="ltx_equation"><tbody><tr><td><math display="inline"><semantics><mi>c</mi><annotation encoding="application/x-tex">c</annotation></semantics></math></td></tr><tr><td><math display="inline"><mi>d</mi></math></td></tr></tbody></table>
<h2>Header and mixed content</h2>
<table class="ltx_eqn_table"><tbody><tr><th>Expression</th><th>Meaning</th></tr><tr><td><math display="inline"><mi>g</mi></math></td><td>Measured value</td></tr></tbody></table>
<h2>Nested table</h2>
<table class="ltx_equation"><tbody><tr><td><math display="inline"><mi>h</mi></math></td><td><table class="ltx_equation"><tbody><tr><td><math display="inline"><mi>i</mi></math></td></tr></tbody></table></td><td>(9)</td></tr></tbody></table>
<h2>Ordinary data table</h2>
<table><tbody><tr><th>Item</th><th>Value</th></tr><tr><td>Sample</td><td>10</td></tr></tbody></table>
<h2>Sanitized label</h2>
<table class="ltx_equation" onclick="alert(1)"><tbody><tr><td><math display="inline" data-latex="j"><mi>j</mi></math></td><td><a href="javascript:alert(1)" onclick="alert(1)"><span title='label" onmouseover="alert(1)'>(10)</span></a><script>alert(1)</script></td></tr></tbody></table>
<p>The single standalone equation retains the existing promotion policy. Tables containing fragments retain their cell boundaries rather than implying that every fragment is a complete display equation.</p>
</article></body></html>
Loading