From d6f622a5b56eede937cc66e40780b8a24192fb12 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Fri, 10 Apr 2026 12:01:18 +1200 Subject: [PATCH 01/46] Use mathml-polyfills to resolve legacy mathml codes --- src/components/organisms/ExposureDetail.vue | 9 ++++- src/utils/mathTransformer.ts | 38 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/utils/mathTransformer.ts diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index ce3cb93c..9791588b 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -27,6 +27,7 @@ import { formatFileCount } from '@/utils/format' import { formatLicenseUrl } from '@/utils/license' import { isValidTerm } from '@/utils/search' import TermButton from '../atoms/TermButton.vue' +import { initMathPolyfills, transformMathString } from '@/utils/mathTransformer' const props = defineProps<{ alias: string @@ -247,6 +248,8 @@ const toggleCodeWrap = () => { const generateMath = async () => { error.value = null + initMathPolyfills() + try { const response = await exposureStore.getExposureRawContent( exposureId.value, @@ -254,7 +257,11 @@ const generateMath = async () => { 'cellml_math', 'math.json', ) - mathsJSON.value = JSON.parse(response) + const mathResponseJSON = JSON.parse(response) + mathsJSON.value = mathResponseJSON.map((entry: [string, string[]]) => { + const mathMLArray = entry[1].map((mathML) => transformMathString(mathML)) + return [entry[0], mathMLArray] + }) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to parse mathematics data.' error.value = { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts new file mode 100644 index 00000000..5f49c341 --- /dev/null +++ b/src/utils/mathTransformer.ts @@ -0,0 +1,38 @@ +// @ts-ignore - The polyfill lib might not have @types +import { _MathTransforms } from 'https://w3c.github.io/mathml-polyfills/all-polyfills.js' + +let isMathPolyfillsInitialized = false +const MATH_POLYFILLS_STYLE_ATTR = 'data-math-polyfills' + +/** + * Injects the necessary CSS for polyfills into the document head. + */ +export function initMathPolyfills() { + if (typeof document === 'undefined' || isMathPolyfillsInitialized) { + return + } + + const existingStyle = document.head.querySelector(`[${MATH_POLYFILLS_STYLE_ATTR}="true"]`) + if (existingStyle) { + isMathPolyfillsInitialized = true + return + } + + const style = _MathTransforms.getCSSStyleSheet() + style.setAttribute(MATH_POLYFILLS_STYLE_ATTR, 'true') + document.head.appendChild(style) + isMathPolyfillsInitialized = true +} + +/** + * Transforms a raw MathML string from an API into modern MathML Core. + * @param rawMathML The string containing legacy tags. + */ +export function transformMathString(rawMathML: string): string { + const container = document.createElement('div') + container.innerHTML = rawMathML + + _MathTransforms.transform(container) + + return container.innerHTML +} From f14ef2df43a67ab32949d917b3e46083cada30cc Mon Sep 17 00:00:00 2001 From: akhuoa Date: Fri, 10 Apr 2026 13:15:20 +1200 Subject: [PATCH 02/46] Format MathML into table style --- src/components/organisms/ExposureDetail.vue | 29 ++++++++++++---- src/utils/mathTransformer.ts | 38 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 9791588b..e21cf9c3 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -27,7 +27,7 @@ import { formatFileCount } from '@/utils/format' import { formatLicenseUrl } from '@/utils/license' import { isValidTerm } from '@/utils/search' import TermButton from '../atoms/TermButton.vue' -import { initMathPolyfills, transformMathString } from '@/utils/mathTransformer' +import { initMathPolyfills, transformMathString, formatMathMLTable } from '@/utils/mathTransformer' const props = defineProps<{ alias: string @@ -259,7 +259,7 @@ const generateMath = async () => { ) const mathResponseJSON = JSON.parse(response) mathsJSON.value = mathResponseJSON.map((entry: [string, string[]]) => { - const mathMLArray = entry[1].map((mathML) => transformMathString(mathML)) + const mathMLArray = entry[1].map((mathML) => formatMathMLTable(transformMathString(mathML))) return [entry[0], mathMLArray] }) } catch (err) { @@ -537,13 +537,13 @@ onMounted(async () => { -
+

{{ value[0] }}

-
+
@@ -909,8 +909,25 @@ onMounted(async () => { } .math-view { - & :deep(math) { - @apply flex flex-col gap-4; + & :deep(math > mtable) { + border-spacing: 0 0.75em; + } + + & :deep(math > mtable > mtr > mtd:nth-child(1)) { + display: flex; + justify-content: flex-end; + padding-right: 0.5em; + } + + & :deep(math > mtable > mtr > mtd:nth-child(2)) { + text-align: center; + padding-left: 0.5em; + padding-right: 0.5em; + } + + & :deep(math > mtable > mtr > mtd:nth-child(3)) { + text-align: left; + padding-left: 0.5em; } } diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 5f49c341..c87e0d3c 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -36,3 +36,41 @@ export function transformMathString(rawMathML: string): string { return container.innerHTML } + +/** + * Formats a MathML string into a table structure for better rendering. + * @param rawMathML The string containing updated tags. + * @returns The formatted MathML string. + */ +export const formatMathMLTable = (rawMathML: string): string => { + const parser = new DOMParser() + const doc = parser.parseFromString(rawMathML, 'text/html') + const mathBlocks = doc.querySelectorAll('math') + + mathBlocks.forEach((math) => { + const rows = Array.from(math.children).filter((child) => child.tagName === 'mrow') + + if (rows.length > 1) { + const mtable = doc.createElement('mtable') + mtable.setAttribute('columnalign', 'right center left') + mtable.setAttribute('rowspacing', '0.75em') + + rows.forEach((row) => { + const mtr = doc.createElement('mtr') + + Array.from(row.childNodes).forEach((node) => { + const mtd = doc.createElement('mtd') + mtd.appendChild(node) + mtr.appendChild(mtd) + }) + + mtable.appendChild(mtr) + row.remove() + }) + + math.appendChild(mtable) + } + }) + + return doc.body.innerHTML +} From bf8abcb7114fc575f53ab4fd0585b00c41922969 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Fri, 10 Apr 2026 13:25:29 +1200 Subject: [PATCH 03/46] Fix mathml polyfills mismatch closing fence --- src/utils/mathTransformer.ts | 43 +++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index c87e0d3c..e57b20f8 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -3,6 +3,43 @@ import { _MathTransforms } from 'https://w3c.github.io/mathml-polyfills/all-poly let isMathPolyfillsInitialized = false const MATH_POLYFILLS_STYLE_ATTR = 'data-math-polyfills' +const OPEN_TO_CLOSE_FENCE: Record = { + '(': ')', + '[': ']', + '{': '}', + '<': '>', + '⟨': '⟩', + '⌊': '⌋', + '⌈': '⌉', +} + +const isFenceOperator = (element: Element): element is HTMLElement => + element.tagName.toLowerCase() === 'mo' && element.getAttribute('fence') === 'true' + +const getFenceText = (element: Element): string => (element.textContent || '').trim() + +const fixMismatchedFencePairs = (root: ParentNode) => { + const rows = root.querySelectorAll('mrow') + + rows.forEach((row) => { + const children = Array.from(row.children) + if (children.length < 2) return + + const firstFence = children.find(isFenceOperator) + const lastFence = [...children].reverse().find(isFenceOperator) + + if (!firstFence || !lastFence || firstFence === lastFence) return + + const openingFence = getFenceText(firstFence) + const closingFence = getFenceText(lastFence) + const expectedClosingFence = OPEN_TO_CLOSE_FENCE[openingFence] + + if (expectedClosingFence && closingFence && closingFence !== expectedClosingFence) { + // Remove only the mismatched trailing fence injected by polyfills. + lastFence.remove() + } + }) +} /** * Injects the necessary CSS for polyfills into the document head. @@ -48,7 +85,11 @@ export const formatMathMLTable = (rawMathML: string): string => { const mathBlocks = doc.querySelectorAll('math') mathBlocks.forEach((math) => { - const rows = Array.from(math.children).filter((child) => child.tagName === 'mrow') + fixMismatchedFencePairs(math) + + const rows = Array.from(math.children).filter( + (child) => child.tagName.toLowerCase() === 'mrow', + ) if (rows.length > 1) { const mtable = doc.createElement('mtable') From 9b5e20a985eb191763e622651bcf2f59e02367fe Mon Sep 17 00:00:00 2001 From: akhuoa Date: Fri, 10 Apr 2026 13:28:19 +1200 Subject: [PATCH 04/46] Fix math view overflow --- src/components/organisms/ExposureDetail.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index e21cf9c3..4a884de6 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -543,7 +543,7 @@ onMounted(async () => { >

{{ value[0] }}

-
+
From 9625f7ac0839bd90c90f34fdbeba2b309008cccc Mon Sep 17 00:00:00 2001 From: akhuoa Date: Fri, 10 Apr 2026 13:59:07 +1200 Subject: [PATCH 05/46] Fix mathml-polyfills loading --- src/components/organisms/ExposureDetail.vue | 2 +- src/utils/mathTransformer.ts | 62 ++++++++++++++++----- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 4a884de6..c60786f5 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -248,7 +248,7 @@ const toggleCodeWrap = () => { const generateMath = async () => { error.value = null - initMathPolyfills() + await initMathPolyfills() try { const response = await exposureStore.getExposureRawContent( diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index e57b20f8..6f11caa1 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -1,7 +1,15 @@ -// @ts-ignore - The polyfill lib might not have @types -import { _MathTransforms } from 'https://w3c.github.io/mathml-polyfills/all-polyfills.js' +const MATH_POLYFILLS_MODULE_URL = 'https://w3c.github.io/mathml-polyfills/all-polyfills.js' + +type MathTransformsModule = { + _MathTransforms?: { + getCSSStyleSheet: () => HTMLStyleElement + transform: (container: HTMLElement) => void + } +} let isMathPolyfillsInitialized = false +let isMathPolyfillsInitializing = false +let loadedMathTransforms: MathTransformsModule['_MathTransforms'] | null = null const MATH_POLYFILLS_STYLE_ATTR = 'data-math-polyfills' const OPEN_TO_CLOSE_FENCE: Record = { '(': ')', @@ -41,24 +49,52 @@ const fixMismatchedFencePairs = (root: ParentNode) => { }) } +const loadMathTransforms = async () => { + if (loadedMathTransforms) return loadedMathTransforms + if (typeof window === 'undefined') return null + + try { + // Avoid static URL imports so Node-based test runners don't fail at module parse time. + const dynamicImport = new Function('path', 'return import(path)') as ( + path: string, + ) => Promise + const module = await dynamicImport(MATH_POLYFILLS_MODULE_URL) + loadedMathTransforms = module._MathTransforms || null + } catch (err) { + console.warn('Unable to load MathML polyfills module:', err) + loadedMathTransforms = null + } + + return loadedMathTransforms +} + /** * Injects the necessary CSS for polyfills into the document head. */ -export function initMathPolyfills() { - if (typeof document === 'undefined' || isMathPolyfillsInitialized) { +export async function initMathPolyfills() { + if (typeof document === 'undefined' || isMathPolyfillsInitialized || isMathPolyfillsInitializing) { return } - const existingStyle = document.head.querySelector(`[${MATH_POLYFILLS_STYLE_ATTR}="true"]`) - if (existingStyle) { + isMathPolyfillsInitializing = true + + try { + const existingStyle = document.head.querySelector(`[${MATH_POLYFILLS_STYLE_ATTR}="true"]`) + if (existingStyle) { + isMathPolyfillsInitialized = true + return + } + + const mathTransforms = await loadMathTransforms() + if (!mathTransforms) return + + const style = mathTransforms.getCSSStyleSheet() + style.setAttribute(MATH_POLYFILLS_STYLE_ATTR, 'true') + document.head.appendChild(style) isMathPolyfillsInitialized = true - return + } finally { + isMathPolyfillsInitializing = false } - - const style = _MathTransforms.getCSSStyleSheet() - style.setAttribute(MATH_POLYFILLS_STYLE_ATTR, 'true') - document.head.appendChild(style) - isMathPolyfillsInitialized = true } /** @@ -69,7 +105,7 @@ export function transformMathString(rawMathML: string): string { const container = document.createElement('div') container.innerHTML = rawMathML - _MathTransforms.transform(container) + loadedMathTransforms?.transform(container) return container.innerHTML } From 4cd5714d3a2dd55799c74b2e65e3d0fc18f94413 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Fri, 10 Apr 2026 15:26:40 +1200 Subject: [PATCH 06/46] Update math elements spacing --- src/components/organisms/ExposureDetail.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index c60786f5..228babe0 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -913,6 +913,13 @@ onMounted(async () => { border-spacing: 0 0.75em; } + & :deep(math mi), + & :deep(math mo), + & :deep(math mn) { + padding-left: 0.05em; + padding-right: 0.05em; + } + & :deep(math > mtable > mtr > mtd:nth-child(1)) { display: flex; justify-content: flex-end; From b59bc1463ab6491fac6c37dd16e940db13eff72c Mon Sep 17 00:00:00 2001 From: akhuoa Date: Fri, 10 Apr 2026 16:21:03 +1200 Subject: [PATCH 07/46] Update MathML styles --- src/components/organisms/ExposureDetail.vue | 17 ++++++++++++----- src/utils/mathTransformer.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 228babe0..0395dae2 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -543,7 +543,7 @@ onMounted(async () => { >

{{ value[0] }}

-
+
@@ -909,27 +909,34 @@ onMounted(async () => { } .math-view { + @apply p-2 text-center text-sm overflow-auto; + & :deep(math > mtable) { - border-spacing: 0 0.75em; + border-spacing: 0.5em 0.75em; } & :deep(math mi), & :deep(math mo), & :deep(math mn) { + line-height: 1.4; padding-left: 0.05em; padding-right: 0.05em; } + & :deep(math > mtable > mtr + mtr > mtd) { + padding-top: 0.5em; + } + & :deep(math > mtable > mtr > mtd:nth-child(1)) { display: flex; justify-content: flex-end; padding-right: 0.5em; } - & :deep(math > mtable > mtr > mtd:nth-child(2)) { + & :deep(math > mtable > mtr > mtd[data-math-operator='equals']) { text-align: center; - padding-left: 0.5em; - padding-right: 0.5em; + padding-left: 0.25em; + padding-right: 0.25em; } & :deep(math > mtable > mtr > mtd:nth-child(3)) { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 6f11caa1..b1f032b6 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -127,7 +127,7 @@ export const formatMathMLTable = (rawMathML: string): string => { (child) => child.tagName.toLowerCase() === 'mrow', ) - if (rows.length > 1) { + if (rows.length) { const mtable = doc.createElement('mtable') mtable.setAttribute('columnalign', 'right center left') mtable.setAttribute('rowspacing', '0.75em') @@ -137,6 +137,15 @@ export const formatMathMLTable = (rawMathML: string): string => { Array.from(row.childNodes).forEach((node) => { const mtd = doc.createElement('mtd') + + if ( + node.nodeType === Node.ELEMENT_NODE + && (node as Element).tagName.toLowerCase() === 'mo' + && ((node.textContent || '').trim() === '=' || (node as Element).getAttribute('form') === 'infix') + ) { + mtd.setAttribute('data-math-operator', 'equals') + } + mtd.appendChild(node) mtr.appendChild(mtd) }) From 055e701a43899c9434342a65022973776a3502a4 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Sat, 11 Apr 2026 14:53:09 +1200 Subject: [PATCH 08/46] Update maths response json transform --- src/components/organisms/ExposureDetail.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 0395dae2..821c9311 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -258,10 +258,11 @@ const generateMath = async () => { 'math.json', ) const mathResponseJSON = JSON.parse(response) - mathsJSON.value = mathResponseJSON.map((entry: [string, string[]]) => { + const transformedMathsJSON = mathResponseJSON.map((entry: [string, string[]]) => { const mathMLArray = entry[1].map((mathML) => formatMathMLTable(transformMathString(mathML))) return [entry[0], mathMLArray] }) + mathsJSON.value = transformedMathsJSON } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to parse mathematics data.' error.value = { From 4590601df046dc2cda86b2cf6e814935a608e194 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Sat, 11 Apr 2026 18:09:22 +1200 Subject: [PATCH 09/46] Add empty content fallback --- src/components/organisms/ExposureDetail.vue | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 821c9311..d7fc6366 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -538,15 +538,18 @@ onMounted(async () => { -
-
-

{{ value[0] }}

-
-
+
+

No mathematics content available.

+
From 033801745dc0b985ef7b9909cc069651c0c36a7c Mon Sep 17 00:00:00 2001 From: akhuoa Date: Sat, 11 Apr 2026 18:09:46 +1200 Subject: [PATCH 10/46] Format --- src/utils/mathTransformer.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index b1f032b6..eeeec1e1 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -72,7 +72,11 @@ const loadMathTransforms = async () => { * Injects the necessary CSS for polyfills into the document head. */ export async function initMathPolyfills() { - if (typeof document === 'undefined' || isMathPolyfillsInitialized || isMathPolyfillsInitializing) { + if ( + typeof document === 'undefined' || + isMathPolyfillsInitialized || + isMathPolyfillsInitializing + ) { return } @@ -123,9 +127,7 @@ export const formatMathMLTable = (rawMathML: string): string => { mathBlocks.forEach((math) => { fixMismatchedFencePairs(math) - const rows = Array.from(math.children).filter( - (child) => child.tagName.toLowerCase() === 'mrow', - ) + const rows = Array.from(math.children).filter((child) => child.tagName.toLowerCase() === 'mrow') if (rows.length) { const mtable = doc.createElement('mtable') @@ -139,9 +141,10 @@ export const formatMathMLTable = (rawMathML: string): string => { const mtd = doc.createElement('mtd') if ( - node.nodeType === Node.ELEMENT_NODE - && (node as Element).tagName.toLowerCase() === 'mo' - && ((node.textContent || '').trim() === '=' || (node as Element).getAttribute('form') === 'infix') + node.nodeType === Node.ELEMENT_NODE && + (node as Element).tagName.toLowerCase() === 'mo' && + ((node.textContent || '').trim() === '=' || + (node as Element).getAttribute('form') === 'infix') ) { mtd.setAttribute('data-math-operator', 'equals') } From 088ca5cd44c5995b6581e17ae9a3f305ae4c6157 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Sat, 11 Apr 2026 18:10:02 +1200 Subject: [PATCH 11/46] Format --- src/components/organisms/ExposureDetail.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index d7fc6366..96a5d8e8 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -25,9 +25,9 @@ import { downloadFileFromContent, downloadWorkspaceFile } from '@/utils/download import { getExposureIdFromResourcePath } from '@/utils/exposure' import { formatFileCount } from '@/utils/format' import { formatLicenseUrl } from '@/utils/license' +import { formatMathMLTable, initMathPolyfills, transformMathString } from '@/utils/mathTransformer' import { isValidTerm } from '@/utils/search' import TermButton from '../atoms/TermButton.vue' -import { initMathPolyfills, transformMathString, formatMathMLTable } from '@/utils/mathTransformer' const props = defineProps<{ alias: string From fbbff808b857227e3a417c5b2ea3b064f4e14c1d Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 13 Apr 2026 12:25:54 +1200 Subject: [PATCH 12/46] Use MathML namespace to create element --- src/utils/mathTransformer.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index eeeec1e1..86ac7801 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -123,6 +123,7 @@ export const formatMathMLTable = (rawMathML: string): string => { const parser = new DOMParser() const doc = parser.parseFromString(rawMathML, 'text/html') const mathBlocks = doc.querySelectorAll('math') + const NS = 'http://www.w3.org/1998/Math/MathML' mathBlocks.forEach((math) => { fixMismatchedFencePairs(math) @@ -130,15 +131,15 @@ export const formatMathMLTable = (rawMathML: string): string => { const rows = Array.from(math.children).filter((child) => child.tagName.toLowerCase() === 'mrow') if (rows.length) { - const mtable = doc.createElement('mtable') + const mtable = doc.createElementNS(NS, 'mtable') mtable.setAttribute('columnalign', 'right center left') mtable.setAttribute('rowspacing', '0.75em') rows.forEach((row) => { - const mtr = doc.createElement('mtr') + const mtr = doc.createElementNS(NS, 'mtr') Array.from(row.childNodes).forEach((node) => { - const mtd = doc.createElement('mtd') + const mtd = doc.createElementNS(NS, 'mtd') if ( node.nodeType === Node.ELEMENT_NODE && From c9ab80bcd99b98a22a7b30f3c346ce03ce3a93f1 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 13 Apr 2026 17:23:50 +1200 Subject: [PATCH 13/46] Update mathml-polyfills dynamic import --- src/utils/mathTransformer.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 86ac7801..59b7bf81 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -55,10 +55,10 @@ const loadMathTransforms = async () => { try { // Avoid static URL imports so Node-based test runners don't fail at module parse time. - const dynamicImport = new Function('path', 'return import(path)') as ( - path: string, - ) => Promise - const module = await dynamicImport(MATH_POLYFILLS_MODULE_URL) + // Use dynamic import() instead of new Function to comply with strict CSP. + // Dynamic imports are evaluated at runtime, not parse time, so they don't + // cause issues with Node-based test runners. + const module = await import(MATH_POLYFILLS_MODULE_URL) loadedMathTransforms = module._MathTransforms || null } catch (err) { console.warn('Unable to load MathML polyfills module:', err) From a7825ff41f0bf510b208f348ca9dec6a47b223ca Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 13 Apr 2026 17:25:50 +1200 Subject: [PATCH 14/46] Update comment --- src/utils/mathTransformer.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 59b7bf81..52ac5d0a 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -54,10 +54,8 @@ const loadMathTransforms = async () => { if (typeof window === 'undefined') return null try { - // Avoid static URL imports so Node-based test runners don't fail at module parse time. - // Use dynamic import() instead of new Function to comply with strict CSP. - // Dynamic imports are evaluated at runtime, not parse time, so they don't - // cause issues with Node-based test runners. + // Dynamic import() avoids unsafe-eval CSP violations + // while remaining compatible with Node test runners. const module = await import(MATH_POLYFILLS_MODULE_URL) loadedMathTransforms = module._MathTransforms || null } catch (err) { From ad014429861bfa29e7e5d05f3e01eebc9814db87 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 13 Apr 2026 17:31:25 +1200 Subject: [PATCH 15/46] Fix mathml transform --- src/utils/mathTransformer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 52ac5d0a..b032bb0e 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -104,6 +104,8 @@ export async function initMathPolyfills() { * @param rawMathML The string containing legacy tags. */ export function transformMathString(rawMathML: string): string { + if (typeof document === 'undefined') return rawMathML + const container = document.createElement('div') container.innerHTML = rawMathML @@ -118,6 +120,8 @@ export function transformMathString(rawMathML: string): string { * @returns The formatted MathML string. */ export const formatMathMLTable = (rawMathML: string): string => { + if (typeof document === 'undefined') return rawMathML + const parser = new DOMParser() const doc = parser.parseFromString(rawMathML, 'text/html') const mathBlocks = doc.querySelectorAll('math') From bba0e3ebc8140b136685051465bf19f19ecf2ed2 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 13 Apr 2026 17:35:06 +1200 Subject: [PATCH 16/46] Fix DOMParser performance --- src/utils/mathTransformer.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index b032bb0e..46621ad8 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -7,6 +7,15 @@ type MathTransformsModule = { } } +let sharedDOMParser: DOMParser | null = null + +const getDOMParser = (): DOMParser => { + if (!sharedDOMParser) { + sharedDOMParser = new DOMParser() + } + return sharedDOMParser +} + let isMathPolyfillsInitialized = false let isMathPolyfillsInitializing = false let loadedMathTransforms: MathTransformsModule['_MathTransforms'] | null = null @@ -122,7 +131,7 @@ export function transformMathString(rawMathML: string): string { export const formatMathMLTable = (rawMathML: string): string => { if (typeof document === 'undefined') return rawMathML - const parser = new DOMParser() + const parser = getDOMParser() const doc = parser.parseFromString(rawMathML, 'text/html') const mathBlocks = doc.querySelectorAll('math') const NS = 'http://www.w3.org/1998/Math/MathML' From a435c7f80e82a3f51963415245468f33bcb13c6a Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 13 Apr 2026 17:57:24 +1200 Subject: [PATCH 17/46] Add unit test for mathmlTransform --- src/utils/mathTransformer.test.ts | 277 ++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/utils/mathTransformer.test.ts diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts new file mode 100644 index 00000000..aa6fe8f5 --- /dev/null +++ b/src/utils/mathTransformer.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi } from 'vitest' +import { + formatMathMLTable, + initMathPolyfills, + transformMathString, +} from '@/utils/mathTransformer' + +/** + * Tests for MathML transformation utilities. + * + * Key behaviours tested: + * 1. CSP-compliant dynamic import of polyfills (no unsafe-eval) + * 2. Polyfill initialisation with idempotency and style injection + * 3. MathML transformation applying polyfill transforms + * 4. Table formatting with proper mtable attributes + * 5. Mismatched fence pair removal (e.g., '(' paired with '>') + * 6. Non-DOM environment graceful degradation (returns input unchanged) + * 7. Equals operator detection and marking for styling + * 8. Structure preservation through transformations + */ + +// Mock the dynamic import. +vi.mock('https://w3c.github.io/mathml-polyfills/all-polyfills.js', () => ({ + _MathTransforms: { + getCSSStyleSheet: () => { + const style = document.createElement('style') + style.textContent = '/* Mock polyfill CSS */' + return style + }, + transform: (container: HTMLElement) => { + // Mock transform: add a marker attribute to indicate it was called. + container.setAttribute('data-transformed', 'true') + }, + }, +})) + +describe('initMathPolyfills', () => { + it('injects the polyfill CSS into the document head', async () => { + // Remove any previously injected styles (from other tests). + const existings = document.head.querySelectorAll('[data-math-polyfills="true"]') + existings.forEach((el) => el.remove()) + + await initMathPolyfills() + + const style = document.head.querySelector('[data-math-polyfills="true"]') + expect(style).toBeTruthy() + expect(style?.tagName).toBe('STYLE') + }) + + it('does not throw when the document is unavailable', async () => { + const originalDocument = globalThis.document + // @ts-ignore + delete globalThis.document + + await expect(initMathPolyfills()).resolves.toBeUndefined() + + globalThis.document = originalDocument + }) + + it('does not throw when the polyfill module fails to load', async () => { + // Mock a failed import to test graceful error handling. + vi.doMock('https://w3c.github.io/mathml-polyfills/all-polyfills.js', () => { + throw new Error('Module load failed') + }) + + await expect(initMathPolyfills()).resolves.toBeUndefined() + }) +}) + +describe('transformMathString', () => { + const simpleEquation = ` + + x + = + 5 + + ` + + it('transforms a MathML string and applies the polyfill transform', () => { + const result = transformMathString(simpleEquation) + + expect(result).toBeTruthy() + expect(result).toContain('x') + expect(result).toContain('=') + expect(result).toContain('5') + }) + + it('preserves the MathML structure after transformation', () => { + const result = transformMathString(simpleEquation) + + expect(result).toContain('math') + expect(result).toContain('mrow') + expect(result).toContain('mi') + }) + + it('returns the input unchanged when the document is unavailable', () => { + const originalDocument = globalThis.document + // @ts-ignore + delete globalThis.document + + const result = transformMathString(simpleEquation) + expect(result).toBe(simpleEquation) + + globalThis.document = originalDocument + }) + + it('handles an empty MathML string', () => { + const result = transformMathString('') + expect(result).toBe('') + }) +}) + +describe('formatMathMLTable', () => { + const multiRowEquation = ` + + vcell + = + 1000 + + + Ageo + = + 2 + + ` + + const equationWithFence = ` + + + + a + = + 1 + + + + ` + + // Test case for mismatched fence pairs (e.g., opening '(' but closing '>'). + const mismatchedFenceEquation = ` + + ( + x + > + + + result + = + 0 + + ` + + it('wraps multiple rows in an mtable', () => { + const result = formatMathMLTable(multiRowEquation) + + expect(result).toContain('mtable') + expect(result).toContain('mtr') + expect(result).toContain('mtd') + }) + + it('sets the correct attributes on the mtable', () => { + const result = formatMathMLTable(multiRowEquation) + + expect(result).toContain('columnalign="right center left"') + expect(result).toContain('rowspacing="0.75em"') + }) + + it('marks equals operators with the data-math-operator attribute', () => { + const result = formatMathMLTable(multiRowEquation) + + expect(result).toContain('data-math-operator="equals"') + }) + + it('preserves the MathML structure in table cells', () => { + const result = formatMathMLTable(multiRowEquation) + + expect(result).toContain('vcell') + expect(result).toContain('Ageo') + expect(result).toContain('1000') + }) + + it('handles MathML containing fenced expressions', () => { + const result = formatMathMLTable(equationWithFence) + + expect(result).toContain('mtable') + expect(result).toContain('mfenced') + }) + + it('returns the input unchanged when the document is unavailable', () => { + const originalDocument = globalThis.document + // @ts-ignore + delete globalThis.document + + const result = formatMathMLTable(multiRowEquation) + expect(result).toBe(multiRowEquation) + + globalThis.document = originalDocument + }) + + it('handles an empty MathML string', () => { + const result = formatMathMLTable('') + expect(result).toBe('') + }) + + it('does not create a table when there are no mrow elements', () => { + const noRowsEquation = ` + x + ` + + const result = formatMathMLTable(noRowsEquation) + + expect(result).not.toContain('mtable') + }) + + it('removes a mismatched closing fence from an mrow element', () => { + const result = formatMathMLTable(mismatchedFenceEquation) + + // The mismatched closing fence '>' should be removed, leaving only the opening '('. + expect(result).toContain('mtable') + expect(result).toContain('result') + expect(result).toContain('data-math-operator="equals"') + }) +}) + +describe('mathTransformer integration', () => { + it('transforms and formats a complete equation', () => { + const equation = ` + + v + = + + d + t + + + + a + = + + dv + dt + + + ` + + const transformed = transformMathString(equation) + const formatted = formatMathMLTable(transformed) + + expect(formatted).toContain('mtable') + expect(formatted).toContain('data-math-operator="equals"') + expect(formatted).toContain('mfrac') + }) + + it('handles complex nested MathML structures', () => { + const complexEquation = ` + + + + + time + period + + + + = + floor + + ` + + const result = formatMathMLTable(complexEquation) + + expect(result).toContain('mtable') + expect(result).toContain('mfrac') + expect(result).toContain('⌊') + expect(result).toContain('⌋') + }) +}) From b19fcd2108ae50cc648167d857b4dfaf995a04ee Mon Sep 17 00:00:00 2001 From: Aung Kyaw Hein <161257464+akhuoa@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:59:25 +1200 Subject: [PATCH 18/46] Fix v-for key usage Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/components/organisms/ExposureDetail.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 96a5d8e8..dda1c967 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -545,7 +545,7 @@ onMounted(async () => { class="mb-6 pb-6 last:mb-0 last:pb-0 border-b border-gray-200 dark:border-gray-700 last:border-0" >

{{ value[0] }}

-
+
From 27345286e07d1af51313fd8f637afceeeade5801 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 13 Apr 2026 18:00:54 +1200 Subject: [PATCH 19/46] Format --- src/utils/mathTransformer.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index aa6fe8f5..ed1d30f7 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - formatMathMLTable, - initMathPolyfills, - transformMathString, -} from '@/utils/mathTransformer' +import { formatMathMLTable, initMathPolyfills, transformMathString } from '@/utils/mathTransformer' /** * Tests for MathML transformation utilities. From 0639785dc9680f81a11b10c8e5fba359e23cfbcc Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 13 Apr 2026 18:04:34 +1200 Subject: [PATCH 20/46] Fix format and lint errors --- src/utils/mathTransformer.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index ed1d30f7..1a19bf34 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -33,8 +33,9 @@ vi.mock('https://w3c.github.io/mathml-polyfills/all-polyfills.js', () => ({ describe('initMathPolyfills', () => { it('injects the polyfill CSS into the document head', async () => { // Remove any previously injected styles (from other tests). - const existings = document.head.querySelectorAll('[data-math-polyfills="true"]') - existings.forEach((el) => el.remove()) + for (const el of document.head.querySelectorAll('[data-math-polyfills="true"]')) { + el.remove() + } await initMathPolyfills() @@ -45,7 +46,7 @@ describe('initMathPolyfills', () => { it('does not throw when the document is unavailable', async () => { const originalDocument = globalThis.document - // @ts-ignore + // @ts-expect-error delete globalThis.document await expect(initMathPolyfills()).resolves.toBeUndefined() @@ -91,7 +92,7 @@ describe('transformMathString', () => { it('returns the input unchanged when the document is unavailable', () => { const originalDocument = globalThis.document - // @ts-ignore + // @ts-expect-error delete globalThis.document const result = transformMathString(simpleEquation) @@ -184,7 +185,7 @@ describe('formatMathMLTable', () => { it('returns the input unchanged when the document is unavailable', () => { const originalDocument = globalThis.document - // @ts-ignore + // @ts-expect-error delete globalThis.document const result = formatMathMLTable(multiRowEquation) From c1d41d455d01ce41b01a473925a949662fa529f5 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 20 Apr 2026 12:17:44 +1200 Subject: [PATCH 21/46] Add mathml-polyfills in vendor folder --- .../all-polyfills-bundle.d.ts | 1202 +++++++++++++++++ .../mathml-polyfills/all-polyfills-bundle.js | 295 ++++ 2 files changed, 1497 insertions(+) create mode 100644 src/vendor/mathml-polyfills/all-polyfills-bundle.d.ts create mode 100644 src/vendor/mathml-polyfills/all-polyfills-bundle.js diff --git a/src/vendor/mathml-polyfills/all-polyfills-bundle.d.ts b/src/vendor/mathml-polyfills/all-polyfills-bundle.d.ts new file mode 100644 index 00000000..ed00aa19 --- /dev/null +++ b/src/vendor/mathml-polyfills/all-polyfills-bundle.d.ts @@ -0,0 +1,1202 @@ +/** +* Same as cloneNode(true) except that shadow roots are copied +* If you are using the transforms and you need to clone a node that potentially has a shadowRoot, use this so the shadowRoot is copied +* As of November, 2020, Elementary Math and Linebreaking transforms are the only transforms that have shadowRoots. +* @param {Element} el +* @param {Element} [clone] +* @returns {Element} -- the clone (only useful if function is called with one arg) +*/ +function cloneElementWithShadowRoot(el, clone) { + if (clone === void 0) clone = el.cloneNode(true); + if (el.shadowRoot) { + let shadowRoot = clone.attachShadow({ + mode: "open" + }); + shadowRoot.appendChild(_MathTransforms.getCSSStyleSheet()); + for (let i = 0; i < el.shadowRoot.childElementCount; i++) shadowRoot.appendChild(cloneElementWithShadowRoot(el.shadowRoot.children[i])); + } + for (let i = 0; i < el.childElementCount; i++) cloneElementWithShadowRoot(el.children[i], clone.children[i]); + return clone; +} +/** +* Converts a CSS length unit to pixels and returns that as a number +* @param{Element} element +* @param {string} length +* @returns {number} +*/ +function convertToPx(element, length) { + if (/px/.test(length)) return parseFloat(length); + let img = document.createElement("img"); + let leafWrapper = document.createElementNS(MATHML_NS, "mtext"); + leafWrapper.appendChild(img); + leafWrapper.style.overflow = "hidden"; + leafWrapper.style.visibility = "hidden"; + img.style.width = length; + element.appendChild(leafWrapper); + const result = leafWrapper.getBoundingClientRect().width; + leafWrapper.remove(); + return result; +} + +//#endregion +//#region mglyph/mglyph.js +/*** +* Convert mglyph into img element. +* This conversion should be valid everwhere mglyph is legal. +***/ +/** +* @param {HTMLElement} el +*/ + +function collapseWhiteSpace$1(text) { + return text.replace(/^[\s]+|[\s]+$/g, "").replace(/[\s]+/g, " "); +} +function newOperator(text, separator) { + let operator = document.createElementNS(namespaceURI$1, "mo"); + operator.appendChild(document.createTextNode(text)); + operator.setAttribute(separator ? "separator" : "fence", "true"); + return operator; +} +function newMrow() { + return document.createElementNS(namespaceURI$1, "mrow"); +} +function getSeparatorList(text) { + if (text === null) return [","]; + let separatorList = []; + for (let i = 0; i < text.length; i++) if (!/\s/g.test(text.charAt(i))) { + let c = text.charCodeAt(i); + if (c >= 55296 && c < 56320 && i + 1 < text.length) { + separatorList.push(text.substr(i, 2)); + i++; + } else separatorList.push(text.charAt(i)); + } + return separatorList; +} +function shouldCopyAttribute(attribute) { + return attribute.namespaceURI || !["dir", "open", "close", "separators"].includes(attribute.localName); +} +/** +* +* @param {HTMLElement} el +* @returns {boolean} +*/ +function isSpaceLike(el) { + if (el.tagName === "mtext" || el.tagName === "mspace" || el.tagName === "maligngroup" || el.tagName === "malignmark") return true; + if (MRowLikeElements.includes(el.tagName)) { + for (let i = 0; i < el.children.length; i++) if (!isSpaceLike(el.children[i])) return false; + if (el.tagName === "maction" && el.hasAttribute("selection") && isSpaceLike(el.children[el.elAttribute("selection")])) return true; + return true; + } + return false; +} +/** +* +* @param {HTMLElement} el +* @returns {[HTMLElement|null]} +*/ +function getEmbellishedOperator$1(el) { + if (el.tagName === "mo") return el; + if (EmbellishedOpsElements.includes(el.tagName)) return getEmbellishedOperator$1(el.firstChild); + if (MRowLikeElements.includes(el.tagName)) { + for (let i = 0; i < el.children.length; i++) if (!isSpaceLike(el.children[i])) return getEmbellishedOperator$1(el.children[i]); + return null; + } + return null; +} +/** +* +* @param {HTMLElement} child +* @param {string} attrName +*/ +function setAccentValue(child, attrName) { + const op = getEmbellishedOperator$1(child); + if (op === null) return; + let accentVal = op.getAttribute("accent"); + if (accentVal === null && child.tagName === "mo") accentVal = "true"; + if (accentVal !== null) child.parentElement.setAttribute(attrName, accentVal); +} +/** +* +* @param {HTMLElement} el +*/ + +//#endregion +//#region horiz-align/horiz-align.js +/*** +* Handles the "numalign" and "denomalign" attributes on mfrac +* Handles the "align" attribute on munder, mover, and munderover +***/ +/** +* @param {HTMLElement} child +* @returns {number} +*/ +function getChildWidth(child) { + return child.getBoundingClientRect().width; +} +/** +* Handles left/right alignment by creating an mspace of the appropriate width +* on either the left or right side. +* For something like a fraction numerator, 'child' is the numerator and 'maxWidth' +* is the denominator's width +* @param {HTMLElement} child +* @param {number} childWidth +* @param {string} align +* @param {number} maxWidth +* @returns {HTMLElement} +*/ +function doAlignment(child, childWidth, align, maxWidth) { + if (childWidth >= maxWidth || align === "center") return child; + if (child.tagName !== "mrow") { + const sibling = child.nextElementSibling; + const mrow = document.createElementNS(MATHML_NS, "mrow"); + const parent = child.parentElement; + mrow.appendChild(child); + parent.insertBefore(mrow, sibling); + child = mrow; + } + let mspace = document.createElementNS(MATHML_NS, "mspace"); + mspace.setAttribute("width", `${(maxWidth - childWidth).toPrecision(2)}px`); + if (align === "left") child.appendChild(mspace);else if (align === "right") child.insertBefore(mspace, child.firstElementChild); + return child; +} +/** +* @param {HTMLElement} el +* @param {number} iChild +* @param {number} iOther +* @param {string} attr +* @returns {HTMLElement} +*/ +function alignChild(el, iChild, iOther, attr) { + doAlignment(el.children[iChild], getChildWidth(el.children[iChild]), el.getAttribute(attr), getChildWidth(el.children[iOther])); + return el; +} +/** +* @param {HTMLElement} mfrac +*/ + +function getWidthOf(mathmlStr) { + const math = document.createElementNS(MATHML_NS, "math"); + math.innerHTML = mathmlStr; + document.body.appendChild(math); + const width = math.getBoundingClientRect().width; + document.body.lastElementChild.remove(); + return width; +} +/** +* +* @param {string} notationAttrValue +* @returns {boolean} -- true if the transform should be used +*/ +function useMencloseTransform(notationAttrValue) { + if (getWidthOf("x") === getWidthOf("x")) return true; + if (/arrow/.test(notationAttrValue)) { + if (getWidthOf("x") === getWidthOf("x")) return true; + } + if (/phasorangle/.test(notationAttrValue)) { + if (getWidthOf("x") === getWidthOf("x")) return true; + } + return false; +} +/** +* +* @param {string[]} notationArray +* @returns {string[]} notationArray +*/ +function removeRedundantNotations(notationArray) { + notationArray = Array.from(new Set(notationArray)); + if (notationArray.includes("box")) notationArray = notationArray.filter(notation => !BORDER_NOTATIONS.includes(notation)); + for (const [notation, values] of Object.entries(ARROW_INFO)) { + const removeArray = values[3]; + if (removeArray !== [""] && notationArray.includes(notation)) notationArray = notationArray.filter(notation => !removeArray.includes(notation)); + } + return notationArray; +} +/** +* +* @param {HTMLElement} element +* @returns {boolean} +*/ +function isDirLTR(element) { + let lookingForMathElement = true; + do { + if (element.hasAttribute("dir")) return element.getAttribute("dir") === "ltr"; + lookingForMathElement = element.tagName !== "math"; + element = element.parentElement; + } while (lookingForMathElement); + return true; +} +/** +* +* @param {HTMLElement} el +* @param {string[]} notationArray +* @returns {number} -- amount of padding in pixels +*/ +function padAmount(el, notationArray) { + let padding = "0.467em"; + if (notationArray.includes("roundedbox") || notationArray.includes("circle")) padding = "0.601em"; + return convertToPx(el, padding); +} +/** +* +* @param {HTMLElement} el // menclose element +* @returns +*/ + +//#endregion +//#region ms/ms.js +/*** +* Handles lqoute and rquote attrs on ms +***/ +/** +* +* @param {string} text +*/ +function collapseWhiteSpace(text) { + return text.replace(/^[\s]+|[\s]+$/g, "").replace(/[\s]+/g, " "); +} +/** +* @param {HTMLElement} ms +*/ + +function getMathAlphanumeric(ch, mathStyle) { + if (!mathStyle || mathStyle == "mup") return ch; + let code = ch.charCodeAt(0); + let n; + if (ch >= "0" && ch <= "9") { + code += 120734; + n = setsDigit.indexOf(mathStyle); + return n != -1 ? String.fromCodePoint(code + n * 10) : ch; + } + if (/[A-Za-z]/.test(ch)) { + let varsel = ""; + if (mathStyle == "mchan" || mathStyle == "mrhnd") { + varsel = mathStyle == "mchan" ? "︀" : "︁"; + mathStyle = "mscr"; + } + let chT = ""; + switch (mathStyle) { + case "mit": + if (ch == "h") return "ℎ"; + break; + case "mfrak": + chT = letterlikeFraktur[ch]; + break; + case "mscr": + chT = letterlikeScript[ch]; + break; + case "Bbb": + chT = letterlikeDoubleStruck[ch]; + break; + } + if (chT) return chT + varsel; + n = setsEn.indexOf(mathStyle); + if (n == -1) return ch; + code -= 65; + if (code > 26) code -= 6; + return String.fromCodePoint(code + 52 * n + 119808) + varsel; + } + if (ch >= "Α" && ch <= "ϵ" || ch == "∂" || ch == "∇") { + if (mathStyle == "mbf") { + if (ch == "Ϝ") return "𝟊"; + if (ch == "ϝ") return "𝟋"; + } + n = setsGr.indexOf(mathStyle); + if (n == -1) return ch; + let code0 = offsetsGr[ch]; + if (code0) code = code0;else { + code -= 913; + if (code > 25) code -= 6; + } + return String.fromCodePoint(code + 58 * n + 120488); + } + if (code < 1575) return ch == "ı" ? "𝚤" : ch == "ȷ" ? "𝚥" : ch; + if (code > 1722) return ch; + n = setsAr.indexOf(mathStyle); + if (n == -1) return ch; + if (code <= 1610) { + code = abjad[code - 1575]; + if (code == -1) return ch; + } else { + code = dottedChars.indexOf(ch); + if (code == -1) return ch; + code += 28; + } + if (mathStyle == "misol") { + if (code == 4) n = 1; + } else if (1 << code & missingCharMask[n - 1]) return ch; + return String.fromCodePoint(32 * n + code + 126464); +} +//#endregion +//#region mpadded/mpadded.js +/*** +* Handles width/height/depth attributes with % values for mpadded +***/ +/** +* @param {HTMLElement} mpadded +* @returns {{width:number, height: number: depth: number}} +*/ +function getDimensions(mpadded) { + const mrow = document.createElementNS(MATHML_NS, "mrow"); + mrow.appendChild(document.createElementNS(MATHML_NS, "mspace")); + const cloneMpadded = cloneElementWithShadowRoot(mpadded); + for (let i = 0; i < cloneMpadded.children.length; i++) mrow.appendChild(cloneMpadded.children[i]); + cloneMpadded.appendChild(mrow); + mpadded.parentElement.replaceChild(cloneMpadded, mpadded); + const mspaceRect = mrow.firstElementChild.getBoundingClientRect(); + const mpaddedRect = mrow.getBoundingClientRect(); + cloneMpadded.parentElement.replaceChild(mpadded, cloneMpadded); + return { + width: mpaddedRect.width, + height: mspaceRect.y - mpaddedRect.top, + depth: mpaddedRect.bottom - mspaceRect.y + }; +} +/** +* @param {HTMLElement} el +* @param {string} attr +* @param {'width'|'height'|'depth'} dimension +* @param {{width:number, height: number: depth: number}} dimensions +* @returns {boolean} +*/ +function replacePseudoAttr(el, attr, dimension, dimensions) { + const attrValue = el.getAttribute(attr).toLowerCase(); + if (attrValue.includes(dimension)) { + const floatVal = parseFloat(attrValue) * dimensions[dimension] / (attrValue.includes("%") ? 100 : 1); + el.setAttribute(attr, floatVal.toFixed(1) + "px"); + return true; + } + return false; +} +/** +* @param {HTMLElement} el +* @param {attr} align +* @param {{width:number, height: number: depth: number}} dimensions +* @returns {boolean} // true if handled +*/ +function handleAttr(el, attr, dimensions) { + if (!el.hasAttribute(attr)) return false; + if (replacePseudoAttr(el, attr, "width", dimensions)) return true; + if (replacePseudoAttr(el, attr, "height", dimensions)) return true; + if (replacePseudoAttr(el, attr, "depth", dimensions)) return true; + return false; +} +/** +* @param {HTMLElement} el +*/ + +/** +* +* @param {HTMLElement} mtable +*/ +function makeTableSquare(mtable) { + return mtable; +} +/** +* +* @param {HTMLElement} mtable +*/ +function handleLabeledRows(mtable) { + if (mtable.getElementsByTagName("mlabeledtr").length === 0) return mtable; + const side = mtable.getAttribute("side") || "right"; + let emptyColumnEntry = document.createElementNS(namespaceURI, "mtd"); + emptyColumnEntry.setAttribute("intent", ":no-equation-label"); + for (let i = 0; i < mtable.children.length; i++) { + let row = mtable.children[i]; + if (row.tagName === "mlabeledtr") { + let label = row.firstElementChild; + addIntent(label); + let newRow = document.createElementNS(namespaceURI, "mtr"); + for (const attr of row.attributes) newRow.setAttribute(attr.name, attr.value); + let mtd = row.children[side == "left" ? 0 : 1]; + newRow.appendChild(mtd); + while (row.children.length > 0) newRow.appendChild(row.firstChild); + if (side === "right") newRow.appendChild(label); + row.replaceWith(newRow); + } else { + const newColEntry = emptyColumnEntry.cloneNode(); + if (side === "right") row.appendChild(newColEntry);else row.insertBefore(newColEntry, row.firstElementChild); + } + } + return mtable; +} +/** +* +* @param {HTMLElement} mtd +*/ +function addIntent(mtd) { + if (!mtd.hasAttribute("intent")) { + mtd.setAttribute("intent", ":equation-label"); + return; + } + let intentValue = mtd.getAttribute("intent"); + let iOpenParen = intentValue.indexOf("("); + let head = iOpenParen == -1 ? intentValue : intentValue.substring(0, iOpenParen); + if (head.includes(":equation-label")) return; + intentValue = head + ":equation-label" + intentValue.substring(head.length); + mtd.setAttribute("intent", intentValue); +} +/** +* +* @param {HTMLElement} mtable +*/ + +/** +* Look first in the shadowRoot for the 'id'; if not found, check the whole document +* @param {string} id +* @returns {Element | null} +*/ +function getElementByIdEverywhere(id) { + const found = shadowRoot.get().getElementById(id); + if (found) return found; + return document.getElementById(id); +} +/** +* Creates a new MathML element +* @param {string} tagName +* @returns {Element} +*/ +function newElement(tagName) { + return document.createElementNS(MATHML_NS, tagName); +} +/** +* Copies the attributes from 'source' to 'target' +* 'target' is unchanged. +* @param {Element} target +* @param {Element} source +* @returns {Element} // target +*/ +function copyAttributes(target, source) { + const attrs = source.attributes; + for (let i = 0; i < attrs.length; i++) target.setAttribute(attrs[i].name, attrs[i].value); + return target; +} +/** +* Looks at 'element' and its ancestors to see if the value is set on an attr; if so, it is returned. +* @param {Element} element +* @param {string} attrName +* @param {string} defaultVal +* @returns {string} +*/ +function getMathMLAttrValueAsString(element, attrName, defaultVal) { + let lookingForMathElement = true; + do { + if (element.hasAttribute(attrName)) return element.getAttribute(attrName); + lookingForMathElement = element.tagName !== "math"; + element = element.parentElement; + } while (lookingForMathElement); + return defaultVal; +} +/** +* @returns {Element} +*/ +function createLineBreakMTable() { + const mtable = newElement("mtable"); + mtable.setAttribute(MTABLE_HAS_LINEBREAKS, "true"); + mtable.setAttribute("displaystyle", "true"); + return mtable; +} +/** +* +* @param {Element} mtd +* @returns {boolean} +*/ +function isInLineBreakTable(mtd) { + return mtd.tagName === "mtd" && mtd.parentElement.tagName === "mtr" && mtd.parentElement.parentElement.tagName === "mtable" && mtd.parentElement.parentElement.hasAttribute(MTABLE_HAS_LINEBREAKS); +} +/** +* +* @param {Element} child +* @returns {Element} +*/ +function createNewTableRowWithChild(child) { + const mtr = newElement("mtr"); + const mtd = newElement("mtd"); + mtd.appendChild(child); + mtr.appendChild(mtd); + return mtr; +} +/** +* +* @param {Element} mo +* @param {'first' | 'middle' | 'last'} firstMiddleOrLast +* @returns {Object} +*/ +function computeIndentAttrObject(mo, firstMiddleOrLast) { + const attrObject = {}; + let linebreakstyle = getMathMLAttrValueAsString(mo, "linebreakstyle", "before"); + if (linebreakstyle === "infixLineBreakStyle") linebreakstyle = getMathMLAttrValueAsString(mo, "infixLineBreakStyle", "before"); + attrObject.linebreakstyle = linebreakstyle; + attrObject.indentAlign = getMathMLAttrValueAsString(mo, "indentalign", "auto"); + attrObject.indentShift = getMathMLAttrValueAsString(mo, "indentshift", "0px"); + if (firstMiddleOrLast == "first") { + attrObject.indentAlign = getMathMLAttrValueAsString(mo, "indentalignfirst", attrObject.indentAlign); + attrObject.indentShift = getMathMLAttrValueAsString(mo, "indentshiftfirst", attrObject.indentShift); + } else if (firstMiddleOrLast === "last") { + attrObject.indentAlign = getMathMLAttrValueAsString(mo, "indentalignlast", attrObject.indentAlign); + attrObject.indentShift = getMathMLAttrValueAsString(mo, "indentshiftlast", attrObject.indentShift); + } + attrObject.indentShift = convertToPx(mo, attrObject.indentShift); + attrObject.target = getMathMLAttrValueAsString(mo, "indenttarget", ""); + attrObject.firstMiddleOrLast = firstMiddleOrLast; + return attrObject; +} +/** +* Stores the attrs used for indenting on the 'mtd' so they can be found easily later +* @param {Element} mtd +* @param {Element} mo +*/ +function storeLineBreakAttrsOnMtd(mtd, mo) { + /** @type {'first' | 'middle' | 'last'} */ + let firstMiddleOrLast = "middle"; + if (mtd.parentElement === mtd.parentElement.parentElement.firstElementChild) firstMiddleOrLast = "first";else if (mtd.parentElement === mtd.parentElement.parentElement.lastElementChild) firstMiddleOrLast = "last"; + mtd.setAttribute(INDENT_ATTRS, JSON.stringify(computeIndentAttrObject(mo, firstMiddleOrLast))); +} +/** +* Either create a new (linebreak) mtable with the new table row or if it already exists, add the row +* It exists if we stopped at a and it is an mtable inserted for linebreaking purposes +* @param {Element} parent // 'mtd' if stopped in existing table, otherwise some non-mrow element +* @param {Element} upToBreak // the first part of the split line +* @param {Element} afterBreak // the remainder of the current line +* @returns {Element} // the last row added to the table (one or two rows are created) +*/ +function addNewLineBreakRow(parent, upToBreak, afterBreak) { + const mtr = createNewTableRowWithChild(upToBreak); + if (isInLineBreakTable(parent)) { + copyAttributes(mtr.firstElementChild, parent); + while (parent.attributes.length > 0) parent.removeAttributeNode(parent.attributes[0]); + parent.parentElement.parentElement.insertBefore(mtr, parent.parentElement); + return parent.parentElement; + } else { + const mtable = createLineBreakMTable(); + mtable.setAttribute("style", "width: 100%"); + mtable.appendChild(mtr); + afterBreak.replaceWith(mtable); + mtable.appendChild(createNewTableRowWithChild(afterBreak)); + return mtable.lastElementChild; + } +} +/** +* Splits the line at the 'mo' -- at beginning/end of line depending on 'linebreakstyle' +* @param {Element} mo // operator to split +* @returns {Element} // the last row added to the table +*/ +function splitLine(mo) { + let linebreakstyle = getMathMLAttrValueAsString(mo, "linebreakstyle", "before"); + if (linebreakstyle === "infixLineBreakStyle") linebreakstyle = getMathMLAttrValueAsString(mo, "infixLineBreakStyle", "before"); + let upToBreak = null; + let breakElement = mo; + if (mo.previousElementSibling !== null && mo.nextElementSibling !== null) mo.setAttribute("form", "infix"); + let parent = breakElement.parentElement; + for (; parent.tagName === "mrow"; parent = parent.parentElement) { + let newMRow = newElement("mrow"); + while (parent.firstElementChild) { + const child = parent.firstElementChild; + if (child === breakElement) { + if (linebreakstyle === "after") { + newMRow.appendChild(child); + linebreakstyle = "before"; + break; + } else if (linebreakstyle === "duplicate") { + linebreakstyle = "before"; + newMRow.appendChild(child.cloneNode(true)); + } + break; + } + newMRow.appendChild(child); + } + breakElement = parent; + if (upToBreak) newMRow.appendChild(upToBreak); + upToBreak = newMRow.children.length === 1 ? newMRow.firstElementChild : newMRow; + } + if (breakElement.tagName === "mrow" && breakElement.children.length === 1) { + const newBreakElement = breakElement.firstElementChild; + breakElement.replaceWith(newBreakElement); + breakElement = newBreakElement; + } + return addNewLineBreakRow(parent, upToBreak, breakElement); +} +/** +* +* @param {Element} mo +* @returns {Element} +*/ +function computeLineBreakRoot(mo) { + let mrow = mo; + let parent = mo.parentElement; + while (parent.tagName === "mrow" || parent.tagName === "mstyle" || parent.tagName === "mpadded") { + mrow = parent; + parent = parent.parentElement; + } + return mrow; +} +/** +* Finds all the forced linebreaks, splits the lines, and stores the indent info on the 'mtd' +* @param {Element} math +*/ +function splitIntoLinesAtForcedBreaks(math, maxLineWidth) { + const forcedBreakElements = math.querySelectorAll("mo[linebreak=\"newline\"]"); + if (forcedBreakElements.length === 0) return; + /** @type {Element} */ + let lastRow = null; + forcedBreakElements.forEach(mo => { + lastRow = splitLine(mo); + }); + const tableChildren = lastRow.parentElement.children; + storeLineBreakAttrsOnMtd(tableChildren[0].firstElementChild, tableChildren[0].firstElementChild); + for (let i = 0; i < forcedBreakElements.length; i++) storeLineBreakAttrsOnMtd(tableChildren[i + 1].firstElementChild, forcedBreakElements[i]); +} +/** +* Returns true if first line of math +* @param {Element} mtr +* returns {boolean} +*/ +function isFirstRow(mtr) { + return mtr === mtr.parentElement.firstElementChild; +} +/** +* Find the leftMostChild not counting an mspace +* @param {Element} element +*/ +function leftMostChild(element) { + while (element.children.length > 0) element = element.firstElementChild; + return element.tagName == "mspace" ? element.nextElementSibling : element; +} +function isMatchLessThanHalfWay(xStart, indent, maxWidth) { + return indent - xStart <= .5 * maxWidth; +} +/** +* Return the operators that match 'char'. For the match, "+"/"-" match each other +* @param {Element[]} operators +* @param {string} char +* @returns {Element[]} +*/ +function filterOnCharMatch(operators, char) { + if (char === "-") char = "+"; + return operators.filter(function (operator) { + let opChar = operator.textContent.trim(); + if (opChar === "-") opChar = "+"; + return char === opChar; + }); +} +/** +* Look through all the previous lines and find a good indent +* Potential breakpoints are those 'mo's at the same depth as the 'mo' that starts the current line +* Preference is given to an 'mo' with the same char (i.e, if we have a '+', find another '+' at the same depth). +* Of those 'mo' that match, the one with the minimum amount of indent is chosen so that more fits on that line. +* @param {Element} mtd +* @returns {number} +*/ +function computeAutoShiftAmount(mtd) { + if (isFirstRow(mtd.parentElement)) return 0; + const mo = leftMostChild(mtd); + if (!mo.hasAttribute(ELEMENT_DEPTH)) console.log(`Linebreaking error: depth not set on ${mo.tagName} with content '${mo.textContent.trim()}'`); + const moDepth = mo.getAttribute(ELEMENT_DEPTH); + const moChar = mo.textContent.trim(); + let minIndentAmount = 1e21; + let operatorMatched = false; + const xStart = mtd.getBoundingClientRect().left; + const maxWidth = parseFloat(mtd.parentElement.parentElement.getAttribute(MTABLE_LINEBREAKS_ATTR)); + let previousLine = mtd.parentElement.previousElementSibling; + while (previousLine) { + const previousLineOperators = getAllBreakPoints(previousLine.firstElementChild).filter(operator => moDepth === operator.getAttribute(ELEMENT_DEPTH)); + previousLineOperators.length === 0 || previousLineOperators[0].textContent.trim(); + const previousLineMatches = filterOnCharMatch(previousLineOperators, moChar); + let indent = previousLineMatches.length === 0 ? minIndentAmount : previousLineMatches[0].getBoundingClientRect().left; + if (isMatchLessThanHalfWay(xStart, indent, maxWidth)) { + if (indent < minIndentAmount || !operatorMatched) { + operatorMatched = true; + minIndentAmount = indent; + } + } + indent = previousLineOperators.length === 0 ? minIndentAmount : previousLineOperators[0].getBoundingClientRect().left; + if (!operatorMatched && isMatchLessThanHalfWay(xStart, indent, maxWidth)) minIndentAmount = Math.min(indent, minIndentAmount); + previousLine = previousLine.previousElementSibling; + } + if (minIndentAmount == 1e21) return convertToPx(mo, FALLBACK_INDENT_AMOUNT); + return minIndentAmount - xStart; +} +/** +* Adds shift amounts to the mtd +* The amount is finalized in a pass after linebreaking. +* It is not done now because center/right alignment positioning would mess up linebreaking +* @param {Element} mtd +* @param {string} alignment // should be one of 'left'|'center'|'right' +* @param {number} shiftAmount +*/ +function setupLineShifts(mtd, alignment, shiftAmount) { + mtd.setAttribute("style", `text-align: ${alignment};`); + const mspace = newElement("mspace"); + mspace.setAttribute("width", shiftAmount.toString() + "px"); + mtd.setAttribute(INDENT_AMOUNT, shiftAmount.toString()); + if (mtd.children.length !== 1 || mtd.firstElementChild.tagName !== "mrow") { + console.log(`unexpected element '${mtd.firstElementChild.tagName}' encountered while trying to indent line`); + return; + } + const mrow = mtd.firstElementChild; + if (alignment === "right") mrow.appendChild(mrow);else mrow.insertBefore(mspace, mrow.firstElementChild); +} +/** +* Return the amount of indent that should happen if we break on 'mo' +* @param {Element} mo // mo or mtd +* @param {number} xLineStart +* @param {Object} indentAttrs +* @returns {number} +*/ +function computeIndentAmount(mo, xLineStart, indentAttrs) { + let indentShiftAsPx = parseFloat(indentAttrs.indentShift); + let indentAlign = indentAttrs.indentAlign; + if (indentAlign === "id") { + const elementWithID = getElementByIdEverywhere(indentAttrs.target); + if (elementWithID) return elementWithID.getBoundingClientRect().left - xLineStart + indentShiftAsPx; + indentAlign = "auto"; + } + if (indentAlign == "auto") { + if (indentAttrs.firstMiddleOrLast !== "first") { + while (mo.tagName !== "mtd" && !mo.parentElement.parentElement.hasAttribute(MTABLE_HAS_LINEBREAKS)) mo = mo.parentElement; + indentShiftAsPx += computeAutoShiftAmount(mo); + } + } + return indentShiftAsPx; +} +/** +* Indent the line +* @param {Element} mtd +*/ +function indentLine(mtd) { + if (mtd.hasAttribute(INDENT_AMOUNT)) return; + const indentAttrs = JSON.parse(mtd.getAttribute(INDENT_ATTRS)); + const xLineStart = mtd.getBoundingClientRect().left; + let indentShiftAsPx = computeIndentAmount(mtd, xLineStart, indentAttrs); + let indentAlign = indentAttrs.indentAlign; + if (indentAlign === "id") { + if (getElementByIdEverywhere(indentAttrs.target) && !mtd.querySelector("#" + indentAttrs.target)) { + setupLineShifts(mtd, "left", indentShiftAsPx); + return; + } + indentAlign = "auto"; + } + if (indentAlign == "auto") indentAlign = "left"; + setupLineShifts(mtd, indentAlign, indentShiftAsPx); +} +/** +* Returns the outermost embellishment of an 'mo' +* @param {Element} mo +* @returns {Element} +*/ +function expandToEmbellishedElement(mo) { + let el = mo; + let parent = mo.parentElement; + do { + if (parent.firstElementChild !== mo || !EMBELLISHED_ELEMENT_NAMES.includes(parent.tagName)) { + if (el !== mo) { + if (!mo.hasAttribute(ELEMENT_DEPTH)) console.log(`Linebreaking error: depth not set on ${mo.tagName} with content '${mo.textContent.trim()}'`); + el.setAttribute(ELEMENT_DEPTH, mo.getAttribute(ELEMENT_DEPTH)); + } + return el; + } + el = parent; + parent = parent.parentElement; + } while (parent); + console.log(`In linebreaking in expandToEmbellishedElement: unexpected loop termination. mo = '${mo.tagName}'`); + return mo; +} +/** +* Return all the potential break points inside 'element' (math or mtd) +* @param {Element} element +* @returns {Element[]} +*/ +function getAllBreakPoints(element) { + return Array.from(element.querySelectorAll("mo:not([linebreak=\"nobreak\"])")).filter(mo => { + do mo = mo.parentElement; while (mo.tagName === "mrow" || mo.tagName === "mstyle" || mo.tagName === "mpadded"); + return mo.tagName === "math" || isInLineBreakTable(mo); + }).map(mo => expandToEmbellishedElement(mo)); +} +/** +* +* @param {string} ch +* @returns number +*/ +function operatorPrecedence(ch) { + const precedence = PrecedenceTable[ch]; + if (precedence === void 0) return 40; + return precedence; +} +/** +* +* @param {Element} el +* @returns {Element} +*/ +function getEmbellishedOperator(el) { + if (el.tagName === "mo") return el; + let firstChild = el; + while (EMBELLISHED_ELEMENT_NAMES.includes(firstChild.tagName)) { + firstChild = firstChild.firstElementChild; + if (!firstChild) return el; + } + return firstChild.tagName === "mo" ? firstChild : el; +} +/** +* +* @param {[any]} stack +* @returns {any} +*/ +function top(stack) { + return stack[stack.length - 1]; +} +/** +* +* @param {[] | Element} elementStackEntry +* @returns {boolean} +*/ +function isOperand(elementStackEntry) { + return Array.isArray(elementStackEntry); +} +/** +* +* @param {string} mo +* @returns {boolean} +*/ +function isPrefix(mo) { + return OpenList.includes(mo); +} +/** +* The "reduce" step of parsing. +* @param {[number]} opStack +* @param {[Element | [Element]]} elementStack +* @param {number} childPrecedence +* @returns {[[number], [Element | [Element]]} + +*/ +function parseReduce(opStack, elementStack, childPrecedence) { + let stackPrecedence = top(opStack); + let previousStackPrecedence = stackPrecedence + 1; + while (childPrecedence < stackPrecedence) { + let iPopTo = elementStack.length - 1; + while (Array.isArray(elementStack[iPopTo])) iPopTo--; + iPopTo--; + opStack.pop(); + while (Array.isArray(elementStack[iPopTo])) iPopTo--; + const elementsPopped = elementStack.splice(iPopTo + 1); + if (stackPrecedence === previousStackPrecedence && Array.isArray(top(elementsPopped))) { + const lastElement = elementsPopped.pop(); + elementStack.push(elementsPopped.concat(lastElement)); + } else elementStack.push(elementsPopped); + previousStackPrecedence = stackPrecedence; + stackPrecedence = top(opStack); + } + return [opStack, elementStack]; +} +function addInvisibleFunctionApply(opStack, elementStack) { + const childPrecedence = operatorPrecedence(InvisibleFunctionApply); + [opStack, elementStack] = parseReduce(opStack, elementStack, childPrecedence); + opStack.push(childPrecedence); + elementStack.push(InvisibleFunctionApplyMo); +} +/** +* @param {Element} treeRoot +* @param {[number]} opStack +* @param {[Element | [Element]]} elementStack +* @returns {[[number], [Element | [Element]]} +*/ +function buildParseTree(treeRoot, opStack, elementStack) { + for (let i = 0; i < treeRoot.children.length; i++) { + const child = getEmbellishedOperator(treeRoot.children[i]); + if (child.tagName === "mo") { + const childCh = child.textContent.trim(); + if (isOperand(top(elementStack)) && isPrefix(childCh)) addInvisibleFunctionApply(opStack, elementStack); + if (isPrefix(childCh)) { + opStack.push(0); + elementStack.push(child); + } else if (CloseList.includes(childCh)) { + [opStack, elementStack] = parseReduce(opStack, elementStack, 0); + elementStack.push(child); + if (top(opStack) !== 0) console.log(`In linebreaking, parsing error with close char -- top of op stack is ${top(opStack)}`); + opStack.pop(); + const elementsPopped = elementStack.splice(elementStack.length - 3); + elementStack.push(elementsPopped); + } else { + const childPrecedence = operatorPrecedence(childCh); + [opStack, elementStack] = parseReduce(opStack, elementStack, childPrecedence); + opStack.push(childPrecedence); + elementStack.push(child); + } + } else if (child.tagName === "mrow" || child.tagName === "mpadded" || child.tagName === "mstyle") [opStack, elementStack] = buildParseTree(child, opStack, elementStack);else { + if (isOperand(top(elementStack))) addInvisibleFunctionApply(opStack, elementStack); + elementStack.push([child]); + } + } + return [opStack, elementStack]; +} +/** +* Store nesting depth info for each 'mo' as an attr. Depth is based on depth in tree of arrays +* @param {[Element | [Element]]} elementStack +* @param {number} depth +*/ +function setDepthAttr(elementStack, depth) { + elementStack.forEach(child => { + if (Array.isArray(child)) setDepthAttr(child, depth + 1);else if (child.tagName === "mo") child.setAttribute(ELEMENT_DEPTH, depth.toString()); + }); +} +/** +* Tries to determine if there is good mrow structure. If so returns true. +* @param {Element} mrow +* @returns {boolean} +*/ +function isMRowWellStructured(mrow) { + if (mrow.childElementCount <= 3) return true; + if (mrow.childElementCount % 2 === 0) return false; + const precedence = operatorPrecedence(mrow.children[1].textContent.trim()); + for (let i = 0; i < mrow.childElementCount - 1; i += 2) if (mrow.children[i].tagName === "mo" || mrow.children[i + 1].tagName !== "mo" || operatorPrecedence(mrow.children[i + 1].textContent.trim()) !== precedence) return false; + return true; +} +/** +* Tries to determine if there is good mrow structure. If so returns true. +* @param {Element} treeRoot +* @returns {boolean} +*/ +function isWellStructured(treeRoot) { + const mrows = Array.from(treeRoot.querySelectorAll("mrow")); + if (treeRoot.tagName === "mrow" || treeRoot.tagName === "math") mrows.push(treeRoot); + switch (mrows.length) { + case 0: + return true; + case 1: + return isMRowWellStructured(mrows[0]); + case 2: + return isMRowWellStructured(mrows[0]) && isMRowWellStructured(mrows[1]); + default: + return isMRowWellStructured(mrows[0]) && isMRowWellStructured(mrows[Math.floor(mrows.length / 2)]) && isMRowWellStructured(mrows[mrows.length - 1]); + } +} +/** +* Store nesting depth info for each 'mo' as an attr. Depth is based on depth in tree +* @param {Element} el +* @param {number} depth +*/ +function setDepthAttrBasedOnOriginalTree(el, depth) { + const embellishedOp = getEmbellishedOperator(el); + if (embellishedOp.tagName === "mo") { + embellishedOp.setAttribute(ELEMENT_DEPTH, depth.toString()); + return; + } + if (el.tagName === "mrow" || el.tagName === "mstyle" || el.tagName === "mpadded" || el.tagName === "math") for (let i = 0; i < el.childElementCount; i++) setDepthAttrBasedOnOriginalTree(el.children[i], depth + (el.tagName === "mrow" ? 1 : 0)); +} +/** +* Store nesting depth info for each 'mo' as an attr +* @param {Element} linebreakRoot +*/ +function addDepthInfo(linebreakRoot) { + /** @type {Element[]} */ + let linebreakRoots = []; + const linebreakElements = Array.from(linebreakRoot.querySelectorAll("mo[linebreak=\"newline\"]")); + linebreakElements.push(linebreakRoot); + linebreakElements.forEach(mo => { + const linebreakRoot = computeLineBreakRoot(mo); + if (!linebreakRoots.includes(linebreakRoot)) { + linebreakRoots.push(linebreakRoot); + if (isWellStructured(linebreakRoot)) setDepthAttrBasedOnOriginalTree(linebreakRoot, 0);else { + let [opStack, elementStack] = buildParseTree(linebreakRoot, [-1], [null]); + if (elementStack.length != 2) [opStack, elementStack] = parseReduce(opStack, elementStack, -1); + setDepthAttr(elementStack[1], 0); + } + } + }); +} +/********* linebreaking penalty computation *******/ +/** +* Used in penalty computation; 0 <= x <= max +* @param {number} x +* @param {number} xMax +* @returns {number} +*/ +function computeLineFillPenalty(x, xMax) { + const penalty = (LINE_FILL_TARGET * xMax - x) / xMax; + return penalty * penalty; +} +/** +* Used in penalty computation +* @param {Element} mo +* @returns {number} +*/ +function computeDepthPenalty(mo) { + const depthTable = [.05, .090909, .173554, .248685, .316987, .379079, .435526, .486842, .533493, .575902, .614457, .649506, .681369, .710336, .736669, .760608]; + if (!mo.hasAttribute(ELEMENT_DEPTH)) console.log(`Linebreaking error: depth not set on ${mo.tagName} with content '${mo.textContent.trim()}'`); + let depth = parseInt(mo.getAttribute(ELEMENT_DEPTH)); + return depth >= depthTable.length ? 1 - 3.482066 / depth : depthTable[depth]; +} +/** +* Computes a penalty based on % line filled, depth in the syntax tree, and whether the user indicated a break here is good/bad +* @param {Element} mo +* @param {number } x +* @param {number} xMax +* @returns {number} +*/ +function computePenalty(mo, x, xMax) { + const penalty = DEPTH_PENALTY_TO_FILL_PENALTY_RATIO * computeDepthPenalty(mo) + computeLineFillPenalty(x, xMax); + const linebreakAttrVal = getMathMLAttrValueAsString(mo, "linebreak", "auto"); + if (linebreakAttrVal === "goodbreak") return penalty / GOOD_PENALTY_SCALE_FACTOR;else if (linebreakAttrVal === "badbreak") return BAD_PENALTY_SCALE_FACTOR * penalty;else return penalty; +} +/** +* Handles substitution of char if InvisibleTimes ('linebreakmultchar' mo attr) +* The array is modified and the node replaced in the DOM +* @param {Element[]} potentialBreaks +* @param {number} index // index of char in +* @returns {Element} // the mo @index or it's replacement +*/ +function substituteCharIfNeeded(potentialBreaks, index) { + const mo = potentialBreaks[index]; + if (mo.textContent.trim() === "⁢") { + const replaceChar = getMathMLAttrValueAsString(mo, "linebreakmultchar", "⁢"); + if (replaceChar !== "⁢") { + const replacementMO = newElement("mo"); + replacementMO.textContent = replaceChar; + copyAttributes(replacementMO, mo); + mo.replaceWith(replacementMO); + potentialBreaks[index] = replacementMO; + return replacementMO; + } + } + return mo; +} +/** +* The entry point to linebreaking +* @param {Element} element // or (if previously split due to manual linebreak) +* @param {number} maxLineWidth +*/ +function linebreakLine(element, maxLineWidth) { + if (parseFloat(element.getAttribute(FULL_WIDTH)) <= maxLineWidth) return; + const potentialBreaks = getAllBreakPoints(element); + let lineBreakMO; + /** @type {Element} */ + let lastRow = element.tagName === "mtd" ? element.parentElement : element.parentNode; + let nLines = 0; + let iOperator = 1; + while (iOperator < potentialBreaks.length) { + let iLine = iOperator; + const firstMTD = element.tagName === "mtd" ? lastRow.firstElementChild : lastRow.lastElementChild; + const indentAttrs = JSON.parse(firstMTD.getAttribute(INDENT_ATTRS)); + const leftSide = indentAttrs.linebreakstyle === "before" ? potentialBreaks[iOperator - 1].getBoundingClientRect().left : firstMTD.firstElementChild.getBoundingClientRect().left; + const lineBreakWidth = maxLineWidth - computeIndentAmount(potentialBreaks[iOperator - 1], firstMTD.getBoundingClientRect().left, indentAttrs); + let minPenalty = 1e5; + let iMinPenalty = -1; + while (iLine < potentialBreaks.length) { + const xRelativePosition = potentialBreaks[iLine].getBoundingClientRect().right - leftSide; + if (xRelativePosition > lineBreakWidth) break; + const penalty = computePenalty(potentialBreaks[iLine], xRelativePosition, lineBreakWidth); + if (penalty <= minPenalty) { + minPenalty = penalty; + iMinPenalty = iLine; + } + iLine++; + } + if (iMinPenalty === -1) { + console.log(`Linebreaking error: no breakpoint found on line ${nLines + 1}`); + iMinPenalty = iOperator; + } + nLines++; + iOperator = iMinPenalty + 1; + if (iOperator < potentialBreaks.length) { + lineBreakMO = substituteCharIfNeeded(potentialBreaks, iMinPenalty); + lastRow = splitLine(potentialBreaks[iMinPenalty]); + lastRow.parentElement.setAttribute(MTABLE_LINEBREAKS_ATTR, maxLineWidth.toString()); + storeLineBreakAttrsOnMtd(lastRow.firstElementChild, lineBreakMO); + const previousRow = lastRow.previousElementSibling; + if (!previousRow.firstElementChild.hasAttribute(INDENT_ATTRS)) previousRow.firstElementChild.setAttribute(INDENT_ATTRS, element.getAttribute(INDENT_ATTRS)); + indentLine(previousRow.firstElementChild); + } else if (nLines === 1) return; + } + if (nLines > 0) indentLine(lastRow.firstElementChild); +} +/** +* Linebreak/indent display math +* There is no good target in core, so the following hack is used if linebreaking is needed: +* 1. The custom element 'math-with-linebreaks' is created as the parent of 'math' if it isn't already there. +* 2. A clone is made and added into the shadow DOM (avoids duplicate 'id' problems) +* 3. A marked is created at the appropriate point (typically a child of ) and each line of the math is a row in the table +* +* On resize, we throw out the old shadow and start from fresh with a clone of the element. +* +* Since most math doesn't need to be linebroken, we start with a quick check to see if there are forced linebreaks or if it is wide. +* @param {HTMLElement} math +*/ + +/** +* The main starting point +* @param {Element} customElement // (likely inside a shadow DOM) +* @param {number} maxLineWidth +*/ +function lineBreakDisplayMath(customElement, maxLineWidth) { + maxLineWidth = Math.min(maxLineWidth, parseFloat(customElement.getAttribute(FULL_WIDTH))); + const math = customElement.shadowRoot.lastElementChild; + if (math.childElementCount > 1) { + const mrow = newElement("mrow"); + while (math.firstElementChild) mrow.appendChild(math.firstElementChild); + math.appendChild(mrow); + } + shadowRoot.set(customElement.shadowRoot); + splitIntoLinesAtForcedBreaks(math, maxLineWidth); + let linebreakGroups = Array.from(math.querySelectorAll(`mtable[${MTABLE_HAS_LINEBREAKS}]`)); + if (linebreakGroups.length > 0) linebreakGroups.forEach(table => { + table.setAttribute(MTABLE_LINEBREAKS_ATTR, maxLineWidth.toString()); + Array.from(table.children).forEach(line => { + const mtd = line.firstElementChild; + indentLine(mtd); + if (mtd.firstElementChild.getBoundingClientRect().right - mtd.getBoundingClientRect().left > maxLineWidth) linebreakLine(mtd, maxLineWidth); + }); + });else if (parseInt(customElement.getAttribute(FULL_WIDTH)) >= maxLineWidth) { + math.setAttribute(INDENT_ATTRS, JSON.stringify(computeIndentAttrObject(math, "first"))); + linebreakLine(math, maxLineWidth); + } +} +/** +* +* @param {Element} customElement +* @param {Element} math +*/ +function setShadowRootContents(customElement, math) { + /** @type {HTMLElement} */ + const mathClone = cloneElementWithShadowRoot(math); + customElement.shadowRoot.appendChild(mathClone); + let fullWidth = mathClone.lastElementChild.getBoundingClientRect().right - mathClone.firstElementChild.getBoundingClientRect().left; + if (mathClone.hasAttribute("maxwidth")) fullWidth = Math.min(fullWidth, convertToPx(mathClone, mathClone.getAttribute("maxwidth"))); + customElement.setAttribute(FULL_WIDTH, fullWidth.toString()); + lineBreakDisplayMath(customElement, fullWidth); + customElement.setAttribute(LINE_BREAK_WIDTH, (2 * fullWidth).toString()); +} +function addCustomElement(math) { + const computedStyle = getComputedStyle(math).getPropertyValue("display"); + const displayValue = math.hasAttribute("display") ? math.getAttribute("display") : "inline"; + if (computedStyle === "inline" || displayValue === "inline") return null; + if (math.tagName.toLowerCase() === SHADOW_ELEMENT_NAME) return math;else if (math.parentElement.tagName.toLowerCase() === SHADOW_ELEMENT_NAME) return math;else { + const mathParent = math.parentElement; + const nextSibling = math.nextElementSibling; + const shadowHost = document.createElement(SHADOW_ELEMENT_NAME); + shadowHost.appendChild(math); + mathParent.insertBefore(shadowHost, nextSibling); + addDepthInfo(math); + setShadowRootContents(shadowHost, math); + return null; + } +} +{ + let UAStyle = document.createElement("style"); + UAStyle.innerHTML = ` + math-with-linebreaks { + display: block; + } + `; + document.head.insertBefore(UAStyle, document.head.firstElementChild); +} + +//#endregion +//#region href/href.js +/*** +* Make href work on all MathML elements by adding click, mouseover, +* and mouseout events +***/ +/** +* @param {MathMLElement} el +*/ + +//#endregion +export { _MathTransforms }; \ No newline at end of file diff --git a/src/vendor/mathml-polyfills/all-polyfills-bundle.js b/src/vendor/mathml-polyfills/all-polyfills-bundle.js new file mode 100644 index 00000000..2bd6a403 --- /dev/null +++ b/src/vendor/mathml-polyfills/all-polyfills-bundle.js @@ -0,0 +1,295 @@ +const e=`http://www.w3.org/1998/Math/MathML`,t={_plugins:new Map,_css:``,_createStyleSheet:e=>{if(e.length!==t.cssKey){t.cssKey=e.length;let n=document.createElement(`style`);n.textContent=e,document.head.appendChild(n),t.styleSheet=n,document.head.removeChild(n)}return t.styleSheet},getCSSStyleSheet:()=>t._createStyleSheet(t._css).cloneNode(!0),transform:e=>{for(let n of t._plugins.keys()){let r=t._plugins.get(n);Array.from(e.querySelectorAll(n)).reverse().forEach(e=>{let t=r(e);t&&t!==e&&e.parentElement.replaceChild(t,e)})}},add:(e,n,r=``)=>{t._plugins.set(e,n),t._css+=r}};function n(e,r){if(r===void 0&&(r=e.cloneNode(!0)),e.shadowRoot){let i=r.attachShadow({mode:`open`});i.appendChild(t.getCSSStyleSheet());for(let t=0;t{let t=document.createElement(`img`),n=e.attributes;for(let i=n.length-1;i>=0;i--)switch(n[i].name){case`valign`:t.setAttribute(`style`,`vertical-align: ${n[i].value}`);break;case`width`:case`height`:t.setAttribute(n[i].name,r(e.parentElement,n[i].value).toString());break;default:t.setAttribute(n[i].name,n[i].value);break}return t});const i=`http://www.w3.org/1998/Math/MathML`;function a(e){return e.replace(/^[\s]+|[\s]+$/g,``).replace(/[\s]+/g,` `)}function o(e,t){let n=document.createElementNS(i,`mo`);return n.appendChild(document.createTextNode(e)),n.setAttribute(t?`separator`:`fence`,`true`),n}function s(){return document.createElementNS(i,`mrow`)}function c(e){if(e===null)return[`,`];let t=[];for(let n=0;n=55296&&r<56320&&n+1{let t=s();if(t.appendChild(o(a(e.getAttribute(`open`)||`(`))),e.childElementCount===1)t.appendChild(n(e.firstElementChild));else if(e.childElementCount>1){let r=c(e.getAttribute(`separators`)),i=s(),a=e.firstElementChild;for(;a;)i.appendChild(n(a)),a=a.nextElementSibling,a&&r.length&&i.appendChild(o(r.length>1?r.shift():r[0]));t.appendChild(i)}t.appendChild(o(a(e.getAttribute(`close`)||`)`)));for(let n=0;n{let n=t.firstElementChild.getBoundingClientRect().height,i=r(t.firstElementChild,`0.5em`),a=Math.max(n,t.lastElementChild.getBoundingClientRect().height)+i,o=document.createElementNS(e,`mrow`),s=document.createElementNS(e,`mpadded`);s.setAttribute(`height`,`${n+i}px`),s.setAttribute(`voffset`,`${i}px`),s.appendChild(t.firstElementChild),o.appendChild(s);let c=document.createElementNS(e,`mo`);c.setAttribute(`stretchy`,`true`),c.setAttribute(`symmetric`,`false`),c.setAttribute(`lspace`,`0px`),c.setAttribute(`rspace`,`0px`);let l=Math.round(-.2*a);return c.setAttribute(`style`,`margin-left: ${l}px; margin-right: ${l}px`),c.appendChild(document.createTextNode(`/`)),o.appendChild(c),o.appendChild(t.lastElementChild),o});const u=[`msub`,`msup`,`msubsup`,`munder`,`mover`,`munderover`,`mmultiscripts`,`mfrac`,`semantics`],d=[`mrow`,`mstyle`,`mphantom`,`mpadded`];function f(e){if(e.tagName===`mtext`||e.tagName===`mspace`||e.tagName===`maligngroup`||e.tagName===`malignmark`)return!0;if(d.includes(e.tagName)){for(let t=0;t{!e.getAttribute(`accentunder`)&&e.tagName!==`mover`&&m(e.children[1],`accentunder`),!e.getAttribute(`accent`)&&e.tagName!==`munder`&&m(e.children.length===2?e.children[1]:e.children[2],`accent`)};t.add(`munder`,h),t.add(`mover`,h),t.add(`munderover`,h);function g(e){return e.getBoundingClientRect().width}function _(t,n,r,i){if(n>=i||r===`center`)return t;if(t.tagName!==`mrow`){let n=t.nextElementSibling,r=document.createElementNS(e,`mrow`),i=t.parentElement;r.appendChild(t),i.insertBefore(r,n),t=r}let a=document.createElementNS(e,`mspace`);return a.setAttribute(`width`,`${(i-n).toPrecision(2)}px`),r===`left`?t.appendChild(a):r===`right`&&t.insertBefore(a,t.firstElementChild),t}function v(e,t,n,r){return _(e.children[t],g(e.children[t]),e.getAttribute(r),g(e.children[n])),e}const ee=e=>v(e,0,1,`numalign`),te=e=>v(e,1,0,`denomalign`),y=e=>v(e,1,0,`align`);t.add(`mfrac[numalign]`,ee),t.add(`mfrac[denomalign]`,te),t.add(`munder[align]`,y),t.add(`mover[align]`,y),t.add(`munderover[align]`,e=>{let t=e.getAttribute(`align`),n=g(e.children[0]),r=g(e.children[1]),i=g(e.children[2]),a=Math.max(n,r,i);return _(e.children[1],r,t,a),_(e.children[2],i,t,a),e}),t.add(`[mathsize="small"]`,e=>(e.setAttribute(`mathsize`,`75%`),e)),t.add(`[mathsize="normal"]`,e=>(e.setAttribute(`mathsize`,`100%`),e)),t.add(`[mathsize="big"]`,e=>(e.setAttribute(`mathsize`,`150%`),e));const b={veryverythinmathspace:`0.05555555555555555em`,verythinmathspace:`0.1111111111111111em`,thinmathspace:`0.16666666666666666em`,veryverythickmathspace:`0.3888888888888889em`,verythickmathspace:`0.3333333333333333em`,thickmathspace:`0.2777777777777778em`,mediummathspace:`0.2222222222222222em`},x=e=>{let t=e.getAttribute(`rspace`);if(t){for(let[e,n]of Object.entries(b))t=t.replaceAll(e,n);t=t.replaceAll(`negative0`,`-0`),e.setAttribute(`rspace`,t)}if(t=e.getAttribute(`lspace`),t){for(let[e,n]of Object.entries(b))t=t.replaceAll(e,n);t=t.replaceAll(`negative0`,`-0`),e.setAttribute(`lspace`,t)}return e};t.add(`math *[rspace*="mathspace"]`,x),t.add(`math *[lspace*="mathspace"]`,x);const S=[`left`,`right`,`top`,`bottom`,`actuarial`,`madruwb`],C={longdiv:`padding: 0.05em 0.2em 0.0em 0.433em; border-top: 0.067em solid;`,actuarial:`padding-top: 0.01em; padding-right: 0.1em;`,radical:`padding-top: 0.403em; padding-bottom: 0.112em; padding-left: 1.02em;`,box:`padding: 0.2em;`,roundedbox:`padding: 0.267em;`,circle:`padding: 0.267em;`,phasorangle:`border-bottom: 0.067em solid; padding: 0.2em 0.2em 0.0em 0.7em;`,phasoranglertl:`border-bottom: 0.067em solid; padding: 0.2em 0.7em 0.0em 0.2em;`,madruwb:`padding-bottom: 0.2em; padding-right: 0.2em;`},w={horizontalstrike:[0,0,!1,[``]],verticalstrike:[0,Math.PI/2,!1,[``]],updiagonalstrike:[-1,0,!1,[``]],downdiagonalstrike:[1,0,!1,[``]],uparrow:[0,-Math.PI/2,!1,[`verticalstrike`]],downarrow:[0,Math.PI/2,!1,[`verticakstrike`]],rightarrow:[0,0,!1,[`horizontalstrike`]],leftarrow:[0,Math.PI,!1,[`horizontalstrike`]],updownarrow:[0,Math.PI/2,!0,[`verticalstrike`,`uparrow`,`downarrow`]],leftrightarrow:[0,0,!0,[`horizontalstrike`,`leftarrow`,`rightarrow`]],northeastarrow:[-1,0,!1,[`updiagonalstrike`,`updiagonalarrow`]],southeastarrow:[1,0,!1,[`downdiagonalstrike`]],northwestarrow:[1,Math.PI,!1,[`downdiagonalstrike`]],southwestarrow:[-1,Math.PI,!1,[`updiagonalstrike`]],northeastsouthwestarrow:[-1,0,!0,[`updiagonalstrike`,`northeastarrow`,`updiagonalarrow`,`southwestarrow`]],northwestsoutheastarrow:[1,0,!0,[`downdiagonalstrike`,`northwestarrow`,`southeastarrow`]]},ne=Array.from(new Set(S.concat(Object.keys(C),Object.keys(w))));function T(t){let n=document.createElementNS(e,`math`);n.innerHTML=t,document.body.appendChild(n);let r=n.getBoundingClientRect().width;return document.body.lastElementChild.remove(),r}function re(e){return!!(T(`x`)===T(`x`)||/arrow/.test(e)&&T(`x`)===T(`x`)||/phasorangle/.test(e)&&T(`x`)===T(`x`))}function ie(e){e=Array.from(new Set(e)),e.includes(`box`)&&(e=e.filter(e=>!S.includes(e)));for(let[t,n]of Object.entries(w)){let r=n[3];r!==[``]&&e.includes(t)&&(e=e.filter(e=>!r.includes(e)))}return e}function ae(e){let t=!0;do{if(e.hasAttribute(`dir`))return e.getAttribute(`dir`)===`ltr`;t=e.tagName!==`math`,e=e.parentElement}while(t);return!0}function oe(e,t){let n=`0.467em`;return(t.includes(`roundedbox`)||t.includes(`circle`))&&(n=`0.601em`),r(e,n)}t.add(`menclose`,t=>{let i=t.getAttribute(`notation`)||``;if(!re(i))return t;let a=i.split(` `);if(a=a.filter(e=>ne.includes(e)),a.length===0&&a.push(`longdiv`),a=ie(a),a.includes(`phasorangle`)&&!ae(t)){let e=a.indexOf(`phasorangle`);a[e]=`phasoranglertl`}let o=document.createElementNS(e,`mrow`),s=t.firstElementChild;for(;s;)o.appendChild(n(s)),s=s.nextElementSibling;let c=document.createElementNS(e,`mrow`);if(c.className=`menclose`,c.appendChild(o),a.includes(`radical`)){let t=document.createElementNS(e,`msqrt`);t.appendChild(c.firstElementChild),c.appendChild(t),a=a.filter(e=>e!==`radical`)}let l=``,u=oe(t,a),d=t.getBoundingClientRect(),f=d.width+u,p=d.height+u;return a.forEach(n=>{let i=document.createElementNS(e,`mrow`);if(w[n]!==void 0){let[t,r,a,o]=w[n],s=r===0||r===Math.PI,c=t===0?r:t*(Math.atan2(p,f)-r),l=f,u=p;/arrow/.test(n)&&(l--,u--);let d=t===0?s?l:u:Math.sqrt(l*l+u*u);i.style.width=`${d}px`,i.style.transform=(c?`rotate(${c}rad) `:``)+`translate(0.067em, 0.0em)`,i.style.left=`${(l-d)/2}px`;let m=document.createElementNS(e,`mrow`);if(m.className=`line`,i.appendChild(m),/arrow/.test(n)){let t=document.createElementNS(e,`mrow`);t.className=`rthead`,i.appendChild(t);let n=document.createElementNS(e,`mrow`);if(n.className=`rbhead`,i.appendChild(n),a){let t=document.createElementNS(e,`mrow`);t.className=`lthead`,i.appendChild(t);let n=document.createElementNS(e,`mrow`);n.className=`lbhead`,i.appendChild(n)}}}else if(n===`phasorangle`||n===`phasoranglertl`){let e=r(t,`.7em`),a=d.height;i.style.width=`${Math.sqrt(e*e+a*a)}px`,i.style.transform=`rotate(${n===`phasoranglertl`?``:`-`}${Math.atan(a/e)}rad) translateY(0.067em)`}let a=C[n]||``;a===``&&l.length===0&&(a=`padding: 0.2em;`),l.includes(a)||(l+=a),i.className=`menclose-${w[n]===void 0?n:`arrow`}`,c.appendChild(i)}),c.setAttribute(`style`,l),c},` +mrow.menclose { + display: inline-block; + text-align: left; + position: relative; +} + +/* the following class names should be of the form 'menclose-[notation name]' */ +mrow.menclose-longdiv { + position: absolute; + top: 0; + bottom: 0.1em; + left: -0.4em; + width: 0.7em; + border: 0.067em solid; + transform: translateY(-0.067em); + border-radius: 70%; + clip-path: inset(0 0 0 0.4em); + box-sizing: border-box; +} + +mrow.menclose-actuarial { + position: absolute; + display: inline-block; + top: 0; + bottom: 0; + right: 0; + left: 0; + border-top: 0.067em solid; + border-right: 0.067em solid; +} + +mrow.menclose-phasorangle { + display: inline-block; + left: 0; + bottom: 0; + position: absolute; + border-top: 0.067em solid; + transform-origin: bottom left; +} + +mrow.menclose-phasoranglertl { + display: inline-block; + right: 0; + bottom: 0; + position: absolute; + border-top: 0.067em solid; + transform-origin: bottom right; +} + +mrow.menclose-box { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + border: 0.067em solid; +} + +mrow.menclose-roundedbox { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + border: 0.067em solid; + border-radius: 0.267em; +} + +mrow.menclose-circle { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + border: 0.067em solid; + border-radius: 50%; +} + +mrow.menclose-box { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + border: 0.067em solid; +} + +mrow.menclose-left { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + border-left: 0.067em solid; +} + +mrow.menclose-right { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + border-right: 0.067em solid; +} + +mrow.menclose-top { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + border-top: 0.067em solid; +} + +mrow.menclose-bottom { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + border-bottom: 0.067em solid; +} + +mrow.menclose-madruwb { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + border-right: 0.067em solid; + border-bottom: 0.067em solid; +} + +/* + * Arrows and 'strikes' are composed of an 'menclose-arrow' wrapper and one, three or five children + * Strikes just have class 'line' + * Single-headed arrows have the children with classes 'line', 'rthead' (right top arrow head), and 'rbhead' + * Double-headed arrows add lthead' (left top arrow head), and 'lbhead' + */ +mrow.menclose-arrow { + position: absolute; + left: 0; + bottom: 50%; + height: 0; + width: 0; +} + +mrow.menclose > mrow.menclose-arrow > * { + display: block; + position: absolute; + transform-origin: bottom; + border-left: 0.268em solid; + border-right: 0; + box-sizing: border-box; + } + +mrow.menclose-arrow > mrow.line{ + left: 0; + top: -0.0335em; + right: 0.201em; + height: 0; + border-top: 0.067em solid; + border-left: 0; +} + +mrow.menclose-arrow > mrow.rthead { + transform: skewX(0.464rad); + right: 1px; + bottom: -1px; + border-bottom: 1px solid transparent; + border-top: 0.134em solid transparent; +} + +mrow.menclose-arrow > mrow.rbhead { + transform: skewX(-0.464rad); + transform-origin: top; + right: 1px; + top: -1px; + border-top: 1px solid transparent; + border-bottom: 0.134em solid transparent; +} + +mrow.menclose-arrow > mrow.lthead { + transform: skewX(-0.464rad); + left: 0; + bottom: -1px; + border-left: 0; + border-right: 0.268em solid; + border-bottom: 1px solid transparent; + border-top: 0.134em solid transparent; +} + +mrow.menclose-arrow > mrow.lbhead { + transform: skewX(0.464rad); + transform-origin: top; + left: 0; + top: -1px; + border-left: 0; + border-right: 0.268em solid; + border-top: 1px solid transparent; + border-bottom: 0.134em solid transparent; +} +`),t.add(`[linethickness="thin"]`,e=>(e.setAttribute(`linethickness`,`67%`),e)),t.add(`[linethickness="medium"]`,e=>(e.setAttribute(`linethickness`,`100%`),e)),t.add(`[linethickness="thick"]`,e=>(e.setAttribute(`linethickness`,`167%`),e));function se(e){return e.replace(/^[\s]+|[\s]+$/g,``).replace(/[\s]+/g,` `)}t.add(`ms`,e=>{let t=e.getAttribute(`lquote`)||`"`,n=e.getAttribute(`rquote`)||`"`,r=se(e.textContent);r=r.replace(t,`\\`+t),n!==t&&(r=r.replace(n,`\\`+n)),e.textContent=t+r+n});const ce={normal:`mup`,bold:`mbf`,italic:`mit`,"bold-italic":`mbfit`,"double-struck":`Bbb`,"bold-fraktur":`mbffrak`,script:`mscr`,"bold-script":`mbfscr`,fraktur:`mfrak`,"sans-serif":`msans`,"bold-sans-serif":`mbfsans`,"sans-serif-italic":`mitsans`,"sans-serif-bold-italic":`mbfitsans`,monospace:`mtt`,isolated:`misol`,initial:`minit`,tailed:`mtail`,looped:`mloop`,stretched:`mstrc`,chancery:`mchan`,roundhand:`mrhnd`},le=e=>{let t=e.getAttribute(`mathvariant`);if(!t||t==`normal`)return;let n=ce[t];if(!n)return;let r=e.textContent,i=``,a=!0;for(let e=0;e=`0`&&e<=`9`)return n+=120734,r=_e.indexOf(t),r==-1?e:String.fromCodePoint(n+r*10);if(/[A-Za-z]/.test(e)){let i=``;(t==`mchan`||t==`mrhnd`)&&(i=t==`mchan`?`︀`:`︁`,t=`mscr`);let a=``;switch(t){case`mit`:if(e==`h`)return`ℎ`;break;case`mfrak`:a=fe[e];break;case`mscr`:a=pe[e];break;case`Bbb`:a=de[e];break}return a?a+i:(r=ve.indexOf(t),r==-1?e:(n-=65,n>26&&(n-=6),String.fromCodePoint(n+52*r+119808)+i))}if(e>=`Α`&&e<=`ϵ`||e==`∂`||e==`∇`){if(t==`mbf`){if(e==`Ϝ`)return`𝟊`;if(e==`ϝ`)return`𝟋`}if(r=ye.indexOf(t),r==-1)return e;let i=he[e];return i?n=i:(n-=913,n>25&&(n-=6)),String.fromCodePoint(n+58*r+120488)}if(n<1575)return e==`ı`?`𝚤`:e==`ȷ`?`𝚥`:e;if(n>1722||(r=ge.indexOf(t),r==-1))return e;if(n<=1610){if(n=ue[n-1575],n==-1)return e}else{if(n=`ٮںڡٯ`.indexOf(e),n==-1)return e;n+=28}if(t==`misol`)n==4&&(r=1);else if(1<{let t=xe(e);return D(e,`width`,t),D(e,`height`,t),D(e,`depth`,t),D(e,`lspace`,t),D(e,`voffset`,t),e});const O=`http://www.w3.org/1998/Math/MathML`;function Se(e){return e}function Ce(e){if(e.getElementsByTagName(`mlabeledtr`).length===0)return e;let t=e.getAttribute(`side`)||`right`,n=document.createElementNS(O,`mtd`);n.setAttribute(`intent`,`:no-equation-label`);for(let r=0;r0;)n.appendChild(i.firstChild);t===`right`&&n.appendChild(e),i.replaceWith(n)}else{let e=n.cloneNode();t===`right`?i.appendChild(e):i.insertBefore(e,i.firstElementChild)}}return e}function we(e){if(!e.hasAttribute(`intent`)){e.setAttribute(`intent`,`:equation-label`);return}let t=e.getAttribute(`intent`),n=t.indexOf(`(`),r=n==-1?t:t.substring(0,n);r.includes(`:equation-label`)||(t=r+`:equation-label`+t.substring(r.length),e.setAttribute(`intent`,t))}t.add(`mtable`,e=>{let t=Se(n(e));return Ce(t),t});const k=`.35ex`;var A=class{constructor(e,t){if(this.attrs={},!t){for(;e&&e.tagName.toLowerCase()!==`math`;)e.tagName.toLowerCase()===`mstyle`&&this.addAttrs(e),e=e.parentElement;e&&e.tagName.toLowerCase()===`math`&&this.addAttrs(e)}else if(this.attrs=Object.assign({},t),e.tagName.toLowerCase()===`mstyle`)for(let t of e.attributes)this.attrs[t.name]=t.value}addAttrs(e){for(let t of e.attributes)this.attrs[t.name]||(this.attrs[t.name]=t.value)}getAttr(e,t,n){return e.hasAttribute(t)?e.getAttribute(t):this.attrs[t]?this.attrs[t]:n}},Te=class{constructor(e,t,n){this.location=e,this.crossout=t,this.scriptsizemultiplier=n}},j=class{constructor(e,t,n){if(n){if(typeof e!=`object`)throw Error(`Elementary math mscarry isn't an 'object'`);this.data=document.createElement(n.location===`n`||n.location===`s`?`div`:`span`),this.data.appendChild(e),this.data.className=`carry`,this.data.style.fontSize=Math.round(n.scriptsizemultiplier).toString()+`%`}else{if(typeof e!=`string`)throw Error(`Elementary math mscarry isn't a 'string'`);this.data=document.createTextNode(e)}this.carry=n,this.style=t||``}},M=class{constructor(e,t,n){n===0?this.data=e:n>0?this.data=this.padOnRight(e,n):n<0&&(this.data=this.padOnLeft(e,-n),t-=n),this.nRight=t,this.shift=n,this.style=``,this.addSpacingAfterRow=!1,this.alignAt=0}addUnderline(e,t){this.style+=`border-bottom: ${e} solid ${t};`,this.addSpacingAfterRow=!0}addUnderlineToCells(e,t,n,r){let i=this.data.length-this.nRight,a=i-e;e+t>i&&(this.data=this.padOnLeft(this.data,e+t-i),a=t),e<-this.nRight&&(this.data=this.padOnRight(this.data,this.nRight-e),this.nRight-=e,a=this.data.length);for(let e=a-t;ee.carry)){e.push(t);return}let a=t.data.length-t.nRight-(i.data.length-i.nRight);a!==0&&(a<0?t.data=t.padOnLeft(t.data,-a):i.data=i.padOnLeft(i.data,a));let o=t.nRight-i.nRight;o!==0&&(o<0?t.data=t.padOnRight(t.data,-o):i.data=i.padOnRight(i.data,o));for(let e=0;enew j(e))),t)n+=e.length;else{let r=a.textContent.trim().indexOf(this.getAttr(a,`decimalpoint`,`.`));n=r<0?0:e.length-r,t=!0}}else{let e=a.textContent.trim();e===`-`&&(e=`−`),r.push(new j(e)),t&&(n+=1)}}return[r,this.stackAlign===`decimalpoint`?n:0]}process_mscarries(e,t,n,r){let i=[],a=e.children[0];for(;a;){let e=a.nextElementSibling,o=t,s=n;a.tagName.toLowerCase()===`mscarry`&&(o=this.getAttr(a,`location`,`n`),s=this.getAttr(a,`crossout`,`none`)),i.push(new j(a,``,new Te(o,s,r))),a=e}return i}processChildren(e,t,n,r){if(!e.children)return t;r||=0;for(let i=e.tagName.toLowerCase()===`mlongdiv`?2:0;inew j(e));this.add(t,new M(o,a,r));break}case`msgroup`:t=this.processChildren(e,t,r,parseInt(this.getAttr(e,`shift`,`0`)));break;case`msline`:{let n=parseInt(this.getAttr(e,`length`,`0`)),i=this.getAttr(e,`mslinethickness`,`medium`);i===`medium`?i=k:i===`thin`?i=`.1ex`:i===`thick`&&(i=`.65ex`),t.length===0&&this.add(t,new M([],0,0));let a=t[t.length-1],o=this.getAttr(e,`mathcolor`,`black`);n===0?a.addUnderline(i,o):a.addUnderlineToCells(r,n,i,o);break}case`mscarries`:{let n=this.getAttr(e,`location`,`n`),i=this.getAttr(e,`crossout`,`none`),a=parseFloat(this.getAttr(e,`scriptsizemultiplier`,`0.6`));this.add(t,new M(this.process_mscarries(e,n,i,100*a),0,r));break}case`mstyle`:{let n=this.attrs;if(this.attrs=new A(e,n),e.children.length===1&&e.children[0].tagName.toLowerCase()===`msline`)this.processChild(e.children[0],t,r);else{let i,a;[i,a]=this.process_msrow(e),this.add(t,new M(i,a,r)),this.attrs=n}break}default:{let n,i=0;e.tagName.toLowerCase()==`msrow`?[n,i]=this.process_msrow(e):n=[new j(e.textContent.trim())],this.add(t,new M(n,i,r));break}}return t}processShifts(e,t){let n=0,r=0;for(let i of e)t===`decimalpoint`?(n=Math.max(n,i.data.length-i.nRight),r=Math.max(r,i.nRight)):n=Math.max(n,i.data.length);for(let i of e)switch(t){case`decimalpoint`:i.data=i.padOnLeft(i.data,n-(i.data.length-i.nRight)),i.data=i.padOnRight(i.data,r-i.nRight),i.nRight=r;break;case`left`:i.data=i.padOnRight(i.data,n-i.data.length);break;case`center`:{let e=n-i.data.length;i.data=i.padOnRight(i.data,e/2),i.data=i.padOnLeft(i.data,e-e/2);break}case`right`:i.data=i.padOnLeft(i.data,n-i.data.length);break;default:console.log(`Unknown mstack stackalign attr value: "${t}"`);break}return e}addOnLongDivParts(e,t,n){function r(e){for(let t=e.data.length-1;t>=0;t--)if(e.data[t].data.textContent!==` `)return e.data.length-1-t;return e.data.length}function i(e,t){let n=0;for(let r=e.data.length-1;r>=0&&e.data[r].data.textContent===` `;r--)t>0?t--:(e.data.pop(),n++);for(let n=0;n0&&(o.data=o.padOnRight(o.data,t)),o.addUnderlineToCells(-o.nRight,o.data.length,k,a),o.addSpacingAfterRow=!1,n[0].data=n[0].data.concat(o.data),n[0].nRight+=o.data.length,n[1].data=n[1].data.concat(l.data),n[1].nRight+=l.data.length;break}case`stackedleftleft`:{n.length==1&&(n.push(new M([new j(` `)],0,0)),n=this.processShifts(n,this.stackAlign));for(let e=0;e0&&(o.data=o.padOnLeft(o.data,e)),o.addUnderlineToCells(-o.nRight,o.data.length,k,a),o.addSpacingAfterRow=!1,n[0].data=o.data.concat(n[0].data),n[1].data=l.data.concat(n[1].data);break}case`righttop`:{l.addUnderline(k,a),l.addSpacingAfterRow=!1;let e=c.concat(n);n=this.processShifts(e,this.stackAlign),o.data[0].style+=`border-left: ${k} solid ${a};`,o.addUnderlineToCells(-o.nRight,o.data.length,k,a),n[1].data=n[1].data.concat(o.data),n[1].nRight+=o.data.length;break}default:{l.addUnderlineToCells(-l.nRight,Math.max(l.data.length,n[0].data.length),k,a),l.addSpacingAfterRow=!1;let e=c.concat(n);n=this.processShifts(e,this.stackAlign),this.longdivstyle===`stackedleftlinetop`?(o.data[o.data.length-1].style+=`border-right: ${k} solid ${a};`,o.data[o.data.length-1].style+=`border-right: ${k} solid ${a};`,o.data[o.data.length-1].data.style+=`position:relative`,o.addUnderlineToCells(-o.nRight,o.data.length,k,a)):(o.data=o.padOnRight(o.data,1),s+=1,o.data[s].class=`curved-line`,o.data[s].style=``),n[1].data=o.data.concat(n[1].data);break}}let u=this.processShifts(n,this.stackAlign);return this.longdivstyle===`lefttop`&&(n[0].data[s].style+=`border-bottom: ${k} solid ${a};`),u}shrinkSeparatorColumns(e){if(e.length===0)return;let t=new Set(Array(e[0].data.length).keys()),n=new Set(Array(e[0].data.length).keys());for(let r of e){let e=r.data;for(let r=0;rt.delete(e));for(let n of t)e.forEach(e=>{e.data[n].class=`separator`,n>0&&(e.data[n-1].class=`precedes-separator`)})}expandMStackElement(e){let t=/[-+]?\d*\.?\d*/g,n=parseFloat(t.exec(this.charSpacing)[0])/2+this.charSpacing.slice(t.lastIndex);this.charSpacing.slice(t.lastIndex);let r=`padding: .1ex ${n} 0 ${n}; text-align: ${this.charAlign};`,i=[];i=this.processChildren(e,i,0,0),i=this.processShifts(i,this.stackAlign),e.tagName.toLowerCase()===`mlongdiv`&&(i=this.addOnLongDivParts(e.children[0],e.children[1],i)),i.length>0&&(i[i.length-1].addSpacingAfterRow=!1),this.shrinkSeparatorColumns(i);let a=document.createElement(`table`);a.setAttribute(`class`,`elem-math`);for(let e of i){let t=document.createElement(`tr`);e.style&&t.setAttribute(`style`,e.style);for(let n of e.data){let e=document.createElement(`td`);if(n.alignAt){let e=document.createElement(`span`);e.style.display=n.alignAt===1?`inline-table`:`inline-block`,e.appendChild(n.data),n.data=e}n.class===`curved-line`&&(n.data.textContent=`\xA0`),e.appendChild(n.data),n.class!==`curved-line`&&e.setAttribute(`style`,r+n.style),n.class&&e.setAttribute(`class`,n.class),t.appendChild(e)}if(a.appendChild(t),e.addSpacingAfterRow){let t=document.createElement(`tr`);t.style.height=`.5ex`;for(let n of e.data){let e=document.createElement(`td`);if(/(border-left|border-right)/.test(n.style)){let t=n.style.match(/(border-left|border-right).*?;/g);e.setAttribute(`style`,t)}t.appendChild(e)}a.appendChild(t)}}return a}};let P=n=>{if(n.parentElement&&(n.parentElement.tagName===`M-ELEM-MATH`||n.parentElement.parentElement&&n.parentElement.parentElement.tagName===`M-ELEM-MATH`))return;let r=document.createElement(`span`);r.attachShadow({mode:`open`}).appendChild(t.getCSSStyleSheet());let i=n.parentElement,a=n.nextElementSibling,o=new N(n).expandMStackElement(n);r.shadowRoot.appendChild(o);let s=document.createElementNS(e,`mtext`);s.appendChild(r);let c=document.createElementNS(e,`math`);return r.appendChild(c),c.appendChild(n),i.insertBefore(s,a),null};t.add(`mstack`,P,` +table.elem-math { + border-collapse: collapse; + border-spacing: 0px; +} +table.elem-math tr { + vertical-align: baseline; +} + +td.curved-line { + position: absolute; + padding-top: 0em; + width: 0.75em; + border: 0.3ex solid; /* match border bottom */ + transform: translate(0.48em, -0.15em); + border-radius: 70%; + clip-path: inset(0.1em 0 0 0.45em); + box-sizing: border-box; + margin-left: -0.85em; + margin-right: 0.75em; +} + +mtd.precedes-separator { + padding-right: 0 !important; /* override an inline style */ +} + +mtd.separator { + padding-left: 0 !important; /* override an inline style */ + padding-right: 0 !important; /* override an inline style */ +} + +.carry { + font-size: 60%; + line-height: 90%; + width: 1px; + overflow: visible; +} + +.hidden-digit { + visibility: hidden; +} + +.crossout-horiz, .crossout-vert, .crossout-up, .crossout-down{ + position: relative; + display: inline-block; +} +.crossout-horiz:before { + content: ''; + border-bottom: .3ex solid black; + width: 140%; + position: absolute; + right: -20%; + top: 40%; +} + +.crossout-vert::before { + content: ''; + border-left: .3ex solid black; + height: 100%; + position: absolute; + right: 35%; + top: 0%; +} + +.crossout-up::before { + content: ''; + width: 100%; + position: absolute; + right: 0; + top: 40%; +} +.crossout-up::before { + border-bottom: .2em solid black; + transform: skewY(-60deg); +} + +.crossout-down::after { + content: ''; + width: 100%; + position: absolute; + right: 0; + top: 40%; +} +.crossout-down::after { + border-bottom: .2em solid black; + transform: skewY(60deg); +} +`),t.add(`mlongdiv`,P),customElements.define(`m-elem-math`,class extends HTMLElement{constructor(){super();let e=new N(this.children[0]).expandMStackElement(this.children[0]),n=this.attachShadow({mode:`open`});n.appendChild(t.getCSSStyleSheet()),n.appendChild(e)}});const F=`data-has-linebreaks`,I=`data-max-linebreak-width`,L=`data-saved-indent-attrs`,R=`data-x-indent`,z=`data-nesting-depth`,B=[`msub`,`msub`,`msubsup`,`mover`,`munder`,`munderover`,`mfrac`,`mmultiscripts`];var Ee=(function(){var e=null,t=0,n=0;return{set:function(r,i,a){e=r,t=i,n=a},get:function(){return e},getEmInPixels:function(){return t},getBreakWidth:function(){return n}}})();function De(e){return Ee.get().getElementById(e)||document.getElementById(e)}function V(t){return document.createElementNS(e,t)}function Oe(e,t){let n=t.attributes;for(let t=0;t0;)e.removeAttributeNode(e.attributes[0]);return e.parentElement.parentElement.insertBefore(r,e.parentElement),e.parentElement}else{let e=ke();return e.setAttribute(`style`,`width: 100%`),e.appendChild(r),n.replaceWith(e),e.appendChild(je(n)),e.lastElementChild}}function Pe(e){let t=H(e,`linebreakstyle`,`before`);t===`infixLineBreakStyle`&&(t=H(e,`infixLineBreakStyle`,`before`));let n=null,r=e;e.previousElementSibling!==null&&e.nextElementSibling!==null&&e.setAttribute(`form`,`infix`);let i=r.parentElement;for(;i.tagName===`mrow`;i=i.parentElement){let e=V(`mrow`);for(;i.firstElementChild;){let n=i.firstElementChild;if(n===r){if(t===`after`){e.appendChild(n),t=`before`;break}else t===`duplicate`&&(t=`before`,e.appendChild(n.cloneNode(!0)));break}e.appendChild(n)}r=i,n&&e.appendChild(n),n=e.children.length===1?e.firstElementChild:e}if(r.tagName===`mrow`&&r.children.length===1){let e=r.firstElementChild;r.replaceWith(e),r=e}return Ne(i,n,r)}function Fe(e){let t=e,n=e.parentElement;for(;n.tagName===`mrow`||n.tagName===`mstyle`||n.tagName===`mpadded`;)t=n,n=n.parentElement;return t}function Ie(e,t){let n=e.querySelectorAll(`mo[linebreak="newline"]`);if(n.length===0)return;let r=null;n.forEach(e=>{r=Pe(e)});let i=r.parentElement.children;U(i[0].firstElementChild,i[0].firstElementChild);for(let e=0;e0;)e=e.firstElementChild;return e.tagName==`mspace`?e.nextElementSibling:e}function ze(e,t,n){return t-e<=.5*n}function Be(e,t){return t===`-`&&(t=`+`),e.filter(function(e){let n=e.textContent.trim();return n===`-`&&(n=`+`),t===n})}function Ve(e){if(Le(e.parentElement))return 0;let t=Re(e);t.hasAttribute(z)||console.log(`Linebreaking error: depth not set on ${t.tagName} with content '${t.textContent.trim()}'`);let n=t.getAttribute(z),i=t.textContent.trim(),a=1e21,o=!1,s=e.getBoundingClientRect().left,c=parseFloat(e.parentElement.parentElement.getAttribute(I)),l=e.parentElement.previousElementSibling;for(;l;){let e=Ge(l.firstElementChild).filter(e=>n===e.getAttribute(z));e.length===0||e[0].textContent.trim();let t=Be(e,i),r=t.length===0?a:t[0].getBoundingClientRect().left;ze(s,r,c)&&(r{do e=e.parentElement;while(e.tagName===`mrow`||e.tagName===`mstyle`||e.tagName===`mpadded`);return e.tagName===`math`||Ae(e)}).map(e=>We(e))}const Ke={"(":0,")":0,"=":10,"+":30,"±":30,"-":30,"*":40,"×":40,InvisibleTimes:40,InvisibleFunctionApply:50},qe=[`(`,`[`,`{`],Je=[`)`,`]`,`}`];function G(e){let t=Ke[e];return t===void 0?40:t}function Ye(e){if(e.tagName===`mo`)return e;let t=e;for(;B.includes(t.tagName);)if(t=t.firstElementChild,!t)return e;return t.tagName===`mo`?t:e}function K(e){return e[e.length-1]}function Xe(e){return Array.isArray(e)}function Ze(e){return qe.includes(e)}function q(e,t,n){let r=K(e),i=r+1;for(;n{Array.isArray(e)?tt(e,t+1):e.tagName===`mo`&&e.setAttribute(z,t.toString())})}function J(e){if(e.childElementCount<=3)return!0;if(e.childElementCount%2==0)return!1;let t=G(e.children[1].textContent.trim());for(let n=0;n{let n=Fe(e);if(!t.includes(n))if(t.push(n),nt(n))rt(n,0);else{let[e,t]=et(n,[-1],[null]);t.length!=2&&([e,t]=q(e,t,-1)),tt(t[1],0)}})}function at(e,t){let n=(.9*t-e)/t;return n*n}function ot(e){let t=[.05,.090909,.173554,.248685,.316987,.379079,.435526,.486842,.533493,.575902,.614457,.649506,.681369,.710336,.736669,.760608];e.hasAttribute(z)||console.log(`Linebreaking error: depth not set on ${e.tagName} with content '${e.textContent.trim()}'`);let n=parseInt(e.getAttribute(z));return n>=t.length?1-3.482066/n:t[n]}function st(e,t,n){let r=3*ot(e)+at(t,n),i=H(e,`linebreak`,`auto`);return i===`goodbreak`?r/3:i===`badbreak`?3*r:r}function ct(e,t){let n=e[t];if(n.textContent.trim()===`⁢`){let r=H(n,`linebreakmultchar`,`⁢`);if(r!==`⁢`){let i=V(`mo`);return i.textContent=r,Oe(i,n),n.replaceWith(i),e[t]=i,i}}return n}function lt(e,t){if(parseFloat(e.getAttribute(Z))<=t)return;let n=Ge(e),r,i=e.tagName===`mtd`?e.parentElement:e.parentNode,a=0,o=1;for(;od)break;let t=st(n[s],e,d);t<=f&&(f=t,p=s),s++}if(p===-1&&(console.log(`Linebreaking error: no breakpoint found on line ${a+1}`),p=o),a++,o=p+1,o0&&W(i.firstElementChild)}const Y=`math-with-linebreaks`;function X(e,t){t=Math.min(t,parseFloat(e.getAttribute(Z)));let n=e.shadowRoot.lastElementChild;if(n.childElementCount>1){let e=V(`mrow`);for(;n.firstElementChild;)e.appendChild(n.firstElementChild);n.appendChild(e)}Ee.set(e.shadowRoot),Ie(n,t);let r=Array.from(n.querySelectorAll(`mtable[${F}]`));r.length>0?r.forEach(e=>{e.setAttribute(I,t.toString()),Array.from(e.children).forEach(e=>{let n=e.firstElementChild;W(n),n.firstElementChild.getBoundingClientRect().right-n.getBoundingClientRect().left>t&<(n,t)})}):parseInt(e.getAttribute(Z))>=t&&(n.setAttribute(L,JSON.stringify(Me(n,`first`))),lt(n,t))}const Z=`data-full-width`,Q=`data-linebreak-width`;function $(e,t){let i=n(t);e.shadowRoot.appendChild(i);let a=i.lastElementChild.getBoundingClientRect().right-i.firstElementChild.getBoundingClientRect().left;i.hasAttribute(`maxwidth`)&&(a=Math.min(a,r(i,i.getAttribute(`maxwidth`)))),e.setAttribute(Z,a.toString()),X(e,a),e.setAttribute(Q,(2*a).toString())}function ut(e){let t=getComputedStyle(e).getPropertyValue(`display`),n=e.hasAttribute(`display`)?e.getAttribute(`display`):`inline`;if(t===`inline`||n===`inline`)return null;if(e.tagName.toLowerCase()===Y||e.parentElement.tagName.toLowerCase()===Y)return e;{let t=e.parentElement,n=e.nextElementSibling,r=document.createElement(Y);return r.appendChild(e),t.insertBefore(r,n),it(e),$(r,e),null}}t.add(`math`,ut);const dt=new ResizeObserver(e=>{for(let t of e)if(t.target.tagName.toLowerCase()===Y){let e=t.target;if(t.contentRect.width(e.namespaceURI==`http://www.w3.org/1998/Math/MathML`&&(e.style.cursor=`pointer`,e.tabIndex=0,e.setAttribute(`role`,`link`),e.addEventListener(`click`,e=>{document.location=e.currentTarget.getAttribute(`href`)}),e.addEventListener(`keydown`,e=>{e.key==`Enter`&&(document.location=e.currentTarget.getAttribute(`href`))}),e.addEventListener(`mouseover`,e=>{e.currentTarget.style.textDecoration=`solid underline`}),e.addEventListener(`mouseout`,e=>{e.currentTarget.style.textDecoration=``})),e));export{t as _MathTransforms}; \ No newline at end of file From 68a648935926c6e351a26cb0cd929962ae2fbc1f Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 20 Apr 2026 12:19:48 +1200 Subject: [PATCH 22/46] Update mathml-polyfills from https to local bundle --- src/utils/mathTransformer.test.ts | 4 ++-- src/utils/mathTransformer.ts | 2 +- vitest.setup.ts | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index 1a19bf34..557b2e48 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -16,7 +16,7 @@ import { formatMathMLTable, initMathPolyfills, transformMathString } from '@/uti */ // Mock the dynamic import. -vi.mock('https://w3c.github.io/mathml-polyfills/all-polyfills.js', () => ({ +vi.mock('../vendor/mathml-polyfills/all-polyfills-bundle.js', () => ({ _MathTransforms: { getCSSStyleSheet: () => { const style = document.createElement('style') @@ -56,7 +56,7 @@ describe('initMathPolyfills', () => { it('does not throw when the polyfill module fails to load', async () => { // Mock a failed import to test graceful error handling. - vi.doMock('https://w3c.github.io/mathml-polyfills/all-polyfills.js', () => { + vi.doMock('../vendor/mathml-polyfills/all-polyfills-bundle.js', () => { throw new Error('Module load failed') }) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 46621ad8..3c6bc2b4 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -1,4 +1,4 @@ -const MATH_POLYFILLS_MODULE_URL = 'https://w3c.github.io/mathml-polyfills/all-polyfills.js' +const MATH_POLYFILLS_MODULE_URL = '../vendor/mathml-polyfills/all-polyfills-bundle.js' type MathTransformsModule = { _MathTransforms?: { diff --git a/vitest.setup.ts b/vitest.setup.ts index 7528e4d6..7f988430 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,5 +1,20 @@ import { vi } from 'vitest' +// jsdom does not provide ResizeObserver, but the vendored MathML polyfills +// instantiate it at module load time. +if (!globalThis.ResizeObserver) { + class MockResizeObserver implements ResizeObserver { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + } + + Object.defineProperty(globalThis, 'ResizeObserver', { + writable: true, + value: MockResizeObserver, + }) +} + // Check if cookieStore exists (to avoid overwriting if a polyfill is added later). if (!globalThis.cookieStore) { Object.defineProperty(globalThis, 'cookieStore', { From 5e9c43e801a1663300cd84f9815b54a8c0db5406 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 20 Apr 2026 12:20:43 +1200 Subject: [PATCH 23/46] Add a readme file for mathml-polyfills vendor --- src/vendor/mathml-polyfills/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/vendor/mathml-polyfills/README.md diff --git a/src/vendor/mathml-polyfills/README.md b/src/vendor/mathml-polyfills/README.md new file mode 100644 index 00000000..3fbf8783 --- /dev/null +++ b/src/vendor/mathml-polyfills/README.md @@ -0,0 +1,27 @@ +# MathML polyfills + +This directory contains vendored MathML polyfill bundle files. + +## Source + +- Upstream repository: https://github.com/w3c/mathml-polyfills +- Fork used to generate the bundle in this project: https://github.com/akhuoa/mathml-polyfills/tree/rolldown + +The upstream `w3c/mathml-polyfills` repository provides polyfills for MathML features using MathML Core and browser-native web technologies. For this project, the files here were generated from the `rolldown` branch in the fork above so the polyfills can be imported locally as an ESM bundle. + +## Vendored files + +- `all-polyfills-bundle.js`: minified ESM bundle +- `all-polyfills-bundle.d.ts`: generated TypeScript declarations for the bundle + +## Update process + +To refresh these vendored files: + +1. Clone or open `https://github.com/akhuoa/mathml-polyfills`. +2. Check out the `rolldown` branch. +3. Install dependencies. +4. Run `npm run build`. +5. Copy `dist/all-polyfills-bundle.js` and `dist/all-polyfills-bundle.d.ts` into this directory. + +If the fork, branch, or build pipeline changes, update this README so future maintainers can trace where these files came from and how they were produced. From 1e0d9d16f46598b3df17c2d4fa8678b907a21300 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 20 Apr 2026 12:34:41 +1200 Subject: [PATCH 24/46] Change mathml-polyfills dynamic import to static import to fix build process --- src/utils/mathTransformer.test.ts | 26 ++++--------- src/utils/mathTransformer.ts | 64 ++++++------------------------- 2 files changed, 20 insertions(+), 70 deletions(-) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index 557b2e48..4eaa4b23 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -5,17 +5,16 @@ import { formatMathMLTable, initMathPolyfills, transformMathString } from '@/uti * Tests for MathML transformation utilities. * * Key behaviours tested: - * 1. CSP-compliant dynamic import of polyfills (no unsafe-eval) - * 2. Polyfill initialisation with idempotency and style injection - * 3. MathML transformation applying polyfill transforms - * 4. Table formatting with proper mtable attributes - * 5. Mismatched fence pair removal (e.g., '(' paired with '>') - * 6. Non-DOM environment graceful degradation (returns input unchanged) - * 7. Equals operator detection and marking for styling - * 8. Structure preservation through transformations + * 1. Polyfill initialisation with idempotency and style injection + * 2. MathML transformation applying polyfill transforms + * 3. Table formatting with proper mtable attributes + * 4. Mismatched fence pair removal (e.g., '(' paired with '>') + * 5. Non-DOM environment graceful degradation (returns input unchanged) + * 6. Equals operator detection and marking for styling + * 7. Structure preservation through transformations */ -// Mock the dynamic import. +// Mock the polyfill import. vi.mock('../vendor/mathml-polyfills/all-polyfills-bundle.js', () => ({ _MathTransforms: { getCSSStyleSheet: () => { @@ -53,15 +52,6 @@ describe('initMathPolyfills', () => { globalThis.document = originalDocument }) - - it('does not throw when the polyfill module fails to load', async () => { - // Mock a failed import to test graceful error handling. - vi.doMock('../vendor/mathml-polyfills/all-polyfills-bundle.js', () => { - throw new Error('Module load failed') - }) - - await expect(initMathPolyfills()).resolves.toBeUndefined() - }) }) describe('transformMathString', () => { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 3c6bc2b4..775ab686 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -1,11 +1,4 @@ -const MATH_POLYFILLS_MODULE_URL = '../vendor/mathml-polyfills/all-polyfills-bundle.js' - -type MathTransformsModule = { - _MathTransforms?: { - getCSSStyleSheet: () => HTMLStyleElement - transform: (container: HTMLElement) => void - } -} +import { _MathTransforms as mathPolyfills } from '../vendor/mathml-polyfills/all-polyfills-bundle.js' let sharedDOMParser: DOMParser | null = null @@ -17,8 +10,6 @@ const getDOMParser = (): DOMParser => { } let isMathPolyfillsInitialized = false -let isMathPolyfillsInitializing = false -let loadedMathTransforms: MathTransformsModule['_MathTransforms'] | null = null const MATH_POLYFILLS_STYLE_ATTR = 'data-math-polyfills' const OPEN_TO_CLOSE_FENCE: Record = { '(': ')', @@ -58,54 +49,23 @@ const fixMismatchedFencePairs = (root: ParentNode) => { }) } -const loadMathTransforms = async () => { - if (loadedMathTransforms) return loadedMathTransforms - if (typeof window === 'undefined') return null - - try { - // Dynamic import() avoids unsafe-eval CSP violations - // while remaining compatible with Node test runners. - const module = await import(MATH_POLYFILLS_MODULE_URL) - loadedMathTransforms = module._MathTransforms || null - } catch (err) { - console.warn('Unable to load MathML polyfills module:', err) - loadedMathTransforms = null - } - - return loadedMathTransforms -} - /** * Injects the necessary CSS for polyfills into the document head. */ export async function initMathPolyfills() { - if ( - typeof document === 'undefined' || - isMathPolyfillsInitialized || - isMathPolyfillsInitializing - ) { - return - } - - isMathPolyfillsInitializing = true - - try { - const existingStyle = document.head.querySelector(`[${MATH_POLYFILLS_STYLE_ATTR}="true"]`) - if (existingStyle) { - isMathPolyfillsInitialized = true - return - } + if (typeof document === 'undefined' || isMathPolyfillsInitialized) return + if (!mathPolyfills) return - const mathTransforms = await loadMathTransforms() - if (!mathTransforms) return - - const style = mathTransforms.getCSSStyleSheet() - style.setAttribute(MATH_POLYFILLS_STYLE_ATTR, 'true') - document.head.appendChild(style) + const existingStyle = document.head.querySelector(`[${MATH_POLYFILLS_STYLE_ATTR}="true"]`) + if (existingStyle) { isMathPolyfillsInitialized = true - } finally { - isMathPolyfillsInitializing = false + return } + + const style = mathPolyfills.getCSSStyleSheet() + style.setAttribute(MATH_POLYFILLS_STYLE_ATTR, 'true') + document.head.appendChild(style) + isMathPolyfillsInitialized = true } /** @@ -118,7 +78,7 @@ export function transformMathString(rawMathML: string): string { const container = document.createElement('div') container.innerHTML = rawMathML - loadedMathTransforms?.transform(container) + mathPolyfills?.transform(container) return container.innerHTML } From 14445bb2e9b720aab8163fba911ed2aee781de83 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 20 Apr 2026 12:39:18 +1200 Subject: [PATCH 25/46] Fix unit test for mathml transform --- src/components/organisms/ExposureDetail.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/organisms/ExposureDetail.test.ts b/src/components/organisms/ExposureDetail.test.ts index ee1f2afb..ca99fa80 100644 --- a/src/components/organisms/ExposureDetail.test.ts +++ b/src/components/organisms/ExposureDetail.test.ts @@ -21,6 +21,13 @@ vi.mock('@/utils/analytics', () => ({ trackButtonClick: vi.fn(), })) +// Mock MathML transformer to avoid loading the vendored polyfill bundle in tests. +vi.mock('@/utils/mathTransformer', () => ({ + initMathPolyfills: vi.fn().mockResolvedValue(undefined), + transformMathString: (s: string) => s, + formatMathMLTable: (s: string) => s, +})) + describe('ExposureDetail', () => { let exposureStore: ReturnType let searchStore: ReturnType From b0b27c5799e0f617dc8ccc09c8d3c427c807b034 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 20 Apr 2026 12:47:08 +1200 Subject: [PATCH 26/46] Fix formatting and exclude vendor folder --- biome.json | 3 +++ src/components/molecules/UserDropdown.vue | 2 +- src/components/organisms/ExposureDetail.vue | 10 ++++------ src/components/organisms/Login.vue | 2 +- src/services/authService.ts | 3 ++- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/biome.json b/biome.json index f2cd2c39..5efefd41 100644 --- a/biome.json +++ b/biome.json @@ -47,6 +47,9 @@ "includes": ["*.json", "!package.json"] } ], + "files": { + "includes": ["**", "!src/vendor/**"] + }, "vcs": { "clientKind": "git", "enabled": true, diff --git a/src/components/molecules/UserDropdown.vue b/src/components/molecules/UserDropdown.vue index 9ffbc85d..d17e1b70 100644 --- a/src/components/molecules/UserDropdown.vue +++ b/src/components/molecules/UserDropdown.vue @@ -18,7 +18,7 @@ const showLogoutConfirm = ref(false) const menuId = 'user-dropdown-menu' const buttonLabel = computed(() => - authStore.username ? `${authStore.username} – user menu` : 'User menu' + authStore.username ? `${authStore.username} – user menu` : 'User menu', ) const toggleDropdown = () => { diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 6fde86e6..10fb76a5 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -263,12 +263,10 @@ const generateMath = async () => { (value): value is [string, string[]] => Array.isArray(value[1]) && value[1].length > 0, ) : [] - const transformedMathsJSON = filteredMathsJSON.map( - (entry): [string, string[]] => { - const mathMLArray = entry[1].map((mathML) => formatMathMLTable(transformMathString(mathML))) - return [entry[0], mathMLArray] - }, - ) + const transformedMathsJSON = filteredMathsJSON.map((entry): [string, string[]] => { + const mathMLArray = entry[1].map((mathML) => formatMathMLTable(transformMathString(mathML))) + return [entry[0], mathMLArray] + }) mathsJSON.value = transformedMathsJSON } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to parse mathematics data.' diff --git a/src/components/organisms/Login.vue b/src/components/organisms/Login.vue index d2839a6b..305cf744 100644 --- a/src/components/organisms/Login.vue +++ b/src/components/organisms/Login.vue @@ -52,7 +52,7 @@ watch( focusUsernameInput() globalStateStore.consumeLoginUsernameFocus() - } + }, ) watch(error, (newError) => { diff --git a/src/services/authService.ts b/src/services/authService.ts index 220a45e9..2219d183 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -57,7 +57,8 @@ const mapLoginErrorMessage = (errorText: string, status: number): string => { } // If backend returns machine-style codes, avoid exposing raw text. - const looksLikeMachineCode = /^[a-z0-9_-]+$/i.test(normalisedText) || /^[A-Z][a-zA-Z0-9]+$/.test(normalisedText) + const looksLikeMachineCode = + /^[a-z0-9_-]+$/i.test(normalisedText) || /^[A-Z][a-zA-Z0-9]+$/.test(normalisedText) if (looksLikeMachineCode) { return getLoginErrorMessageByStatus(status) } From 5b4e27b586a8f1fdc36744724d721be6f8151a3d Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 20 Apr 2026 12:50:14 +1200 Subject: [PATCH 27/46] Fix linting --- biome.json | 2 +- src/services/authService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/biome.json b/biome.json index 5efefd41..66dd6597 100644 --- a/biome.json +++ b/biome.json @@ -48,7 +48,7 @@ } ], "files": { - "includes": ["**", "!src/vendor/**"] + "includes": ["**", "!src/vendor"] }, "vcs": { "clientKind": "git", diff --git a/src/services/authService.ts b/src/services/authService.ts index 2219d183..0100892a 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -12,7 +12,7 @@ const LOGIN_ERROR_MESSAGES = { const normaliseErrorText = (errorText: string): string => { const trimmed = errorText.trim() - return trimmed.replace(/^['\"]|['\"]$/g, '') + return trimmed.replace(/^['"]|['"]$/g, '') } const getKnownLoginErrorMessage = (key: string): string | undefined => { From 0aaa7b68496f2d13dccb6b4f73a7abfd20f4e6ee Mon Sep 17 00:00:00 2001 From: akhuoa Date: Mon, 20 Apr 2026 12:51:00 +1200 Subject: [PATCH 28/46] Format --- biome.json | 6 +++--- src/components/organisms/Login.vue | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/biome.json b/biome.json index 66dd6597..008474d3 100644 --- a/biome.json +++ b/biome.json @@ -5,6 +5,9 @@ "tailwindDirectives": true } }, + "files": { + "includes": ["**", "!src/vendor"] + }, "formatter": { "indentStyle": "space", "lineWidth": 100, @@ -47,9 +50,6 @@ "includes": ["*.json", "!package.json"] } ], - "files": { - "includes": ["**", "!src/vendor"] - }, "vcs": { "clientKind": "git", "enabled": true, diff --git a/src/components/organisms/Login.vue b/src/components/organisms/Login.vue index 305cf744..29db9518 100644 --- a/src/components/organisms/Login.vue +++ b/src/components/organisms/Login.vue @@ -3,9 +3,9 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRouter } from 'vue-router' import ActionButton from '@/components/atoms/ActionButton.vue' +import CloseButton from '@/components/atoms/CloseButton.vue' import { getAuthService } from '@/services' import { useAuthStore } from '@/stores/auth' -import CloseButton from '@/components/atoms/CloseButton.vue' import { useGlobalStateStore } from '@/stores/globalState' const router = useRouter() From e35d2ebab53274f4c7647b33bd393090b395aef1 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Tue, 21 Apr 2026 16:08:21 +1200 Subject: [PATCH 29/46] Remove unnecessary global resizeObserver mock used by vendor as vendor folder is ignored from tests --- vitest.setup.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/vitest.setup.ts b/vitest.setup.ts index 7f988430..7528e4d6 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,20 +1,5 @@ import { vi } from 'vitest' -// jsdom does not provide ResizeObserver, but the vendored MathML polyfills -// instantiate it at module load time. -if (!globalThis.ResizeObserver) { - class MockResizeObserver implements ResizeObserver { - observe = vi.fn() - unobserve = vi.fn() - disconnect = vi.fn() - } - - Object.defineProperty(globalThis, 'ResizeObserver', { - writable: true, - value: MockResizeObserver, - }) -} - // Check if cookieStore exists (to avoid overwriting if a polyfill is added later). if (!globalThis.cookieStore) { Object.defineProperty(globalThis, 'cookieStore', { From b975a0a39f1f8885cd0f5eb580aa1c3bf495b280 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Tue, 21 Apr 2026 16:36:17 +1200 Subject: [PATCH 30/46] Replace invisible times with visible dots --- src/utils/mathTransformer.test.ts | 19 +++++++++++++++++++ src/utils/mathTransformer.ts | 11 +++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index 4eaa4b23..1f324828 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -95,6 +95,16 @@ describe('transformMathString', () => { const result = transformMathString('') expect(result).toBe('') }) + + it('replaces invisible times separators with multiplication dots', () => { + const equationWithInvisibleTimes = `ab` + + const result = transformMathString(equationWithInvisibleTimes) + + expect(result).toContain('·') + expect(result).not.toContain('⁢') + expect(result).not.toContain('\u2062') + }) }) describe('formatMathMLTable', () => { @@ -207,6 +217,15 @@ describe('formatMathMLTable', () => { expect(result).toContain('result') expect(result).toContain('data-math-operator="equals"') }) + + it('normalizes invisible times separators in formatted output', () => { + const equationWithInvisibleTimes = `a\u2062b` + + const result = formatMathMLTable(equationWithInvisibleTimes) + + expect(result).toContain('·') + expect(result).not.toContain('\u2062') + }) }) describe('mathTransformer integration', () => { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 775ab686..7b80bd97 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -20,6 +20,8 @@ const OPEN_TO_CLOSE_FENCE: Record = { '⌊': '⌋', '⌈': '⌉', } +const INVISIBLE_TIMES_CHAR = '\u2062' +const VISIBLE_MULTIPLICATION_DOT = '·' const isFenceOperator = (element: Element): element is HTMLElement => element.tagName.toLowerCase() === 'mo' && element.getAttribute('fence') === 'true' @@ -49,6 +51,11 @@ const fixMismatchedFencePairs = (root: ParentNode) => { }) } +const normalizeInvisibleTimesSeparators = (mathml: string): string => + mathml + .replaceAll(INVISIBLE_TIMES_CHAR, VISIBLE_MULTIPLICATION_DOT) + .replaceAll(/⁢|⁢|⁢/gi, VISIBLE_MULTIPLICATION_DOT) + /** * Injects the necessary CSS for polyfills into the document head. */ @@ -80,7 +87,7 @@ export function transformMathString(rawMathML: string): string { mathPolyfills?.transform(container) - return container.innerHTML + return normalizeInvisibleTimesSeparators(container.innerHTML) } /** @@ -133,5 +140,5 @@ export const formatMathMLTable = (rawMathML: string): string => { } }) - return doc.body.innerHTML + return normalizeInvisibleTimesSeparators(doc.body.innerHTML) } From afba3e46fbaf8344199f5b5c8c5388c681cf4a41 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Tue, 21 Apr 2026 17:21:20 +1200 Subject: [PATCH 31/46] Align the conditions in math equations --- src/components/organisms/ExposureDetail.vue | 18 +++++ src/utils/mathTransformer.test.ts | 46 +++++++++++++ src/utils/mathTransformer.ts | 74 +++++++++++++++++++++ 3 files changed, 138 insertions(+) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 10fb76a5..f71d6b62 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -953,5 +953,23 @@ onMounted(async () => { text-align: left; padding-left: 0.5em; } + + & :deep(mtable[data-math-piecewise='true']) { + border-spacing: 0.5em 0.35em; + } + + & :deep(mtable[data-math-piecewise='true'] > mtr > mtd[data-math-piecewise='expression']) { + white-space: nowrap; + } + + & :deep(mtable[data-math-piecewise='true'] > mtr > mtd[data-math-piecewise='keyword']) { + white-space: nowrap; + padding-left: 0.15em; + padding-right: 0.35em; + } + + & :deep(mtable[data-math-piecewise='true'] > mtr > mtd[data-math-piecewise='condition']) { + white-space: nowrap; + } } diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index 1f324828..c0616dbc 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -147,6 +147,40 @@ describe('formatMathMLTable', () => { ` + const piecewiseEquation = ` + + GKr + = + + + + GKr_b·1.3 + + if + + celltype=1 + + + + + GKr_b·0.8 + + if + + celltype=2 + + + + + GKr_b + + otherwise + + + + + ` + it('wraps multiple rows in an mtable', () => { const result = formatMathMLTable(multiRowEquation) @@ -226,6 +260,18 @@ describe('formatMathMLTable', () => { expect(result).toContain('·') expect(result).not.toContain('\u2062') }) + + it('splits piecewise inner tables into aligned expression, keyword, and condition columns', () => { + const result = formatMathMLTable(piecewiseEquation) + + expect(result).toContain('data-math-piecewise="true"') + expect(result).toContain('data-math-piecewise="expression"') + expect(result).toContain('data-math-piecewise="keyword"') + expect(result).toContain('data-math-piecewise="condition"') + expect(result).toContain('data-math-piecewise-keyword="if"') + expect(result).toContain('data-math-piecewise-keyword="otherwise"') + expect(result).toContain('columnalign="right left left"') + }) }) describe('mathTransformer integration', () => { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 7b80bd97..474e2ffd 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -22,6 +22,7 @@ const OPEN_TO_CLOSE_FENCE: Record = { } const INVISIBLE_TIMES_CHAR = '\u2062' const VISIBLE_MULTIPLICATION_DOT = '·' +const PIECEWISE_KEYWORDS = new Set(['if', 'otherwise']) const isFenceOperator = (element: Element): element is HTMLElement => element.tagName.toLowerCase() === 'mo' && element.getAttribute('fence') === 'true' @@ -56,6 +57,77 @@ const normalizeInvisibleTimesSeparators = (mathml: string): string => .replaceAll(INVISIBLE_TIMES_CHAR, VISIBLE_MULTIPLICATION_DOT) .replaceAll(/⁢|⁢|⁢/gi, VISIBLE_MULTIPLICATION_DOT) +const isMathMLElement = (node: Node, tagName: string): node is Element => + node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === tagName + +const normalizePiecewiseTables = (root: ParentNode, doc: Document, namespace: string) => { + const mathTables = Array.from(root.querySelectorAll('mtable')) + + mathTables.forEach((table) => { + const rows = Array.from(table.children).filter((child) => child.tagName.toLowerCase() === 'mtr') + if (!rows.length) return + + let hasPiecewiseRows = false + + rows.forEach((row) => { + const rowCells = Array.from(row.children).filter((child) => child.tagName.toLowerCase() === 'mtd') + if (rowCells.length !== 1) return + + const [singleCell] = rowCells + if (!singleCell) return + + const cellNodes = Array.from(singleCell.childNodes) + const keywordIndex = cellNodes.findIndex( + (node) => + isMathMLElement(node, 'mtext') && + PIECEWISE_KEYWORDS.has((node.textContent || '').trim().toLowerCase()), + ) + + if (keywordIndex <= 0) return + + hasPiecewiseRows = true + + const expressionCell = doc.createElementNS(namespace, 'mtd') + expressionCell.setAttribute('data-math-piecewise', 'expression') + + cellNodes.slice(0, keywordIndex).forEach((node) => { + expressionCell.appendChild(node) + }) + + const keywordCell = doc.createElementNS(namespace, 'mtd') + keywordCell.setAttribute('data-math-piecewise', 'keyword') + + const keywordNode = cellNodes[keywordIndex] + const keywordText = (keywordNode?.textContent || '').trim().toLowerCase() + if (keywordText) { + keywordCell.setAttribute('data-math-piecewise-keyword', keywordText) + } + if (keywordNode) { + keywordCell.appendChild(keywordNode) + } + + const conditionCell = doc.createElementNS(namespace, 'mtd') + conditionCell.setAttribute('data-math-piecewise', 'condition') + cellNodes.slice(keywordIndex + 1).forEach((node) => { + conditionCell.appendChild(node) + }) + + while (row.firstChild) { + row.removeChild(row.firstChild) + } + + row.appendChild(expressionCell) + row.appendChild(keywordCell) + row.appendChild(conditionCell) + }) + + if (hasPiecewiseRows) { + table.setAttribute('data-math-piecewise', 'true') + table.setAttribute('columnalign', 'right left left') + } + }) +} + /** * Injects the necessary CSS for polyfills into the document head. */ @@ -138,6 +210,8 @@ export const formatMathMLTable = (rawMathML: string): string => { math.appendChild(mtable) } + + normalizePiecewiseTables(math, doc, NS) }) return normalizeInvisibleTimesSeparators(doc.body.innerHTML) From 4a5cccab22384377f956ac7caa1ea11fb9c73a1d Mon Sep 17 00:00:00 2001 From: akhuoa Date: Tue, 21 Apr 2026 17:25:15 +1200 Subject: [PATCH 32/46] Update math base font size --- src/components/organisms/ExposureDetail.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index f71d6b62..dcb264d4 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -919,7 +919,7 @@ onMounted(async () => { } .math-view { - @apply p-2 text-center text-sm overflow-auto; + @apply p-2 text-center text-base overflow-auto; & :deep(math > mtable) { border-spacing: 0.5em 0.75em; From c1d39a0d75759ae92874b6c013b41e9016e2d51c Mon Sep 17 00:00:00 2001 From: akhuoa Date: Tue, 21 Apr 2026 17:33:29 +1200 Subject: [PATCH 33/46] Make math font sizes in different levels bigger --- src/components/organisms/ExposureDetail.vue | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index dcb264d4..2dcae644 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -933,6 +933,15 @@ onMounted(async () => { padding-right: 0.05em; } + & :deep(math mfrac > :first-child), + & :deep(math mfrac > :nth-child(2)) { + font-size: 0.95em; + } + + & :deep(math :is(msub, msup, msubsup, mmultiscripts, munder, mover, munderover) > :not(:first-child)) { + font-size: 0.95em; + } + & :deep(math > mtable > mtr + mtr > mtd) { padding-top: 0.5em; } From b3b1fa8fe7d2a8895a6166061b4ed385a54b19c3 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Tue, 21 Apr 2026 17:37:46 +1200 Subject: [PATCH 34/46] Add vertical spacing between the fraction bar and content --- src/components/organisms/ExposureDetail.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 2dcae644..849bc626 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -938,6 +938,14 @@ onMounted(async () => { font-size: 0.95em; } + & :deep(math mfrac > :first-child) { + padding-bottom: 0.14em; + } + + & :deep(math mfrac > :nth-child(2)) { + padding-top: 0.14em; + } + & :deep(math :is(msub, msup, msubsup, mmultiscripts, munder, mover, munderover) > :not(:first-child)) { font-size: 0.95em; } From 58bda98694f6041020c38ac8e6650d9d3f029007 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 11:43:57 +1200 Subject: [PATCH 35/46] Make all variables in mathml italic --- src/components/organisms/ExposureDetail.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 849bc626..dc859997 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -933,6 +933,10 @@ onMounted(async () => { padding-right: 0.05em; } + & :deep(math mi) { + font-style: italic; + } + & :deep(math mfrac > :first-child), & :deep(math mfrac > :nth-child(2)) { font-size: 0.95em; From 3a125c255d19a806a9f07cd949b16cb25c659db4 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 12:17:55 +1200 Subject: [PATCH 36/46] Keep first-level fraction content at parent math font size --- src/components/organisms/ExposureDetail.vue | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index dc859997..e074e1e4 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -937,9 +937,10 @@ onMounted(async () => { font-style: italic; } + /* Keep first-level fraction content at the parent math font size. */ & :deep(math mfrac > :first-child), & :deep(math mfrac > :nth-child(2)) { - font-size: 0.95em; + font-size: 1em; } & :deep(math mfrac > :first-child) { @@ -950,10 +951,6 @@ onMounted(async () => { padding-top: 0.14em; } - & :deep(math :is(msub, msup, msubsup, mmultiscripts, munder, mover, munderover) > :not(:first-child)) { - font-size: 0.95em; - } - & :deep(math > mtable > mtr + mtr > mtd) { padding-top: 0.5em; } From 17d641a88036ff7ccf09586d90e756ea7f63901e Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 12:18:37 +1200 Subject: [PATCH 37/46] Align left to math keywords --- src/components/organisms/ExposureDetail.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index e074e1e4..12f40606 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -981,6 +981,7 @@ onMounted(async () => { } & :deep(mtable[data-math-piecewise='true'] > mtr > mtd[data-math-piecewise='keyword']) { + text-align: left; white-space: nowrap; padding-left: 0.15em; padding-right: 0.35em; From 958d6f7e1cf943f4fb7d2c4f8e0e35d4d436d6c3 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 12:52:42 +1200 Subject: [PATCH 38/46] Convert underscore-delimited identifiers into subscripts --- src/utils/mathTransformer.test.ts | 16 +++++++++++ src/utils/mathTransformer.ts | 45 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index c0616dbc..38948d6a 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -272,6 +272,22 @@ describe('formatMathMLTable', () => { expect(result).toContain('data-math-piecewise-keyword="otherwise"') expect(result).toContain('columnalign="right left left"') }) + + it('converts underscore-delimited identifiers into nested subscripts', () => { + const underscoredIdentifierEquation = ` + + i_Stim_Amplitude + + ` + + const result = formatMathMLTable(underscoredIdentifierEquation) + + const subscriptMatches = result.match(/i') + expect(result).toContain('Stim') + expect(result).toContain('Amplitude') + }) }) describe('mathTransformer integration', () => { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 474e2ffd..9df592ea 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -57,6 +57,50 @@ const normalizeInvisibleTimesSeparators = (mathml: string): string => .replaceAll(INVISIBLE_TIMES_CHAR, VISIBLE_MULTIPLICATION_DOT) .replaceAll(/⁢|⁢|⁢/gi, VISIBLE_MULTIPLICATION_DOT) +const copyElementAttributes = (from: Element, to: Element) => { + Array.from(from.attributes).forEach((attribute) => { + to.setAttribute(attribute.name, attribute.value) + }) +} + +const normalizeUnderscoreIdentifiers = (root: ParentNode) => { + const identifiers = Array.from(root.querySelectorAll('mi')) + + identifiers.forEach((identifier) => { + if (identifier.children.length > 0) return + + const rawText = (identifier.textContent || '').trim() + if (!rawText.includes('_')) return + + const parts = rawText.split('_').map((part) => part.trim()).filter(Boolean) + if (parts.length < 2) return + + const doc = identifier.ownerDocument + const namespace = identifier.namespaceURI || 'http://www.w3.org/1998/Math/MathML' + const createIdentifier = (text: string) => { + const mi = doc.createElementNS(namespace, 'mi') + mi.textContent = text + return mi + } + + const baseIdentifier = createIdentifier(parts[0] || '') + copyElementAttributes(identifier, baseIdentifier) + + let current: Element = doc.createElementNS(namespace, 'msub') + current.appendChild(baseIdentifier) + current.appendChild(createIdentifier(parts[1] || '')) + + parts.slice(2).forEach((part) => { + const next = doc.createElementNS(namespace, 'msub') + next.appendChild(current) + next.appendChild(createIdentifier(part)) + current = next + }) + + identifier.replaceWith(current) + }) +} + const isMathMLElement = (node: Node, tagName: string): node is Element => node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === tagName @@ -177,6 +221,7 @@ export const formatMathMLTable = (rawMathML: string): string => { mathBlocks.forEach((math) => { fixMismatchedFencePairs(math) + normalizeUnderscoreIdentifiers(math) const rows = Array.from(math.children).filter((child) => child.tagName.toLowerCase() === 'mrow') From 65b1ff6ea0f0fd5c222690d52baf6d351a3f93a1 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 13:10:04 +1200 Subject: [PATCH 39/46] Replace logical operator symbols with text labels --- src/utils/mathTransformer.test.ts | 22 ++++++++++++++++++++++ src/utils/mathTransformer.ts | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index 38948d6a..44b8d1fb 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -288,6 +288,28 @@ describe('formatMathMLTable', () => { expect(result).toContain('Stim') expect(result).toContain('Amplitude') }) + + it('replaces logical operator symbols with text labels', () => { + const logicalOperatorsEquation = ` + + ab + c + d + ¬e + + ` + + const result = formatMathMLTable(logicalOperatorsEquation) + + expect(result).toContain('and') + expect(result).toContain('or') + expect(result).toContain('xor') + expect(result).toContain('not') + expect(result).not.toContain('∧') + expect(result).not.toContain('∨') + expect(result).not.toContain('⊻') + expect(result).not.toContain('¬') + }) }) describe('mathTransformer integration', () => { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 9df592ea..33082fab 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -23,6 +23,13 @@ const OPEN_TO_CLOSE_FENCE: Record = { const INVISIBLE_TIMES_CHAR = '\u2062' const VISIBLE_MULTIPLICATION_DOT = '·' const PIECEWISE_KEYWORDS = new Set(['if', 'otherwise']) +const LOGICAL_OPERATOR_LABELS: Record = { + '∧': 'and', + '∨': 'or', + '⊻': 'xor', + '⊕': 'xor', + '¬': 'not', +} const isFenceOperator = (element: Element): element is HTMLElement => element.tagName.toLowerCase() === 'mo' && element.getAttribute('fence') === 'true' @@ -101,6 +108,20 @@ const normalizeUnderscoreIdentifiers = (root: ParentNode) => { }) } +const normalizeLogicalOperators = (root: ParentNode) => { + const operators = Array.from(root.querySelectorAll('mo')) + + operators.forEach((operator) => { + if (operator.children.length > 0) return + + const normalizedText = (operator.textContent || '').trim() + const replacement = LOGICAL_OPERATOR_LABELS[normalizedText] + if (!replacement) return + + operator.textContent = replacement + }) +} + const isMathMLElement = (node: Node, tagName: string): node is Element => node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === tagName @@ -222,6 +243,7 @@ export const formatMathMLTable = (rawMathML: string): string => { mathBlocks.forEach((math) => { fixMismatchedFencePairs(math) normalizeUnderscoreIdentifiers(math) + normalizeLogicalOperators(math) const rows = Array.from(math.children).filter((child) => child.tagName.toLowerCase() === 'mrow') From 99511ad4c652dd2ac1faf3e1c0eb548c6c066aec Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 13:41:43 +1200 Subject: [PATCH 40/46] Update font sizes for math sup and sub --- src/components/organisms/ExposureDetail.vue | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/organisms/ExposureDetail.vue b/src/components/organisms/ExposureDetail.vue index 12f40606..55285e9d 100644 --- a/src/components/organisms/ExposureDetail.vue +++ b/src/components/organisms/ExposureDetail.vue @@ -937,6 +937,17 @@ onMounted(async () => { font-style: italic; } + /* Override sup and sub script text size for nested levels. */ + & :deep(math msub > msub > :nth-child(2)), + & :deep(math msup > :nth-child(2)) { + font-size: 0.7rem; + } + + & :deep(math msub > msub + mi), + & :deep(math msup msup > :nth-child(2)) { + font-size: 0.58rem; + } + /* Keep first-level fraction content at the parent math font size. */ & :deep(math mfrac > :first-child), & :deep(math mfrac > :nth-child(2)) { From 18753274026ea779f1305d5e0b56debfd9fefaea Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 13:44:47 +1200 Subject: [PATCH 41/46] Format math numbers --- src/utils/mathTransformer.test.ts | 16 +++++++++++++++- src/utils/mathTransformer.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index 44b8d1fb..c8aaef8f 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -207,7 +207,21 @@ describe('formatMathMLTable', () => { expect(result).toContain('vcell') expect(result).toContain('Ageo') - expect(result).toContain('1000') + expect(result).toContain('1,000') + }) + + it('formats large integer numerals with thousands separators', () => { + const numberFormattingEquation = ` + + 1000+2500000+999 + + ` + + const result = formatMathMLTable(numberFormattingEquation) + + expect(result).toContain('1,000') + expect(result).toContain('2,500,000') + expect(result).toContain('999') }) it('handles MathML containing fenced expressions', () => { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 33082fab..c0710829 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -1,4 +1,5 @@ import { _MathTransforms as mathPolyfills } from '../vendor/mathml-polyfills/all-polyfills-bundle.js' +import { formatNumber } from './format' let sharedDOMParser: DOMParser | null = null @@ -122,6 +123,22 @@ const normalizeLogicalOperators = (root: ParentNode) => { }) } +const normalizeNumericLiterals = (root: ParentNode) => { + const numerals = Array.from(root.querySelectorAll('mn')) + + numerals.forEach((numeral) => { + if (numeral.children.length > 0) return + + const rawText = (numeral.textContent || '').trim() + if (!/^-?\d+$/.test(rawText)) return + + const parsed = Number(rawText) + if (!Number.isInteger(parsed) || Math.abs(parsed) < 1000) return + + numeral.textContent = formatNumber(parsed) + }) +} + const isMathMLElement = (node: Node, tagName: string): node is Element => node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === tagName @@ -244,6 +261,7 @@ export const formatMathMLTable = (rawMathML: string): string => { fixMismatchedFencePairs(math) normalizeUnderscoreIdentifiers(math) normalizeLogicalOperators(math) + normalizeNumericLiterals(math) const rows = Array.from(math.children).filter((child) => child.tagName.toLowerCase() === 'mrow') From 3517a60ac0832153d6be8b9083bbf8ab925c899a Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 15:19:37 +1200 Subject: [PATCH 42/46] Replace exponential function e --- src/utils/mathTransformer.test.ts | 9 +++++++++ src/utils/mathTransformer.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index c8aaef8f..93d20dce 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -105,6 +105,15 @@ describe('transformMathString', () => { expect(result).not.toContain('⁢') expect(result).not.toContain('\u2062') }) + + it('replaces the exponential glyph with plain e', () => { + const equationWithExponentialGlyph = `^x` + + const result = transformMathString(equationWithExponentialGlyph) + + expect(result).toContain('e') + expect(result).not.toContain('ⅇ') + }) }) describe('formatMathMLTable', () => { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index c0710829..4ebe11e7 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -23,6 +23,7 @@ const OPEN_TO_CLOSE_FENCE: Record = { } const INVISIBLE_TIMES_CHAR = '\u2062' const VISIBLE_MULTIPLICATION_DOT = '·' +const EXPONENTIAL_E_CHAR = 'ⅇ' const PIECEWISE_KEYWORDS = new Set(['if', 'otherwise']) const LOGICAL_OPERATOR_LABELS: Record = { '∧': 'and', @@ -63,6 +64,7 @@ const fixMismatchedFencePairs = (root: ParentNode) => { const normalizeInvisibleTimesSeparators = (mathml: string): string => mathml .replaceAll(INVISIBLE_TIMES_CHAR, VISIBLE_MULTIPLICATION_DOT) + .replaceAll(EXPONENTIAL_E_CHAR, 'e') .replaceAll(/⁢|⁢|⁢/gi, VISIBLE_MULTIPLICATION_DOT) const copyElementAttributes = (from: Element, to: Element) => { From 48fd97d66ffe568cbbd667ef441e51c066eaf5c2 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 15:30:24 +1200 Subject: [PATCH 43/46] Convert greek letters in math --- src/utils/mathTransformer.test.ts | 17 ++++++++++++ src/utils/mathTransformer.ts | 43 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index 93d20dce..b0184b9e 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -333,6 +333,23 @@ describe('formatMathMLTable', () => { expect(result).not.toContain('⊻') expect(result).not.toContain('¬') }) + + it('converts named Greek identifiers into Greek symbols', () => { + const greekIdentifiersEquation = ` + + alpha+beta+Gamma + + ` + + const result = formatMathMLTable(greekIdentifiersEquation) + + expect(result).toContain('α') + expect(result).toContain('β') + expect(result).toContain('γ') + expect(result).not.toContain('alpha') + expect(result).not.toContain('beta') + expect(result).not.toContain('Gamma') + }) }) describe('mathTransformer integration', () => { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 4ebe11e7..cfdfb355 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -32,6 +32,32 @@ const LOGICAL_OPERATOR_LABELS: Record = { '⊕': 'xor', '¬': 'not', } +const GREEK_IDENTIFIER_SYMBOLS: Record = { + alpha: 'α', + beta: 'β', + gamma: 'γ', + delta: 'δ', + epsilon: 'ε', + zeta: 'ζ', + eta: 'η', + theta: 'θ', + iota: 'ι', + kappa: 'κ', + lambda: 'λ', + mu: 'μ', + nu: 'ν', + xi: 'ξ', + omicron: 'ο', + pi: 'π', + rho: 'ρ', + sigma: 'σ', + tau: 'τ', + upsilon: 'υ', + phi: 'φ', + chi: 'χ', + psi: 'ψ', + omega: 'ω', +} const isFenceOperator = (element: Element): element is HTMLElement => element.tagName.toLowerCase() === 'mo' && element.getAttribute('fence') === 'true' @@ -141,6 +167,22 @@ const normalizeNumericLiterals = (root: ParentNode) => { }) } +const normalizeNamedGreekIdentifiers = (root: ParentNode) => { + const identifiers = Array.from(root.querySelectorAll('mi')) + + identifiers.forEach((identifier) => { + if (identifier.children.length > 0) return + + const rawText = (identifier.textContent || '').trim() + if (!rawText) return + + const replacement = GREEK_IDENTIFIER_SYMBOLS[rawText.toLowerCase()] + if (!replacement) return + + identifier.textContent = replacement + }) +} + const isMathMLElement = (node: Node, tagName: string): node is Element => node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === tagName @@ -264,6 +306,7 @@ export const formatMathMLTable = (rawMathML: string): string => { normalizeUnderscoreIdentifiers(math) normalizeLogicalOperators(math) normalizeNumericLiterals(math) + normalizeNamedGreekIdentifiers(math) const rows = Array.from(math.children).filter((child) => child.tagName.toLowerCase() === 'mrow') From 8b6f5cabc64a9fb94a18a85ce5cb910ab1f0353d Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 15:42:39 +1200 Subject: [PATCH 44/46] Convert scientific e-notation tokens in math --- src/utils/mathTransformer.test.ts | 17 ++++++++++ src/utils/mathTransformer.ts | 54 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index b0184b9e..fb91dd3f 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -350,6 +350,23 @@ describe('formatMathMLTable', () => { expect(result).not.toContain('beta') expect(result).not.toContain('Gamma') }) + + it('converts scientific e-notation tokens into multiplication by 10 to an exponent', () => { + const scientificNotationEquation = ` + + 3.1 + e + 5 + + ` + + const result = formatMathMLTable(scientificNotationEquation) + + expect(result).toContain('3.1') + expect(result).toContain('·') + expect(result).toContain('105') + expect(result).not.toContain('e') + }) }) describe('mathTransformer integration', () => { diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index cfdfb355..5e3db71d 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -167,6 +167,59 @@ const normalizeNumericLiterals = (root: ParentNode) => { }) } +const isNumberToken = (value: string): boolean => /^[-+]?\d*\.?\d+$/.test(value) +const isIntegerToken = (value: string): boolean => /^[-+]?\d+$/.test(value) + +const normalizeScientificENotation = (root: ParentNode) => { + const rows = Array.from(root.querySelectorAll('mrow')) + + rows.forEach((row) => { + const children = Array.from(row.children) + if (children.length < 3) return + + for (let i = 0; i <= children.length - 3; i += 1) { + const baseNode = children[i] + const eNode = children[i + 1] + const exponentNode = children[i + 2] + if (!baseNode || !eNode || !exponentNode) continue + + if ( + baseNode.tagName.toLowerCase() !== 'mn' || + eNode.tagName.toLowerCase() !== 'mo' || + exponentNode.tagName.toLowerCase() !== 'mn' + ) { + continue + } + + const baseText = (baseNode.textContent || '').trim() + const eText = (eNode.textContent || '').trim().toLowerCase() + const exponentText = (exponentNode.textContent || '').trim() + if (eText !== 'e' || !isNumberToken(baseText) || !isIntegerToken(exponentText)) { + continue + } + + const doc = row.ownerDocument + const namespace = row.namespaceURI || 'http://www.w3.org/1998/Math/MathML' + const multiplication = doc.createElementNS(namespace, 'mo') + multiplication.textContent = VISIBLE_MULTIPLICATION_DOT + + const msup = doc.createElementNS(namespace, 'msup') + const ten = doc.createElementNS(namespace, 'mn') + ten.textContent = '10' + const exponent = doc.createElementNS(namespace, 'mn') + exponent.textContent = exponentText + msup.appendChild(ten) + msup.appendChild(exponent) + + const afterExponent = exponentNode.nextSibling + row.insertBefore(multiplication, eNode) + row.insertBefore(msup, afterExponent) + row.removeChild(eNode) + row.removeChild(exponentNode) + } + }) +} + const normalizeNamedGreekIdentifiers = (root: ParentNode) => { const identifiers = Array.from(root.querySelectorAll('mi')) @@ -306,6 +359,7 @@ export const formatMathMLTable = (rawMathML: string): string => { normalizeUnderscoreIdentifiers(math) normalizeLogicalOperators(math) normalizeNumericLiterals(math) + normalizeScientificENotation(math) normalizeNamedGreekIdentifiers(math) const rows = Array.from(math.children).filter((child) => child.tagName.toLowerCase() === 'mrow') From 6c944aaa3d38fea8fd1e6adbbf0f3d9f5b038d4b Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 15:47:41 +1200 Subject: [PATCH 45/46] Rename variables and functions --- src/utils/mathTransformer.test.ts | 2 +- src/utils/mathTransformer.ts | 34 +++++++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/utils/mathTransformer.test.ts b/src/utils/mathTransformer.test.ts index fb91dd3f..fa15a8ed 100644 --- a/src/utils/mathTransformer.test.ts +++ b/src/utils/mathTransformer.test.ts @@ -275,7 +275,7 @@ describe('formatMathMLTable', () => { expect(result).toContain('data-math-operator="equals"') }) - it('normalizes invisible times separators in formatted output', () => { + it('normalises invisible times separators in formatted output', () => { const equationWithInvisibleTimes = `a\u2062b` const result = formatMathMLTable(equationWithInvisibleTimes) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 5e3db71d..976ed687 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -87,7 +87,7 @@ const fixMismatchedFencePairs = (root: ParentNode) => { }) } -const normalizeInvisibleTimesSeparators = (mathml: string): string => +const normaliseInvisibleTimesSeparators = (mathml: string): string => mathml .replaceAll(INVISIBLE_TIMES_CHAR, VISIBLE_MULTIPLICATION_DOT) .replaceAll(EXPONENTIAL_E_CHAR, 'e') @@ -99,7 +99,7 @@ const copyElementAttributes = (from: Element, to: Element) => { }) } -const normalizeUnderscoreIdentifiers = (root: ParentNode) => { +const normaliseUnderscoreIdentifiers = (root: ParentNode) => { const identifiers = Array.from(root.querySelectorAll('mi')) identifiers.forEach((identifier) => { @@ -137,21 +137,21 @@ const normalizeUnderscoreIdentifiers = (root: ParentNode) => { }) } -const normalizeLogicalOperators = (root: ParentNode) => { +const normaliseLogicalOperators = (root: ParentNode) => { const operators = Array.from(root.querySelectorAll('mo')) operators.forEach((operator) => { if (operator.children.length > 0) return - const normalizedText = (operator.textContent || '').trim() - const replacement = LOGICAL_OPERATOR_LABELS[normalizedText] + const normalisedText = (operator.textContent || '').trim() + const replacement = LOGICAL_OPERATOR_LABELS[normalisedText] if (!replacement) return operator.textContent = replacement }) } -const normalizeNumericLiterals = (root: ParentNode) => { +const normaliseNumericLiterals = (root: ParentNode) => { const numerals = Array.from(root.querySelectorAll('mn')) numerals.forEach((numeral) => { @@ -170,7 +170,7 @@ const normalizeNumericLiterals = (root: ParentNode) => { const isNumberToken = (value: string): boolean => /^[-+]?\d*\.?\d+$/.test(value) const isIntegerToken = (value: string): boolean => /^[-+]?\d+$/.test(value) -const normalizeScientificENotation = (root: ParentNode) => { +const normaliseScientificENotation = (root: ParentNode) => { const rows = Array.from(root.querySelectorAll('mrow')) rows.forEach((row) => { @@ -220,7 +220,7 @@ const normalizeScientificENotation = (root: ParentNode) => { }) } -const normalizeNamedGreekIdentifiers = (root: ParentNode) => { +const normaliseNamedGreekIdentifiers = (root: ParentNode) => { const identifiers = Array.from(root.querySelectorAll('mi')) identifiers.forEach((identifier) => { @@ -239,7 +239,7 @@ const normalizeNamedGreekIdentifiers = (root: ParentNode) => { const isMathMLElement = (node: Node, tagName: string): node is Element => node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === tagName -const normalizePiecewiseTables = (root: ParentNode, doc: Document, namespace: string) => { +const normalisePiecewiseTables = (root: ParentNode, doc: Document, namespace: string) => { const mathTables = Array.from(root.querySelectorAll('mtable')) mathTables.forEach((table) => { @@ -338,7 +338,7 @@ export function transformMathString(rawMathML: string): string { mathPolyfills?.transform(container) - return normalizeInvisibleTimesSeparators(container.innerHTML) + return normaliseInvisibleTimesSeparators(container.innerHTML) } /** @@ -356,11 +356,11 @@ export const formatMathMLTable = (rawMathML: string): string => { mathBlocks.forEach((math) => { fixMismatchedFencePairs(math) - normalizeUnderscoreIdentifiers(math) - normalizeLogicalOperators(math) - normalizeNumericLiterals(math) - normalizeScientificENotation(math) - normalizeNamedGreekIdentifiers(math) + normaliseUnderscoreIdentifiers(math) + normaliseLogicalOperators(math) + normaliseNumericLiterals(math) + normaliseScientificENotation(math) + normaliseNamedGreekIdentifiers(math) const rows = Array.from(math.children).filter((child) => child.tagName.toLowerCase() === 'mrow') @@ -395,8 +395,8 @@ export const formatMathMLTable = (rawMathML: string): string => { math.appendChild(mtable) } - normalizePiecewiseTables(math, doc, NS) + normalisePiecewiseTables(math, doc, NS) }) - return normalizeInvisibleTimesSeparators(doc.body.innerHTML) + return normaliseInvisibleTimesSeparators(doc.body.innerHTML) } From 1f8e4940bac38acd45795fce1f458101ff9bc251 Mon Sep 17 00:00:00 2001 From: akhuoa Date: Wed, 22 Apr 2026 15:48:20 +1200 Subject: [PATCH 46/46] Format --- src/utils/mathTransformer.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/mathTransformer.ts b/src/utils/mathTransformer.ts index 976ed687..3bdd0495 100644 --- a/src/utils/mathTransformer.ts +++ b/src/utils/mathTransformer.ts @@ -108,7 +108,10 @@ const normaliseUnderscoreIdentifiers = (root: ParentNode) => { const rawText = (identifier.textContent || '').trim() if (!rawText.includes('_')) return - const parts = rawText.split('_').map((part) => part.trim()).filter(Boolean) + const parts = rawText + .split('_') + .map((part) => part.trim()) + .filter(Boolean) if (parts.length < 2) return const doc = identifier.ownerDocument @@ -249,7 +252,9 @@ const normalisePiecewiseTables = (root: ParentNode, doc: Document, namespace: st let hasPiecewiseRows = false rows.forEach((row) => { - const rowCells = Array.from(row.children).filter((child) => child.tagName.toLowerCase() === 'mtd') + const rowCells = Array.from(row.children).filter( + (child) => child.tagName.toLowerCase() === 'mtd', + ) if (rowCells.length !== 1) return const [singleCell] = rowCells