diff --git a/.gitignore b/.gitignore index d2e784d..9c4d0af 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ dist-ssr # test coverage coverage +# generated docx artifacts for manual inspection (see lib/__tests__) +debug/fixtures/**/* + # temporary files tsup.config.bundled* diff --git a/lib/__tests__/fixtures/accents/hat-tilde-bar-vec.md b/lib/__tests__/fixtures/accents/hat-tilde-bar-vec.md new file mode 100644 index 0000000..d58dcaf --- /dev/null +++ b/lib/__tests__/fixtures/accents/hat-tilde-bar-vec.md @@ -0,0 +1,5 @@ +Accents: $\tilde{x}$, $\bar{x}$, $\hat{x}$, $\vec{v}$ + +$$ +\hat{x} +$$ diff --git a/lib/__tests__/fixtures/accents/overline.md b/lib/__tests__/fixtures/accents/overline.md new file mode 100644 index 0000000..513b587 --- /dev/null +++ b/lib/__tests__/fixtures/accents/overline.md @@ -0,0 +1,5 @@ +Overline: $\overline{AB}$ + +$$ +\overline{AB} +$$ diff --git a/lib/__tests__/fixtures/basic/block-equation.md b/lib/__tests__/fixtures/basic/block-equation.md new file mode 100644 index 0000000..9f4d4a8 --- /dev/null +++ b/lib/__tests__/fixtures/basic/block-equation.md @@ -0,0 +1,5 @@ +A simple block equation: + +$$ +x + y = z +$$ diff --git a/lib/__tests__/fixtures/basic/inline-equation.md b/lib/__tests__/fixtures/basic/inline-equation.md new file mode 100644 index 0000000..e422bb5 --- /dev/null +++ b/lib/__tests__/fixtures/basic/inline-equation.md @@ -0,0 +1 @@ +Einstein's mass-energy equivalence: $E = mc^2$ diff --git a/lib/__tests__/fixtures/basic/inline-variable.md b/lib/__tests__/fixtures/basic/inline-variable.md new file mode 100644 index 0000000..c53452a --- /dev/null +++ b/lib/__tests__/fixtures/basic/inline-variable.md @@ -0,0 +1 @@ +A single variable: $x$ diff --git a/lib/__tests__/fixtures/basic/plain-text.md b/lib/__tests__/fixtures/basic/plain-text.md new file mode 100644 index 0000000..1b310c4 --- /dev/null +++ b/lib/__tests__/fixtures/basic/plain-text.md @@ -0,0 +1,3 @@ +Hello world. + +This paragraph has no math. diff --git a/lib/__tests__/fixtures/display/multiple-block-equations.md b/lib/__tests__/fixtures/display/multiple-block-equations.md new file mode 100644 index 0000000..d2eef61 --- /dev/null +++ b/lib/__tests__/fixtures/display/multiple-block-equations.md @@ -0,0 +1,13 @@ +Multiple display equations in sequence: + +$$ +\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} +$$ + +$$ +\int_0^\infty x^2 e^{-x^2} dx = \frac{\sqrt{\pi}}{4} +$$ + +$$ +\frac{d}{dx} \left( \frac{1}{x} \right) = -\frac{1}{x^2} +$$ diff --git a/lib/__tests__/fixtures/edge-cases/adjacent-unrenderable-inline.md b/lib/__tests__/fixtures/edge-cases/adjacent-unrenderable-inline.md new file mode 100644 index 0000000..1782256 --- /dev/null +++ b/lib/__tests__/fixtures/edge-cases/adjacent-unrenderable-inline.md @@ -0,0 +1,5 @@ +Adjacent inline math with unrenderable segment should skip only the bad part. + +The area is $x$ cm$^{2}$ in units. + +Only the variable renders; trailing superscript on text is skipped. diff --git a/lib/__tests__/fixtures/inline/mixed-inline.md b/lib/__tests__/fixtures/inline/mixed-inline.md new file mode 100644 index 0000000..fcae983 --- /dev/null +++ b/lib/__tests__/fixtures/inline/mixed-inline.md @@ -0,0 +1,3 @@ +Inline math expressions can be enclosed within single dollar signs: $E=mc^2$. You can also use `\(` and `\)` delimiters in prose: $\sum_{i=1}^{n} i^2$. + +Mixed on one line: $\alpha + \beta = \gamma$ and $\frac{1}{2}$. diff --git a/lib/__tests__/fixtures/lists/common-symbols-list.md b/lib/__tests__/fixtures/lists/common-symbols-list.md new file mode 100644 index 0000000..76e94ae --- /dev/null +++ b/lib/__tests__/fixtures/lists/common-symbols-list.md @@ -0,0 +1,18 @@ +Common mathematical symbols in a list: + +- Greek letters: $\alpha$, $\beta$, $\gamma$, $\Gamma$, $\Delta$, $\pi$, $\Pi$, $\Sigma$, $\omega$, $\Omega$ +- Superscripts and subscripts: $x^2$, $y_i$, $a^{b+c}$, $e^{-i\omega t}$ +- Fractions: $\frac{1}{2}$, $\frac{x+y}{z}$ +- Square roots: $\sqrt{x}$, $\sqrt[3]{y}$ +- Summations and products: $\sum_{i=1}^n i$, $\prod_{j=1}^m j$ +- Integrals: $\int_a^b f(x)\,dx$, $\int_0^1 f(x)\,dx$, $\oint_C \vec{F} \cdot d\vec{r}$ +- Limits: $\lim_{x \to \infty} \frac{1}{x}$, $\lim_{n \to \infty} a_n$ +- Vectors: $\vec{v}$, $\mathbf{v}$ +- Accents: $\tilde{x}$, $\bar{x}$, $\overline{AB}$, $\hat{x}$ +- Partial derivatives: $\frac{\partial f}{\partial x}$ +- Infinity: $\infty$ +- Logical symbols: $\forall$, $\exists$, $\in$, $\notin$, $\subseteq$, $\supseteq$, $\land$, $\lor$, $\neg$, $\wedge$, $\ne$, $\triangle ABC$, $\cdots$ +- Binomial coefficients: $\binom{n}{k}$ +- Stackrel: $\stackrel{\mathrm{def}}{=}$ +- Trigonometric functions: $\sin(x)$, $\cos(y)$, $\tan(z)$ +- Exponential and logarithmic functions: $e^x$, $\ln(y)$, $\log_{10}(z)$ diff --git a/lib/__tests__/fixtures/operators/contour-integral.md b/lib/__tests__/fixtures/operators/contour-integral.md new file mode 100644 index 0000000..5b1320d --- /dev/null +++ b/lib/__tests__/fixtures/operators/contour-integral.md @@ -0,0 +1 @@ +Contour integral: $\oint_C \vec{F} \cdot d\vec{r}$ diff --git a/lib/__tests__/fixtures/operators/fraction.md b/lib/__tests__/fixtures/operators/fraction.md new file mode 100644 index 0000000..30bbc13 --- /dev/null +++ b/lib/__tests__/fixtures/operators/fraction.md @@ -0,0 +1,5 @@ +Fractions: $\frac{1}{2}$, $\frac{x+y}{z}$, and a block form: + +$$ +\frac{a^2 + b^2}{c^2} = 1 +$$ diff --git a/lib/__tests__/fixtures/operators/integral-definite.md b/lib/__tests__/fixtures/operators/integral-definite.md new file mode 100644 index 0000000..1d1ce2e --- /dev/null +++ b/lib/__tests__/fixtures/operators/integral-definite.md @@ -0,0 +1,5 @@ +Definite integral: $\int_0^1 f(x)\,dx$ + +$$ +\int_0^\infty x^2 e^{-x^2} dx = \frac{\sqrt{\pi}}{4} +$$ diff --git a/lib/__tests__/fixtures/operators/integral-indefinite.md b/lib/__tests__/fixtures/operators/integral-indefinite.md new file mode 100644 index 0000000..e670110 --- /dev/null +++ b/lib/__tests__/fixtures/operators/integral-indefinite.md @@ -0,0 +1 @@ +Indefinite integral with bounds: $\int_a^b f(x)\,dx$ diff --git a/lib/__tests__/fixtures/operators/nth-root.md b/lib/__tests__/fixtures/operators/nth-root.md new file mode 100644 index 0000000..0205e68 --- /dev/null +++ b/lib/__tests__/fixtures/operators/nth-root.md @@ -0,0 +1 @@ +Nth root: $\sqrt[3]{y}$ diff --git a/lib/__tests__/fixtures/operators/product.md b/lib/__tests__/fixtures/operators/product.md new file mode 100644 index 0000000..db29376 --- /dev/null +++ b/lib/__tests__/fixtures/operators/product.md @@ -0,0 +1 @@ +Product notation: $\prod_{j=1}^{m} j$ diff --git a/lib/__tests__/fixtures/operators/sqrt.md b/lib/__tests__/fixtures/operators/sqrt.md new file mode 100644 index 0000000..ead1c9c --- /dev/null +++ b/lib/__tests__/fixtures/operators/sqrt.md @@ -0,0 +1 @@ +Square roots: $\sqrt{x}$ and nested $\sqrt{a^2 + b^2}$ diff --git a/lib/__tests__/fixtures/operators/summation.md b/lib/__tests__/fixtures/operators/summation.md new file mode 100644 index 0000000..1238afa --- /dev/null +++ b/lib/__tests__/fixtures/operators/summation.md @@ -0,0 +1,5 @@ +Summation with limits: $\sum_{i=1}^{n} x_i$ + +$$ +\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} +$$ diff --git a/lib/__tests__/fixtures/scripts/superscripts-subscripts.md b/lib/__tests__/fixtures/scripts/superscripts-subscripts.md new file mode 100644 index 0000000..84e2abb --- /dev/null +++ b/lib/__tests__/fixtures/scripts/superscripts-subscripts.md @@ -0,0 +1 @@ +Superscripts and subscripts: $x^2$, $y_i$, $a^{b+c}$, $e^{-i\omega t}$ diff --git a/lib/__tests__/fixtures/structures/binomial.md b/lib/__tests__/fixtures/structures/binomial.md new file mode 100644 index 0000000..0b0239e --- /dev/null +++ b/lib/__tests__/fixtures/structures/binomial.md @@ -0,0 +1,5 @@ +Binomial coefficient: $\binom{n}{k}$ + +$$ +\binom{n}{k} +$$ diff --git a/lib/__tests__/fixtures/structures/limit.md b/lib/__tests__/fixtures/structures/limit.md new file mode 100644 index 0000000..169ddf2 --- /dev/null +++ b/lib/__tests__/fixtures/structures/limit.md @@ -0,0 +1 @@ +Limits: $\lim_{x \to \infty} \frac{1}{x}$, $\lim_{n \to \infty} a_n$ diff --git a/lib/__tests__/fixtures/structures/parentheses-fraction.md b/lib/__tests__/fixtures/structures/parentheses-fraction.md new file mode 100644 index 0000000..22ad9dd --- /dev/null +++ b/lib/__tests__/fixtures/structures/parentheses-fraction.md @@ -0,0 +1,7 @@ +Bold vector: $\mathbf{v}$ + +Parentheses with fraction: + +$$ +\frac{d}{dx} \left( \frac{1}{x} \right) = -\frac{1}{x^2} +$$ diff --git a/lib/__tests__/fixtures/structures/stackrel.md b/lib/__tests__/fixtures/structures/stackrel.md new file mode 100644 index 0000000..ad6e574 --- /dev/null +++ b/lib/__tests__/fixtures/structures/stackrel.md @@ -0,0 +1,5 @@ +Stackrel: $\stackrel{\mathrm{def}}{=}$ + +$$ +\stackrel{\mathrm{def}}{=} +$$ diff --git a/lib/__tests__/fixtures/symbols/exponential-logarithmic.md b/lib/__tests__/fixtures/symbols/exponential-logarithmic.md new file mode 100644 index 0000000..3665516 --- /dev/null +++ b/lib/__tests__/fixtures/symbols/exponential-logarithmic.md @@ -0,0 +1 @@ +Exponential and logarithmic functions: $e^x$, $\ln(y)$, $\log_{10}(z)$ diff --git a/lib/__tests__/fixtures/symbols/greek-letters.md b/lib/__tests__/fixtures/symbols/greek-letters.md new file mode 100644 index 0000000..bbe0498 --- /dev/null +++ b/lib/__tests__/fixtures/symbols/greek-letters.md @@ -0,0 +1 @@ +Greek letters: $\alpha$, $\beta$, $\gamma$, $\Gamma$, $\Delta$, $\pi$, $\Pi$, $\Sigma$, $\omega$, $\Omega$ diff --git a/lib/__tests__/fixtures/symbols/logical-symbols.md b/lib/__tests__/fixtures/symbols/logical-symbols.md new file mode 100644 index 0000000..734678d --- /dev/null +++ b/lib/__tests__/fixtures/symbols/logical-symbols.md @@ -0,0 +1 @@ +Logical symbols: $\forall$, $\exists$, $\in$, $\notin$, $\subseteq$, $\supseteq$, $\land$, $\lor$, $\neg$, $\wedge$, $\ne$ diff --git a/lib/__tests__/fixtures/symbols/misc-symbols.md b/lib/__tests__/fixtures/symbols/misc-symbols.md new file mode 100644 index 0000000..a7cf3c9 --- /dev/null +++ b/lib/__tests__/fixtures/symbols/misc-symbols.md @@ -0,0 +1,7 @@ +Partial derivative: $\frac{\partial f}{\partial x}$ + +Infinity: $\infty$ + +Triangle notation: $\triangle ABC$ + +Dots: $\cdots$ diff --git a/lib/__tests__/fixtures/symbols/trigonometric.md b/lib/__tests__/fixtures/symbols/trigonometric.md new file mode 100644 index 0000000..f238ff9 --- /dev/null +++ b/lib/__tests__/fixtures/symbols/trigonometric.md @@ -0,0 +1 @@ +Trigonometric functions: $\sin(x)$, $\cos(y)$, $\tan(z)$ diff --git a/lib/__tests__/fixtures/text/text-in-math.md b/lib/__tests__/fixtures/text/text-in-math.md new file mode 100644 index 0000000..18a19c3 --- /dev/null +++ b/lib/__tests__/fixtures/text/text-in-math.md @@ -0,0 +1,7 @@ +Text within math: + +$$ +\text{Let } x \text{ be a real number.} +$$ + +Inline with text macro: $\text{if } x > 0$ diff --git a/lib/__tests__/fixtures/text/textcolor.md b/lib/__tests__/fixtures/text/textcolor.md new file mode 100644 index 0000000..1c13a1e --- /dev/null +++ b/lib/__tests__/fixtures/text/textcolor.md @@ -0,0 +1,9 @@ +`\textcolor` is not styled; only the math body is rendered. + +$$ +\textcolor{red}{E=mc^2} +$$ + +$$ +\textcolor{blue}{\sum_{i=1}^n i} +$$ diff --git a/lib/__tests__/fixtures/unsupported/align-and-cases-skipped.md b/lib/__tests__/fixtures/unsupported/align-and-cases-skipped.md new file mode 100644 index 0000000..5896e6b --- /dev/null +++ b/lib/__tests__/fixtures/unsupported/align-and-cases-skipped.md @@ -0,0 +1,32 @@ +Align and aligned environments are not yet supported; math is skipped but surrounding text remains. + +$$ +\begin{align} +y &= mx + b \\ +y' &= m +\end{align} +$$ + +$$ +\begin{aligned} +(a+b)^2 &= (a+b)(a+b) \\ +&= a^2 + ab + ba + b^2 \\ +&= a^2 + 2ab + b^2 +\end{aligned} +$$ + +$$ +f(x) = \begin{cases} +x^2, & \text{if } x \ge 0 \\ +-x^2, & \text{otherwise} +\end{cases} +$$ + +$$ +f(x) = +\begin{cases} +1, & \text{if } x > 0 \\ +0, & \text{if } x = 0 \\ +-1, & \text{if } x < 0 +\end{cases} +$$ diff --git a/lib/__tests__/fixtures/unsupported/matrices-skipped.md b/lib/__tests__/fixtures/unsupported/matrices-skipped.md new file mode 100644 index 0000000..b3a2476 --- /dev/null +++ b/lib/__tests__/fixtures/unsupported/matrices-skipped.md @@ -0,0 +1,34 @@ +Matrix environments are not yet supported; math is skipped but surrounding text remains. + +Inline matrix attempt: $\begin{pmatrix} a & b \\ c & d \end{pmatrix}$ + +Block matrix attempt: + +$$ +\begin{pmatrix} +1 & 2 & 3 \\ +4 & 5 & 6 \\ +7 & 8 & 9 +\end{pmatrix} +$$ + +$$ +\begin{bmatrix} +1 & 2 \\ +3 & 4 +\end{bmatrix} +$$ + +$$ +\begin{vmatrix} +a & b \\ +c & d +\end{vmatrix} +$$ + +$$ +\begin{Vmatrix} +a & b \\ +c & d +\end{Vmatrix} +$$ diff --git a/lib/__tests__/helpers/assert-valid-docx.ts b/lib/__tests__/helpers/assert-valid-docx.ts new file mode 100644 index 0000000..bc86f90 --- /dev/null +++ b/lib/__tests__/helpers/assert-valid-docx.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { toDocx } from "@m2d/core"; +import { validateFile } from "@xarsh/ooxml-validator"; +import remarkMath from "remark-math"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { mathPlugin } from "../../src"; + +const markdownProcessor = unified().use(remarkParse).use(remarkMath); + +type MdastRoot = Parameters[0]; + +export type DocxValidationResult = Awaited>; + +/** Directory for generated DOCX files used in manual inspection. */ +export const DEBUG_DOCX_DIR = path.resolve( + import.meta.dirname, + "../../../debug", +); + +/** Write a debug artifact under {@link DEBUG_DOCX_DIR}. */ +export const saveDebugFile = ( + filename: string, + content: string | Buffer, +): string => { + const filePath = path.join(DEBUG_DOCX_DIR, filename); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + return filePath; +}; + +/** Write a DOCX buffer under {@link DEBUG_DOCX_DIR} for manual testing. */ +export const saveDebugDocx = (filename: string, buffer: Buffer): string => + saveDebugFile(filename, buffer); + +/** Root directory for OOXML validation fixture markdown files. */ +export const FIXTURES_DIR = path.resolve(import.meta.dirname, "../fixtures"); + +/** Recursively list all `.md` fixture files under {@link FIXTURES_DIR}. */ +export const listFixtureFiles = (): string[] => { + const fixtures: string[] = []; + + const walk = (directory: string) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + walk(filePath); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + fixtures.push(filePath); + } + } + }; + + walk(FIXTURES_DIR); + return fixtures.sort(); +}; + +/** Recursively list individual `.md` fixture files (excludes `combined/`). */ +export const listIndividualFixtureFiles = (): string[] => + listFixtureFiles().filter( + (fixturePath) => !fixturePath.includes(`${path.sep}combined${path.sep}`), + ); + +/** Map a fixture markdown path to its debug DOCX output path. */ +export const fixtureDebugDocxPath = (fixturePath: string): string => + `${path.relative(FIXTURES_DIR, fixturePath).replace(/\.md$/, ".docx")}`; + +/** Build one markdown document containing every individual fixture. */ +export const buildCombinedFixtureMarkdown = (): string => + listIndividualFixtureFiles() + .map((fixturePath) => { + const label = path + .relative(FIXTURES_DIR, fixturePath) + .replace(/\.md$/, "") + .replace(/\//g, " / "); + const body = fs.readFileSync(fixturePath, "utf-8").trim(); + + return `**${label}**\n\n${body}`; + }) + .join("\n\n---\n\n"); + +/** Generate a DOCX buffer from markdown using the math plugin. */ +export const docxFromMarkdown = async (markdown: string): Promise => { + const tree = markdownProcessor.parse(markdown); + const normalized = markdownProcessor.runSync(tree); + + return (await toDocx( + normalized as MdastRoot, + {}, + { plugins: [mathPlugin()] }, + "nodebuffer", + )) as Buffer; +}; + +/** Validate a DOCX buffer against Microsoft's OOXML schema. */ +export const validateDocxBuffer = async ( + buffer: Buffer | Uint8Array, +): Promise => { + const file = path.join( + os.tmpdir(), + `m2d-math-docx-${crypto.randomUUID()}.docx`, + ); + + try { + fs.writeFileSync(file, buffer); + return await validateFile(file, { officeVersion: "Microsoft365" }); + } finally { + fs.unlinkSync(file); + } +}; + +/** Format schema validation errors for test output. */ +export const formatDocxValidationErrors = ( + result: DocxValidationResult, +): string => + result.errors + .map( + (error) => + `[${error.errorType}] ${error.path}\n ${error.xPath}\n ${error.description}`, + ) + .join("\n\n"); diff --git a/lib/__tests__/index.test.ts b/lib/__tests__/index.test.ts index b6291fa..3edc786 100644 --- a/lib/__tests__/index.test.ts +++ b/lib/__tests__/index.test.ts @@ -1,13 +1,35 @@ import fs from "node:fs"; -import { toDocx } from "@m2d/core"; // Adjust path based on your setup +import path from "node:path"; +import { toDocx } from "@m2d/core"; import remarkMath from "remark-math"; import remarkParse from "remark-parse"; import { unified } from "unified"; -import { describe, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { mathPlugin } from "../src"; +import { + buildCombinedFixtureMarkdown, + docxFromMarkdown, + fixtureDebugDocxPath, + formatDocxValidationErrors, + listIndividualFixtureFiles, + saveDebugDocx, + saveDebugFile, + validateDocxBuffer, +} from "./helpers/assert-valid-docx"; const markdown = fs.readFileSync("../sample.md", "utf-8"); +const emptyOMathCount = async (md: string) => { + const buffer = await docxFromMarkdown(md); + const { execSync } = await import("node:child_process"); + const tempPath = `/tmp/m2d-math-test-${Math.random()}.docx`; + fs.writeFileSync(tempPath, buffer); + const xml = execSync(`unzip -p ${tempPath} word/document.xml`, { + encoding: "utf8", + }); + return (xml.match(//g) ?? []).length; +}; + describe("toDocx", () => { it("should handle maths", async ({ expect }) => { const mdast = unified().use(remarkParse).use(remarkMath).parse(markdown); @@ -16,4 +38,43 @@ describe("toDocx", () => { expect(docxBlob).toBeInstanceOf(Blob); }); + + it("should not emit empty oMath for unrenderable inline math", async ({ + expect, + }) => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(await emptyOMathCount("$x$ cm$^{2}$")).toBe(0); + expect(error).toHaveBeenCalled(); + + error.mockRestore(); + }); +}); + +describe("OOXML schema validation", () => { + it.each( + listIndividualFixtureFiles().map((fixturePath) => [fixturePath]), + )("passes for %s", async (fixturePath) => { + const markdown = fs.readFileSync(fixturePath, "utf-8"); + const buffer = await docxFromMarkdown(markdown); + saveDebugDocx( + path.join("fixtures", fixtureDebugDocxPath(fixturePath)), + buffer, + ); + const result = await validateDocxBuffer(buffer); + + expect(result.ok, formatDocxValidationErrors(result)).toBe(true); + }); + + it("passes for combined all-fixtures document", async () => { + const markdown = buildCombinedFixtureMarkdown(); + const buffer = await docxFromMarkdown(markdown); + + saveDebugFile("fixtures/combined/all-fixtures.md", markdown); + saveDebugDocx("fixtures/combined/all-fixtures.docx", buffer); + + const result = await validateDocxBuffer(buffer); + + expect(result.ok, formatDocxValidationErrors(result)).toBe(true); + }); }); diff --git a/lib/package.json b/lib/package.json index dc0d89e..d6c024d 100644 --- a/lib/package.json +++ b/lib/package.json @@ -23,13 +23,15 @@ } }, "scripts": { - "build": "tsup && tsc -p tsconfig-build.json && gzip -c dist/index.js | wc -c", + "build": "tsup && tsc -p tsconfig-build.json && rm -f dist/katexData.d.ts && gzip -c dist/index.js | wc -c", "clean": "rm -rf dist", "dev": "tsup --watch && tsc -p tsconfig-build.json -w", "typecheck": "tsc --noEmit", - "test": "vitest run --coverage" + "test": "vitest run --coverage", + "generate:katex": "node --experimental-strip-types scripts/generate-katex-data.ts" }, "devDependencies": { + "@xarsh/ooxml-validator": "^0.3.0", "@repo/typescript-config": "workspace:*", "@testing-library/react": "^16.3.2", "@types/node": "^26.0.0", diff --git a/lib/scripts/benchmark-bundle-formats.ts b/lib/scripts/benchmark-bundle-formats.ts new file mode 100644 index 0000000..6321da7 --- /dev/null +++ b/lib/scripts/benchmark-bundle-formats.ts @@ -0,0 +1,277 @@ +/** + * Benchmark KaTeX symbol table serialization formats. + * Run from lib/: pnpm exec node --experimental-strip-types scripts/benchmark-bundle-formats.ts + */ +import { execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; +import { + KATEX_ACCENTS, + KATEX_FUNCTIONS, + KATEX_INTEGRAL_OPS, + KATEX_LIMITS_TEXT_OPS, + KATEX_NARY_OPS, + KATEX_SYMBOLS, + type KatexNAryOp, +} from "../src/katexData.ts"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SRC = join(ROOT, "src"); +const INDEX = join(SRC, "index.ts"); +const KATEX_DATA = join(SRC, "katexData.ts"); + +type Format = { + name: string; + note: string; + write: () => void; + patchIndex: (src: string) => string; +}; + +const sortedEntries = Object.entries(KATEX_SYMBOLS).sort(([a], [b]) => + a.localeCompare(b), +); + +const formatNAryOps = (ops: Record): string => + Object.entries(ops) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => { + const loc = v.limitLocationVal + ? `, limitLocationVal: ${JSON.stringify(v.limitLocationVal)}` + : ""; + return ` ${JSON.stringify(k)}: { accent: ${JSON.stringify(v.accent)}${loc} },`; + }) + .join("\n"); + +const metaTail = [ + `export const KATEX_ACCENTS = ${JSON.stringify(KATEX_ACCENTS)} as Record;`, + ``, + `export const KATEX_FUNCTIONS = new Set(${JSON.stringify([...KATEX_FUNCTIONS].sort())});`, + ``, + `export type KatexNAryOp = { accent: string; limitLocationVal?: "subSup" };`, + ``, + `export const KATEX_NARY_OPS: Record = {`, + formatNAryOps(KATEX_NARY_OPS), + `};`, + ``, + `export const KATEX_INTEGRAL_OPS: Record = {`, + formatNAryOps(KATEX_INTEGRAL_OPS), + `};`, + ``, + `export const KATEX_LIMITS_TEXT_OPS = new Set(${JSON.stringify([...KATEX_LIMITS_TEXT_OPS].sort())});`, + ``, +].join("\n"); + +const objectLiteralBody = sortedEntries + .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`) + .join("\n"); + +const tupleBody = sortedEntries + .map(([k, v]) => ` [${JSON.stringify(k)}, ${JSON.stringify(v)}],`) + .join("\n"); + +const parallelKeys = sortedEntries.map(([k]) => JSON.stringify(k)).join(","); +const parallelValues = JSON.stringify(sortedEntries.map(([, v]) => v)); +const gzipB64 = gzipSync( + Buffer.from(JSON.stringify(KATEX_SYMBOLS), "utf8"), +).toString("base64"); + +const baselineIndex = readFileSync(INDEX, "utf8"); +const baselineKatexData = readFileSync(KATEX_DATA, "utf8"); + +const mapPatchIndex = (src: string): string => + src + .replace( + ` KATEX_SYMBOLS,\n type KatexNAryOp,\n} from "./katexData";`, + ` type KatexNAryOp,\n KATEX_SYMBOL_MAP,\n} from "./katexData";`, + ) + .replace( + `const resolveLatexSymbol = (name: string): string | undefined =>\n KATEX_SYMBOLS[name];`, + `const resolveLatexSymbol = (name: string): string | undefined =>\n KATEX_SYMBOL_MAP.get(name);`, + ); + +const formats: Format[] = [ + { + name: "1-baseline-literal", + note: "Current katexData.ts: merged object literal + direct lookup", + write: () => writeFileSync(KATEX_DATA, baselineKatexData), + patchIndex: (src) => src, + }, + { + name: "2-merged-literal", + note: "Regenerated object literal from imported KATEX_SYMBOLS", + write: () => { + writeFileSync( + KATEX_DATA, + [ + `/** benchmark: merged object literal */`, + `export const KATEX_SYMBOLS: Record = {`, + objectLiteralBody, + `};`, + ``, + metaTail, + ].join("\n"), + ); + }, + patchIndex: (src) => src, + }, + { + name: "3-json-parse", + note: "Single JSON.parse blob", + write: () => { + writeFileSync( + KATEX_DATA, + [ + `/** benchmark: JSON.parse blob */`, + `export const KATEX_SYMBOLS = JSON.parse(${JSON.stringify(JSON.stringify(KATEX_SYMBOLS))}) as Record;`, + metaTail, + ].join("\n"), + ); + }, + patchIndex: (src) => src, + }, + { + name: "4-tuple-fromEntries", + note: "Tuple array + Object.fromEntries at module init", + write: () => { + writeFileSync( + KATEX_DATA, + [ + `/** benchmark: tuple entries + Object.fromEntries */`, + `const ENTRIES: [string, string][] = [`, + tupleBody, + `];`, + `export const KATEX_SYMBOLS = Object.fromEntries(ENTRIES) as Record;`, + metaTail, + ].join("\n"), + ); + }, + patchIndex: (src) => src, + }, + { + name: "5-parallel-arrays", + note: "Parallel keys/values arrays + Object.fromEntries", + write: () => { + writeFileSync( + KATEX_DATA, + [ + `/** benchmark: parallel arrays */`, + `const KEYS = [${parallelKeys}] as const;`, + `const VALS = ${parallelValues} as const;`, + `export const KATEX_SYMBOLS = Object.fromEntries(KEYS.map((k, i) => [k, VALS[i]])) as Record;`, + metaTail, + ].join("\n"), + ); + }, + patchIndex: (src) => src, + }, + { + name: "6-gzip-base64-node", + note: "gzip+base64 blob, gunzipSync at module init (Node zlib)", + write: () => { + writeFileSync( + KATEX_DATA, + [ + `/** benchmark: gzip base64 (Node) */`, + `import { gunzipSync } from "node:zlib";`, + `const B64 = ${JSON.stringify(gzipB64)};`, + `export const KATEX_SYMBOLS = JSON.parse(`, + ` gunzipSync(Buffer.from(B64, "base64")).toString("utf8"),`, + `) as Record;`, + metaTail, + ].join("\n"), + ); + }, + patchIndex: (src) => src, + }, + { + name: "7-literal-oneline", + note: "Merged object literal on one line via JSON.stringify", + write: () => { + writeFileSync( + KATEX_DATA, + [ + `/** benchmark: one-line object literal */`, + `export const KATEX_SYMBOLS: Record = ${JSON.stringify(KATEX_SYMBOLS)};`, + metaTail, + ].join("\n"), + ); + }, + patchIndex: (src) => src, + }, + { + name: "8-map-constructor", + note: "new Map(entries) then lookup via .get", + write: () => { + writeFileSync( + KATEX_DATA, + [ + `/** benchmark: Map constructor */`, + `const ENTRIES: [string, string][] = [`, + tupleBody, + `];`, + `export const KATEX_SYMBOL_MAP = new Map(ENTRIES);`, + metaTail, + ].join("\n"), + ); + }, + patchIndex: mapPatchIndex, + }, +]; + +const measure = () => { + const cjs = readFileSync(join(ROOT, "dist/index.js")); + const esm = readFileSync(join(ROOT, "dist/index.mjs")); + const gzCjs = execSync("gzip -c dist/index.js", { + cwd: ROOT, + encoding: "buffer", + }); + const dataSrc = readFileSync(KATEX_DATA).length; + + return { cjs: cjs.length, esm: esm.length, gzCjs: gzCjs.length, dataSrc }; +}; + +console.log("KaTeX symbol format benchmark\n"); +console.log(`Merged lookup entries: ${sortedEntries.length}`); +console.log(`Raw JSON size: ${JSON.stringify(KATEX_SYMBOLS).length} B`); +console.log( + `gzip(JSON) alone: ${gzipSync(Buffer.from(JSON.stringify(KATEX_SYMBOLS))).length} B`, +); +console.log(`gzip+base64 payload: ${gzipB64.length} chars\n`); + +const results: Array< + { name: string; note: string } & ReturnType +> = []; + +for (const format of formats) { + format.write(); + writeFileSync(INDEX, format.patchIndex(baselineIndex)); + execSync("pnpm build", { cwd: ROOT, stdio: "pipe" }); + const stats = measure(); + results.push({ name: format.name, note: format.note, ...stats }); + console.log( + `✓ ${format.name}: gzip ${stats.gzCjs} B, CJS ${stats.cjs} B, data src ${stats.dataSrc} B`, + ); +} + +writeFileSync(INDEX, baselineIndex); +writeFileSync(KATEX_DATA, baselineKatexData); + +console.log("\n| Format | gzip CJS | CJS | ESM | data src | vs baseline |"); +console.log("|--------|----------|-----|-----|----------|-------------|"); +const baseGz = results[0].gzCjs; +for (const r of results) { + const delta = r.gzCjs - baseGz; + const pct = ((delta / baseGz) * 100).toFixed(1); + const deltaStr = + delta === 0 ? "—" : `${delta >= 0 ? "+" : ""}${delta} B (${pct}%)`; + console.log( + `| ${r.name} | ${r.gzCjs} | ${r.cjs} | ${r.esm} | ${r.dataSrc} | ${deltaStr} |`, + ); +} + +console.log("\nNotes:"); +for (const r of results) { + console.log(`- ${r.name}: ${r.note}`); +} diff --git a/lib/scripts/generate-katex-data.ts b/lib/scripts/generate-katex-data.ts new file mode 100644 index 0000000..2a4238a --- /dev/null +++ b/lib/scripts/generate-katex-data.ts @@ -0,0 +1,323 @@ +/** + * Generates KaTeX-derived symbol data for @m2d/math. + * Fetches KaTeX v0.16.22 source at codegen time (MIT): + * https://github.com/KaTeX/KaTeX/tree/v0.16.22/src + * + * Run: pnpm generate:katex + */ +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const KATEX_VERSION = "0.16.22"; +const KATEX_BASE = `https://raw.githubusercontent.com/KaTeX/KaTeX/v${KATEX_VERSION}/src`; +const REGENERATE_CMD = "pnpm generate:katex"; +const SIMPLE_MACRO = /^\\([a-zA-Z@][a-zA-Z0-9@]*)$/; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(SCRIPT_DIR, ".."); + +/** Fetch a KaTeX source file from the pinned GitHub release. */ +const fetchKatexSource = async (path: string): Promise => { + const url = `${KATEX_BASE}/${path}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch ${url}: ${response.status} ${response.statusText}`, + ); + } + return response.text(); +}; + +const symbolMap: Record = {}; +const aliasMap: Record = {}; +const accentMap: Record = {}; +const overrideMap: Record = {}; + +type KatexNAryOp = { accent: string; limitLocationVal?: "subSup" }; + +/** Commands excluded from generated operator tables (with reason). */ +const EXCLUDED_OPS: Record = { + mathop: "takes a body argument, not a standalone operator name", +}; + +/** Parse a defineFunction block from op.js for limits/symbol flags and names. */ +const parseOpBlock = ( + block: string, +): { limits: boolean; symbol: boolean; names: string[] } | undefined => { + if (!block.includes('type: "op"')) return undefined; + const limitsMatch = block.match(/limits:\s*(true|false)/); + const symbolMatch = block.match(/symbol:\s*(true|false)/); + if (!limitsMatch || !symbolMatch) return undefined; + + const namesMatch = block.match(/names:\s*\[([\s\S]*?)\]/); + const names: string[] = []; + if (namesMatch) { + for (const nameMatch of namesMatch[1].matchAll(/"\\\\([^"]+)"/g)) { + names.push(nameMatch[1]); + } + } + return { + limits: limitsMatch[1] === "true", + symbol: symbolMatch[1] === "true", + names, + }; +}; + +/** Decode a KaTeX char literal or single-character string. */ +const decodeChar = (raw: string): string | undefined => { + if (/^\\u[0-9a-fA-F]{4}$/.test(raw)) { + return JSON.parse(`"${raw}"`) as string; + } + return raw.length === 1 ? raw : undefined; +}; + +// skipcq: JS-R1005 +/** Generate KaTeX symbol tables and write them to src/. */ +const generate = async (): Promise => { + console.log(`Fetching KaTeX v${KATEX_VERSION} from ${KATEX_BASE}`); + + const [symbolsSrc, macrosSrc, opSrc] = await Promise.all([ + fetchKatexSource("symbols.js"), + fetchKatexSource("macros.js"), + fetchKatexSource("functions/op.js"), + ]); + + for (const m of symbolsSrc.matchAll(/defineSymbol\([^\n]+\)/g)) { + const strMatch = [ + ...m[0].matchAll(/"((?:\\u[0-9a-fA-F]{4}|\\[^"]|[^"])+)"/g), + ]; + if (strMatch.length < 2) continue; + const unicode = JSON.parse(`"${strMatch[0][1]}"`) as string; + const cmd = strMatch[1][1].replace(/^\\+/, ""); + symbolMap[cmd] = unicode; + } + + /** Resolve a macro name to a single Unicode character, following aliases. */ + const resolveToUnicode = ( + name: string, + seen = new Set(), + ): string | undefined => { + if (seen.has(name)) return undefined; + seen.add(name); + if (symbolMap[name]) return symbolMap[name]; + const bodyMatch = macrosSrc.match( + new RegExp( + `defineMacro\\("\\\\\\\\${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}",\\s*"([^"]+)"\\)`, + ), + ); + if (!bodyMatch) return undefined; + const body = bodyMatch[1]; + if (body.startsWith("\\mathrm{") && body.endsWith("}")) { + return body.slice(9, -1); + } + if (body.length === 1 && !body.startsWith("\\")) { + return body; + } + const charMatch = body.match(/\\char[`'"]((?:\\u[0-9a-fA-F]{4}|[^`'"]+))/); + if (charMatch) { + return decodeChar(charMatch[1]); + } + if (body.startsWith("\\") && !body.includes("{")) { + return resolveToUnicode(body.replace(/^\\+/, ""), seen); + } + return undefined; + }; + + for (const m of macrosSrc.matchAll( + /defineMacro\("\\\\([^"]+)",\s*"([^"]+)"\)/g, + )) { + const name = m[1]; + if (!/^[a-zA-Z@][a-zA-Z0-9@]*$/.test(name)) continue; + const resolved = resolveToUnicode(name); + if (resolved && [...resolved].length === 1) { + aliasMap[name] = resolved; + } + } + + for (const m of symbolsSrc.matchAll(/defineSymbol\([^\n]+\)/g)) { + if (!m[0].includes(", accent,")) continue; + const strMatch = [ + ...m[0].matchAll(/"((?:\\u[0-9a-fA-F]{4}|\\[^"]|[^"])+)"/g), + ]; + if (strMatch.length < 2) continue; + const chr = JSON.parse(`"${strMatch[0][1]}"`) as string; + const cmd = strMatch[1][1].replace(/^\\+/, ""); + accentMap[cmd] = chr; + } + + for (const m of macrosSrc.matchAll( + /defineMacro\("\\\\([^"]+)",\s*"\\html@mathml\{[^}]+\}\{[^}]*\\char[`'"]((?:\\u[0-9a-fA-F]{4}|[^`'"]+))/g, + )) { + const resolved = decodeChar(m[2]); + if (resolved && [...resolved].length === 1) { + overrideMap[m[1]] = resolved; + } + } + for (const m of macrosSrc.matchAll( + /defineMacro\("\\\\(q?quad)",\s*"\\\\hskip(\d+)em/g, + )) { + overrideMap[m[1]] = m[1] === "qquad" ? "\u2003\u2003" : "\u2003"; + } + for (const m of macrosSrc.matchAll( + /defineMacro\("(\\u[0-9a-fA-F]{4})",\s*"\\\\([^"]+)"\)/g, + )) { + const unicode = JSON.parse(`"${m[1]}"`) as string; + const target = `\\${m[2]}`; + if (!SIMPLE_MACRO.test(target) || unicode === "\uFE0F") continue; + const cmd = m[2]; + const resolved = resolveToUnicode(cmd) ?? unicode; + if ([...resolved].length === 1) { + overrideMap[cmd] = resolved; + } + } + for (const m of macrosSrc.matchAll( + /defineMacro\("\\\\([^"]+)",\s*"([^"]+)"\)/g, + )) { + const name = m[1]; + if (!/^[a-zA-Z@][a-zA-Z0-9@]*$/.test(name)) continue; + if (symbolMap[name] || aliasMap[name] || overrideMap[name]) continue; + const resolved = resolveToUnicode(name); + if (resolved && [...resolved].length === 1) { + overrideMap[name] = resolved; + } + } + + if (overrideMap.neq) overrideMap.ne = overrideMap.neq; + if (symbolMap["@cdots"]) overrideMap.cdots = symbolMap["@cdots"]; + + const lookupMap: Record = { + ...aliasMap, + ...symbolMap, + ...overrideMap, + }; + + const fnSet = new Set(); + const limitsTextSet = new Set(); + const naryOps: Record = {}; + const integralOps: Record = {}; + const excluded: Record = { ...EXCLUDED_OPS }; + + /** Resolve a command name to its n-ary accent character via lookupMap. */ + const resolveAccent = (cmd: string): string | undefined => lookupMap[cmd]; + + let blockIdx = 0; + let nextBlockIdx = opSrc.indexOf("defineFunction({", blockIdx); + while (nextBlockIdx !== -1) { + blockIdx = nextBlockIdx; + const blockEnd = opSrc.indexOf("});", blockIdx); + const block = opSrc.slice(blockIdx, blockEnd); + const parsed = parseOpBlock(block); + if (parsed) { + const { limits, symbol, names } = parsed; + for (const name of names) { + if (EXCLUDED_OPS[name]) continue; + + if (limits && symbol) { + const accent = resolveAccent(name); + if (accent) { + naryOps[name] = { accent }; + } else { + excluded[name] = "no resolvable accent in KATEX_SYMBOLS"; + } + } else if (!limits && symbol) { + const accent = resolveAccent(name); + if (accent) { + integralOps[name] = { accent, limitLocationVal: "subSup" }; + } else { + excluded[name] = "no resolvable accent in KATEX_SYMBOLS"; + } + } else if (limits && !symbol) { + limitsTextSet.add(name); + } else { + fnSet.add(name); + } + } + } + blockIdx = blockEnd; + nextBlockIdx = opSrc.indexOf("defineFunction({", blockIdx); + } + + for (const m of macrosSrc.matchAll(/defineMacro\("\\\\(liminf|limsup)",/g)) { + limitsTextSet.add(m[1]); + } + + for (const name of limitsTextSet) { + fnSet.delete(name); + } + for (const name of Object.keys(naryOps)) { + fnSet.delete(name); + } + for (const name of Object.keys(integralOps)) { + fnSet.delete(name); + } + fnSet.delete("mathop"); + + const lookupLines = Object.entries(lookupMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`) + .join("\n"); + + const sourceNote = `KaTeX v${KATEX_VERSION} — regenerate via \`${REGENERATE_CMD}\` (fetches from ${KATEX_BASE}).`; + const functions = [...fnSet].sort(); + const formatNAryOps = (ops: Record): string => + Object.entries(ops) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => { + const loc = v.limitLocationVal + ? `, limitLocationVal: ${JSON.stringify(v.limitLocationVal)}` + : ""; + return ` ${JSON.stringify(k)}: { accent: ${JSON.stringify(v.accent)}${loc} },`; + }) + .join("\n"); + + writeFileSync( + join(ROOT, "src/katexData.ts"), + [ + `/** ${sourceNote} */`, + `export const KATEX_SYMBOLS: Record = {`, + lookupLines, + `};`, + ``, + `export const KATEX_ACCENTS = ${JSON.stringify(accentMap)} as Record;`, + ``, + `export const KATEX_FUNCTIONS = new Set(${JSON.stringify(functions)});`, + ``, + `export type KatexNAryOp = { accent: string; limitLocationVal?: "subSup" };`, + ``, + `export const KATEX_NARY_OPS: Record = {`, + formatNAryOps(naryOps), + `};`, + ``, + `export const KATEX_INTEGRAL_OPS: Record = {`, + formatNAryOps(integralOps), + `};`, + ``, + `export const KATEX_LIMITS_TEXT_OPS = new Set(${JSON.stringify([...limitsTextSet].sort())});`, + ``, + ].join("\n"), + ); + + console.log(`KATEX_SYMBOLS: ${Object.keys(lookupMap).length} (merged)`); + console.log(` base symbols: ${Object.keys(symbolMap).length}`); + console.log(` aliases: ${Object.keys(aliasMap).length}`); + console.log(` overrides: ${Object.keys(overrideMap).length}`); + console.log(`KATEX_ACCENTS: ${Object.keys(accentMap).length}`); + console.log(`KATEX_FUNCTIONS: ${fnSet.size}`); + console.log(`KATEX_NARY_OPS: ${Object.keys(naryOps).length}`); + console.log(`KATEX_INTEGRAL_OPS: ${Object.keys(integralOps).length}`); + console.log(`KATEX_LIMITS_TEXT_OPS: ${limitsTextSet.size}`); + if (Object.keys(excluded).length > 0) { + console.log("Excluded ops:"); + for (const [cmd, reason] of Object.entries(excluded).sort(([a], [b]) => + a.localeCompare(b), + )) { + console.log(` ${cmd}: ${reason}`); + } + } +}; + +generate().catch((error) => { + console.error(error); + throw error; +}); diff --git a/lib/src/index.ts b/lib/src/index.ts index ecae47b..8336137 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -4,6 +4,17 @@ import type * as latex from "@unified-latex/unified-latex-types"; // skipcq: JS-C1003 import type * as DOCX from "docx"; import { parseMath } from "latex-math"; +import { + KATEX_ACCENTS, + KATEX_FUNCTIONS, + KATEX_INTEGRAL_OPS, + KATEX_LIMITS_TEXT_OPS, + KATEX_NARY_OPS, + KATEX_SYMBOLS, + type KatexNAryOp, +} from "./katexData"; + +type DocxApi = typeof DOCX; /** * Checks if the argument has curly brackets. @@ -13,185 +24,489 @@ const hasCurlyBrackets = ( ): arg is latex.Argument => Boolean(arg && arg.openMark === "{" && arg.closeMark === "}"); -/** convert to MathRun */ -const mapString = (docx: typeof DOCX, s: string): DOCX.MathRun => - new docx.MathRun(s); - -const LATEX_SYMBOLS: Record = { - textasciitilde: "~", - textasciicircum: "^", - textbackslash: "∖", - textbar: "|", - textless: "<", - textgreater: ">", - neq: "≠", - sim: "∼", - simeq: "≃", - approx: "≈", - fallingdotseq: "≒", - risingdotseq: "≓", - equiv: "≡", - geq: "≥", - geqq: "≧", - leq: "≤", - leqq: "≦", - gg: "≫", - ll: "≪", - times: "×", - div: "÷", - pm: "±", - mp: "∓", - oplus: "⊕", - ominus: "⊖", - otimes: "⊗", - oslash: "⊘", - circ: "∘", - cdot: "⋅", - bullet: "∙", - ltimes: "⋉", - rtimes: "⋊", - in: "∈", - ni: "∋", - notin: "∉", - subset: "⊂", - supset: "⊃", - subseteq: "⊆", - supseteq: "⊇", - nsubseteq: "⊈", - nsupseteq: "⊉", - subsetneq: "⊊", - supsetneq: "⊋", - cap: "∩", - cup: "∪", - emptyset: "∅", - infty: "∞", - partial: "∂", - aleph: "ℵ", - hbar: "ℏ", - wp: "℘", - Re: "ℜ", - Im: "ℑ", - alpha: "α", - beta: "β", - gamma: "γ", - delta: "δ", - epsilon: "ϵ", - zeta: "ζ", - eta: "η", - theta: "θ", - iota: "ι", - kappa: "κ", - lambda: "λ", - mu: "μ", - nu: "ν", - xi: "ξ", - pi: "π", - rho: "ρ", - sigma: "σ", - tau: "τ", - upsilon: "υ", - phi: "ϕ", - chi: "χ", - psi: "ψ", - omega: "ω", - varepsilon: "ε", - vartheta: "ϑ", - varrho: "ϱ", - varsigma: "ς", - varphi: "φ", - Gamma: "Γ", - Delta: "Δ", - Theta: "Θ", - Lambda: "Λ", - Xi: "Ξ", - Pi: "Π", - Sigma: "Σ", - Upsilon: "Υ", - Phi: "Φ", - Psi: "Ψ", - Omega: "Ω", - int: "∫", - oint: "∮", - prod: "∏", - coprod: "∐", - sum: "∑", - log: "log", - exp: "exp", - lim: "lim", - inf: "∞", - perp: "⊥", - and: "∧", - or: "∨", - not: "¬", - to: "→", - gets: "⟹", - implies: "⟹", - impliedby: "⟸", - forall: "∀", - exists: "∃", - empty: "∅", - nabla: "∇", - top: "⊤", - bot: "⊥", - angle: "∠", - backslash: "∖", - neg: "¬", - lnot: "¬", - flat: "♭", - natural: "♮", - sharp: "♯", - clubsuit: "♣", - diamondsuit: "♦", - heartsuit: "♥", - spadesuit: "♠", - varnothing: "∅", - S: "∖", - P: "∏", - bigcap: "⋀", - bigcup: "⋁", - bigwedge: "⊓", - bigvee: "⊔", - bigsqcap: "⊓", - bigsqcup: "⊔", - biguplus: "⊕", - bigoplus: "⊕", - bigotimes: "⊗", - bigodot: "⊙", - biginterleave: "⊺", - bigtimes: "⨯", +/** Pending n-ary operator awaiting limits and/or integrand body. */ +type PendingNAry = { + kind: "nary"; + accent: string; + limitLocationVal?: string; + sub: DOCX.MathRun[]; + sup: DOCX.MathRun[]; + body: MathComponent[]; +}; + +/** Pending accent awaiting its base token. */ +type PendingAccent = { + kind: "accent"; + accentChar: string; +}; + +/** Pending limits-text operator awaiting a lower limit via subscript. */ +type PendingLimitsTextOp = { + kind: "limitsText"; + name: string; +}; + +/** Partial script node for chained sub/superscript attachment. */ +type PendingScript = + | { + kind: "script"; + variant: "sub"; + base: DOCX.MathRun; + sub: DOCX.MathRun[]; + } + | { + kind: "script"; + variant: "sup"; + base: DOCX.MathRun; + sup: DOCX.MathRun[]; + } + | { + kind: "script"; + variant: "both"; + base: DOCX.MathRun; + sub: DOCX.MathRun[]; + sup: DOCX.MathRun[]; + }; + +type PendingMarker = + | PendingNAry + | PendingAccent + | PendingLimitsTextOp + | PendingScript; + +type BinomState = + | { phase: "idle" } + | { phase: "needFirst" } + | { phase: "needSecond"; numerator: DOCX.MathRun[] }; + +/** Internal mapping state: OMML runs plus binomial context. */ +type MapContext = { + runs: MathComponent[]; + binom: BinomState; +}; + +type MathComponent = DOCX.MathRun | PendingMarker; + +type MapNodeResult = + | { type: "continue"; components: MathComponent[] } + | { type: "break" }; + +type NAryBuild = { + accent: string; + limitLocationVal?: string; + children: DOCX.MathRun[]; + subScript: DOCX.MathRun[]; + superScript: DOCX.MathRun[]; +}; + +/** Cast custom OMML XmlComponents to MathRun for docx library interop. */ +const asMathRun = (component: DOCX.XmlComponent): DOCX.MathRun => + component as unknown as DOCX.MathRun; + +/** Build an OMML math run with plain text content. */ +const makeMathRun = (docx: DocxApi, text: string): DOCX.MathRun => + new docx.MathRun(text); + +const PLUGIN_ID = "@m2d/math"; + +/** Log and skip inline/block math that would emit empty OMML. */ +const logSkippedEmptyMath = (latex: string, scope: "inline" | "block") => { + console.error( + `[${PLUGIN_ID}] Skipping empty ${scope} math for ${JSON.stringify(latex)}; no renderable OMML was produced. Empty elements break Microsoft Word.`, + ); +}; + +/** Resolve a LaTeX command name to its Unicode symbol. */ +const resolveLatexSymbol = (name: string): string | undefined => + KATEX_SYMBOLS[name]; + +const isMathRun = (node: MathComponent): node is DOCX.MathRun => + !("kind" in node); + +const isPendingNAry = (node: MathComponent | undefined): node is PendingNAry => + Boolean(node && "kind" in node && node.kind === "nary"); + +const isPendingAccent = ( + node: MathComponent | undefined, +): node is PendingAccent => + Boolean(node && "kind" in node && node.kind === "accent"); + +const isPendingLimitsTextOp = ( + node: MathComponent | undefined, +): node is PendingLimitsTextOp => + Boolean(node && "kind" in node && node.kind === "limitsText"); + +const isPendingScript = ( + node: MathComponent | undefined, +): node is PendingScript => + Boolean(node && "kind" in node && node.kind === "script"); + +/** OMML accent chars must be combining marks (U+0300–U+036F, U+20D0–U+20EF). */ +const OMML_ACCENT_CHARS: Record = { + hat: "\u0302", + widehat: "\u0302", + tilde: "\u0303", + widetilde: "\u0303", + bar: "\u0304", + overline: "\u0305", + dot: "\u0307", + ddot: "\u0308", + vec: "\u20D7", + acute: "\u0301", + grave: "\u0300", + breve: "\u0306", + check: "\u030C", + mathring: "\u030A", +}; + +/** Map KaTeX accent glyphs to OMML combining marks. */ +const KATEX_GLYPH_TO_OMML: Record = { + ˆ: "\u0302", + "^": "\u0302", + "˜": "\u0303", + "~": "\u0303", + ˉ: "\u0304", + "¯": "\u0305", + "˙": "\u0307", + "¨": "\u0308", + ˊ: "\u0301", + ˋ: "\u0300", + "⃗": "\u20D7", + "˘": "\u0306", + ˇ: "\u030C", + "˚": "\u030A", +}; + +/** Resolve accent character for a LaTeX accent command name. */ +const resolveAccentChar = (name: string): string | undefined => { + const omml = OMML_ACCENT_CHARS[name]; + if (omml) return omml; + const katexGlyph = KATEX_ACCENTS[name]; + if (katexGlyph) return KATEX_GLYPH_TO_OMML[katexGlyph]; + return undefined; +}; + +const resolveNAryOp = (name: string): KatexNAryOp | undefined => + KATEX_INTEGRAL_OPS[name] ?? KATEX_NARY_OPS[name]; + +/** True when a macro name maps to an OMML accent combining mark. */ +const isAccentCommand = (name: string): boolean => + resolveAccentChar(name) !== undefined; + +/** String nodes may contain unparsed scripts when nested inside braced groups. */ +const UNPARSED_MATH_IN_STRING = /[\^_]|\\[a-zA-Z]/; + +const mapStringNode = (docx: DocxApi, content: string): DOCX.MathRun[] => + UNPARSED_MATH_IN_STRING.test(content) + ? mapGroup(docx, parseMath(content)) + : [makeMathRun(docx, content)]; + +/** Build an OMML n-ary operator element. */ +const buildNAry = (docx: DocxApi, options: NAryBuild): DOCX.MathRun => { + class MathNAry extends docx.XmlComponent { + constructor() { + super("m:nary"); + // OOXML requires m:sub, m:sup, and m:e in fixed order; all three must + // always be present. Always report both limits so docx does not emit + // subHide/supHide (which it orders incorrectly in naryPr). + this.root.push( + docx.createMathNAryProperties({ + accent: options.accent, + hasSuperScript: true, + hasSubScript: true, + limitLocationVal: options.limitLocationVal, + }), + ); + this.root.push( + docx.createMathSubScriptElement({ + children: options.subScript, + }), + ); + this.root.push( + docx.createMathSuperScriptElement({ + children: options.superScript, + }), + ); + this.root.push(docx.createMathBase({ children: options.children })); + } + } + return asMathRun(new MathNAry()); +}; + +/** Build an OMML accent element (m:acc) wrapping base content. */ +const buildMathAccent = ( + docx: DocxApi, + accent: string, + children: DOCX.MathRun[], +): DOCX.MathRun => { + class MathAccent extends docx.XmlComponent { + constructor() { + super("m:acc"); + this.root.push( + new docx.BuilderElement({ + name: "m:accPr", + children: [docx.createMathAccentCharacter({ accent })], + }), + ); + this.root.push(docx.createMathBase({ children })); + } + } + return asMathRun(new MathAccent()); +}; + +/** Resolve accent base content from a macro's first braced argument. */ +const accentChildrenFromArgs = ( + docx: DocxApi, + args: latex.Argument[] | undefined, +): DOCX.MathRun[] => + hasCurlyBrackets(args?.[0]) ? mapGroup(docx, args[0].content) : []; + +/** Build an accent node, deferring base content when the parser omits braced args. */ +const mapAccentMacro = ( + docx: DocxApi, + name: string, + args: latex.Argument[] | undefined, +): MathComponent => { + const accentChar = resolveAccentChar(name); + if (!accentChar) { + return makeMathRun(docx, name); + } + const children = accentChildrenFromArgs(docx, args); + return children.length + ? buildMathAccent(docx, accentChar, children) + : { kind: "accent", accentChar }; +}; + +/** Create an n-ary operator placeholder that accepts limits and a body later. */ +const createPendingNAry = ( + accent: string, + limitLocationVal?: string, +): PendingNAry => ({ + kind: "nary", + accent, + limitLocationVal, + sub: [], + sup: [], + body: [], +}); + +/** Characters that end an n-ary integrand (e.g. `\int ... dx =`). */ +const terminatesNAryBody = (content: string): boolean => + content === "=" || content === "," || content === ";"; + +const finalizeBodyRuns = ( + docx: DocxApi, + body: MathComponent[], +): DOCX.MathRun[] => + body.map((component) => + isMathRun(component) ? component : finalizeComponent(docx, component), + ); + +const finalizeTrailingPendingScriptInBody = ( + docx: DocxApi, + prev: PendingNAry, +): PendingNAry => { + const body = [...prev.body]; + const last = body[body.length - 1]; + if (last && isPendingScript(last)) { + body[body.length - 1] = finalizeScript(docx, last); + } + return { ...prev, body }; +}; + +const appendToNAryBody = ( + docx: DocxApi, + prev: PendingNAry, + items: MathComponent[], +): PendingNAry => { + const nary = finalizeTrailingPendingScriptInBody(docx, prev); + const body = [...nary.body]; + const lastBody = body[body.length - 1]; + const mathRuns = items.filter(isMathRun); + + if (isPendingAccent(lastBody) && mathRuns.length === items.length) { + body.pop(); + body.push(buildMathAccent(docx, lastBody.accentChar, mathRuns)); + return { ...nary, body }; + } + + return { ...nary, body: [...body, ...items] }; +}; + +const applyScriptToNAryBody = ( + docx: DocxApi, + prev: PendingNAry, + variant: "sub" | "sup", + script: DOCX.MathRun[], +): PendingNAry => { + const body = [...prev.body]; + const last = body.pop(); + if (!last) return prev; + + let updated: MathComponent; + if (isPendingScript(last)) { + if (variant === "sup") { + updated = + last.variant === "sub" + ? finalizeScript(docx, { + kind: "script", + variant: "both", + base: last.base, + sub: last.sub, + sup: script, + }) + : finalizeScript(docx, last); + } else { + updated = + last.variant === "sup" + ? finalizeScript(docx, { + kind: "script", + variant: "both", + base: last.base, + sub: script, + sup: last.sup, + }) + : finalizeScript(docx, last); + } + } else if (isMathRun(last)) { + updated = + variant === "sup" + ? { kind: "script", variant: "sup", base: last, sup: script } + : { kind: "script", variant: "sub", base: last, sub: script }; + } else { + body.push(last); + return prev; + } + + body.push(updated); + return { ...prev, body }; +}; + +const finalizePendingNAry = (docx: DocxApi, prev: PendingNAry): DOCX.MathRun => + finalizeNAry(docx, prev, finalizeBodyRuns(docx, prev.body)); + +const attachNArySub = ( + prev: PendingNAry, + subScript: DOCX.MathRun[], +): PendingNAry => ({ ...prev, sub: subScript }); + +const attachNArySup = ( + prev: PendingNAry, + superScript: DOCX.MathRun[], +): PendingNAry => ({ ...prev, sup: superScript }); + +const finalizeNAry = ( + docx: DocxApi, + prev: PendingNAry, + children: DOCX.MathRun[], +): DOCX.MathRun => + buildNAry(docx, { + accent: prev.accent, + limitLocationVal: prev.limitLocationVal, + children, + subScript: prev.sub, + superScript: prev.sup, + }); + +const isScriptMacro = (node: latex.Node): boolean => + node.type === "macro" && (node.content === "_" || node.content === "^"); + +/** Finalize a trailing script marker before processing the next non-script node. */ +const finalizeTrailingPendingScript = ( + docx: DocxApi, + ctx: MapContext, +): void => { + const last = ctx.runs[ctx.runs.length - 1]; + if (isPendingScript(last)) { + ctx.runs[ctx.runs.length - 1] = finalizeScript(docx, last); + } }; +/** Convert unfinalized internal markers to OMML for output. */ +const finalizeComponent = ( + docx: DocxApi, + component: MathComponent, +): DOCX.MathRun => { + if (isMathRun(component)) return component; + switch (component.kind) { + case "nary": + return finalizePendingNAry(docx, component); + case "accent": + return buildMathAccent(docx, component.accentChar, []); + case "limitsText": + return makeMathRun(docx, component.name); + case "script": + return finalizeScript(docx, component); + } +}; + +const finalizeScript = ( + docx: DocxApi, + pending: PendingScript, +): DOCX.MathRun => { + switch (pending.variant) { + case "both": + return new docx.MathSubSuperScript({ + subScript: pending.sub, + superScript: pending.sup, + children: [pending.base], + }); + case "sub": + return new docx.MathSubScript({ + children: [pending.base], + subScript: pending.sub, + }); + case "sup": + return new docx.MathSuperScript({ + children: [pending.base], + superScript: pending.sup, + }); + } +}; + +const createMapContext = (): MapContext => ({ + runs: [], + binom: { phase: "idle" }, +}); + /** convert group to Math */ -const mapGroup = (docx: typeof DOCX, nodes: latex.Node[]): DOCX.MathRun[] => { - const group: DOCX.MathRun[] = []; +const mapGroup = (docx: DocxApi, nodes: latex.Node[]): DOCX.MathRun[] => { + const groupCtx = createMapContext(); for (const c of nodes) { - // skipcq: JS-0357 - group.push(...(mapNode(docx, c, group) || [])); + const result = mapNode(docx, c, groupCtx); + if (result.type === "continue") { + groupCtx.runs.push(...result.components); + } } - return group; + return groupCtx.runs.map((c) => + isMathRun(c) ? c : finalizeComponent(docx, c), + ); }; /** Handle Macros */ // skipcq: JS-R1005 const mapMacro = ( - docx: typeof DOCX, + docx: DocxApi, node: latex.Macro, - runs: DOCX.MathRun[], -): DOCX.MathRun[] | DOCX.MathRun | null => { - let returnVal: DOCX.MathRun[] | DOCX.MathRun | null = null; + ctx: MapContext, +): MathComponent[] | MathComponent | null => { + let returnVal: MathComponent[] | MathComponent | null = null; + const { runs } = ctx; switch (node.content) { case "newline": + returnVal = makeMathRun(docx, " "); + break; case "\\": - // line break return null; case "textcolor": { const args = node.args ?? []; - // const _color = (hasCurlyBrackets(args[1]) && args[1]?.content?.[0]?.content) || ""; if (hasCurlyBrackets(args[2])) { returnVal = mapGroup(docx, args[2].content); } break; } + case "color": + return []; case "text": { const args = node.args ?? []; if (hasCurlyBrackets(args[0])) { @@ -203,93 +518,67 @@ const mapMacro = ( const prev = runs.pop(); if (!prev) break; const superScript = mapGroup(docx, node.args?.[0]?.content ?? []); - // @ts-expect-error -- using extra vars - if (prev.isSum) { - const docNode = new docx.MathSum({ - children: [], - superScript, - // @ts-expect-error -- reading extra field - subScript: prev.sub, - }); - - // @ts-expect-error -- attaching extra field - docNode.sub = prev.sub; - // @ts-expect-error -- attaching extra field - docNode.sup = superScript; - // @ts-expect-error -- attaching extra field - docNode.isSum = 1; - return docNode; - // @ts-expect-error -- attaching extra field - } else if (prev.sub) { - return new docx.MathSubSuperScript({ - // @ts-expect-error -- attaching extra field - subScript: prev.sub, - superScript, - // @ts-expect-error -- attaching extra field - children: [prev.prev], - }); + if (isPendingNAry(prev)) { + if (prev.body.length === 0) { + return attachNArySup(prev, superScript); + } + return applyScriptToNAryBody(docx, prev, "sup", superScript); } - const docxNode = new docx.MathSuperScript({ - children: [prev], - superScript, - }); - // @ts-expect-error -- attaching extra field - docxNode.sup = superScript; - // @ts-expect-error -- attaching extra field - docxNode.prev = prev; - return docxNode; + if (isPendingScript(prev)) { + if (prev.variant === "sub") { + return finalizeScript(docx, { + kind: "script", + variant: "both", + base: prev.base, + sub: prev.sub, + sup: superScript, + }); + } + return finalizeScript(docx, prev); + } + if (!isMathRun(prev)) break; + return { + kind: "script", + variant: "sup", + base: prev, + sup: superScript, + }; } case "_": { const prev = runs.pop(); if (!prev) break; const subScript = mapGroup(docx, node.args?.[0]?.content ?? []); - // @ts-expect-error -- attaching extra field - if (prev.isSum) { - const docNode = new docx.MathSum({ - children: [], - subScript, - // @ts-expect-error -- reading extra field - superScript: prev.sup, - }); - // @ts-expect-error -- attaching extra field - docNode.sup = prev.sup; - // @ts-expect-error -- attaching extra field - docNode.sub = subScript; - // @ts-expect-error -- attaching extra field - docNode.isSum = 1; - return docNode; - // @ts-expect-error -- attaching extra field - } else if (prev.sup) { - return new docx.MathSubSuperScript({ - subScript, - // @ts-expect-error -- attaching extra field - superScript: prev.sup, - // @ts-expect-error -- attaching extra field - children: [prev.prev], + if (isPendingLimitsTextOp(prev)) { + return new docx.MathLimitLower({ + children: [makeMathRun(docx, prev.name)], + limit: subScript, }); } - const docxNode = new docx.MathSubScript({ - children: [prev], - subScript, - }); - // @ts-expect-error -- attaching extra field - docxNode.sub = subScript; - // @ts-expect-error -- attaching extra field - docxNode.prev = prev; - return docxNode; - } - case "hat": - case "widehat": - // returnVal = docx.MathAccentCharacter(n) - returnVal = docx.createMathAccentCharacter({ accent: "^" }); - break; - case "sum": { - const docNode = new docx.MathSum({ - children: [], - }); - // @ts-expect-error - extra var - docNode.isSum = 1; - return docNode; + if (isPendingNAry(prev)) { + if (prev.body.length === 0) { + return attachNArySub(prev, subScript); + } + return applyScriptToNAryBody(docx, prev, "sub", subScript); + } + if (isPendingScript(prev)) { + if (prev.variant === "sup") { + return finalizeScript(docx, { + kind: "script", + variant: "both", + base: prev.base, + sub: subScript, + sup: prev.sup, + }); + } + return finalizeScript(docx, prev); + } + if (!isMathRun(prev)) break; + return { + kind: "script", + variant: "sub", + base: prev, + sub: subScript, + }; } case "frac": case "tfrac": @@ -307,6 +596,23 @@ const mapMacro = ( } break; } + case "stackrel": { + const args = node.args ?? []; + if ( + args.length === 2 && + hasCurlyBrackets(args[0]) && + hasCurlyBrackets(args[1]) + ) { + returnVal = new docx.MathLimitUpper({ + children: mapGroup(docx, args[1].content), + limit: mapGroup(docx, args[0].content), + }); + } + break; + } + case "binom": + ctx.binom = { phase: "needFirst" }; + return []; case "sqrt": { const args = node.args ?? []; if (args.length === 1) { @@ -315,7 +621,7 @@ const mapMacro = ( }); } else if (args.length === 2) { returnVal = new docx.MathRadical( - args[0].content + args[0].content?.length ? { children: mapGroup(docx, args[1].content), degree: mapGroup(docx, args[0].content), @@ -327,100 +633,202 @@ const mapMacro = ( } case "left": case "right": - case "vec": + case "boxed": + case "boldsymbol": return []; case "mathbf": return mapGroup(docx, node.args?.[0]?.content ?? []); - default: - returnVal = mapString(docx, LATEX_SYMBOLS[node.content] ?? node.content); + default: { + const naryOp = resolveNAryOp(node.content); + if (naryOp) { + const pending = runs[runs.length - 1]; + if (isPendingNAry(pending)) { + runs.pop(); + runs.push(finalizePendingNAry(docx, pending)); + } + returnVal = createPendingNAry(naryOp.accent, naryOp.limitLocationVal); + } else if (KATEX_LIMITS_TEXT_OPS.has(node.content)) { + returnVal = { kind: "limitsText", name: node.content }; + } else if ( + node.content === "mathrm" || + node.content === "mathit" || + node.content === "textbf" || + node.content === "textit" || + node.content === "underline" || + node.content === "overbrace" || + node.content === "underbrace" + ) { + const args = node.args ?? []; + if (hasCurlyBrackets(args[0])) { + returnVal = mapGroup(docx, args[0].content); + } + } else if (isAccentCommand(node.content)) { + returnVal = mapAccentMacro(docx, node.content, node.args); + } else if (KATEX_FUNCTIONS.has(node.content)) { + returnVal = makeMathRun(docx, node.content); + } else { + returnVal = makeMathRun( + docx, + resolveLatexSymbol(node.content) ?? node.content, + ); + } + } } - // @ts-expect-error -- reading extra field - if (runs[runs.length - 1]?.isSum && returnVal) { - const prev = runs.pop(); - return [ - new docx.MathSum({ - children: Array.isArray(returnVal) ? returnVal : [returnVal], - // @ts-expect-error -- reading extra field - superScript: prev.sup, - // @ts-expect-error -- reading extra field - subScript: prev.sub, - }), - ]; + const last = runs[runs.length - 1]; + if (isPendingNAry(last) && returnVal) { + runs.pop(); + const items = Array.isArray(returnVal) ? returnVal : [returnVal]; + return appendToNAryBody(docx, last, items); } return returnVal; }; +const handleBinomialGroup = ( + docx: DocxApi, + node: latex.Group, + ctx: MapContext, +): MapNodeResult | null => { + if (ctx.binom.phase === "idle") return null; + + const content = mapGroup(docx, node.content); + if (ctx.binom.phase === "needFirst") { + ctx.binom = { phase: "needSecond", numerator: content }; + return { type: "continue", components: [] }; + } + + const { numerator } = ctx.binom; + ctx.binom = { phase: "idle" }; + return { + type: "continue", + components: [ + new docx.MathRoundBrackets({ + children: [ + new docx.MathFraction({ + numerator, + denominator: content, + }), + ], + }), + ], + }; +}; + /** Process node */ const mapNode = ( - docx: typeof DOCX, + docx: DocxApi, node: latex.Node, - runs: DOCX.MathRun[], -): DOCX.MathRun[] | false => { - let docxNodes: DOCX.MathRun[] = []; + ctx: MapContext, +): MapNodeResult => { + if (!isScriptMacro(node)) { + finalizeTrailingPendingScript(docx, ctx); + } + + if (node.type === "group") { + const binomial = handleBinomialGroup(docx, node, ctx); + if (binomial) return binomial; + } + + let docxNodes: MathComponent[] = []; switch (node.type) { case "string": - docxNodes = [mapString(docx, node.content)]; + docxNodes = mapStringNode(docx, node.content); break; case "whitespace": - docxNodes = [mapString(docx, " ")]; + if (isPendingNAry(ctx.runs[ctx.runs.length - 1])) { + return { type: "continue", components: [] }; + } + docxNodes = [makeMathRun(docx, " ")]; break; case "macro": { - const run = mapMacro(docx, node, runs); + const run = mapMacro(docx, node, ctx); if (!run) { - // line break - return false; - } else { - docxNodes = Array.isArray(run) ? run : [run]; + return { type: "break" }; } + docxNodes = Array.isArray(run) ? run : [run]; break; } case "group": docxNodes = mapGroup(docx, node.content); break; case "environment": - // NOT SUPPORTED BY DOCX library break; default: + break; } - // @ts-expect-error -- reading extra field - if (node.type !== "macro" && runs[runs.length - 1]?.isSum) { - const prev = runs.pop(); - return [ - new docx.MathSum({ - children: docxNodes, - // @ts-expect-error -- reading extra field - superScript: prev.sup, - // @ts-expect-error -- reading extra field - subScript: prev.sub, - }), - ]; + const last = ctx.runs[ctx.runs.length - 1]; + if ( + node.type === "string" && + isPendingNAry(last) && + terminatesNAryBody(node.content) + ) { + ctx.runs.pop(); + return { + type: "continue", + components: [ + finalizePendingNAry(docx, last), + ...mapStringNode(docx, node.content), + ], + }; + } + + if ( + node.type !== "macro" && + node.type !== "whitespace" && + isPendingNAry(last) + ) { + ctx.runs.pop(); + return { + type: "continue", + components: [appendToNAryBody(docx, last, docxNodes)], + }; + } + + const pendingAccent = ctx.runs[ctx.runs.length - 1]; + if ( + !isScriptMacro(node) && + node.type !== "whitespace" && + isPendingAccent(pendingAccent) + ) { + ctx.runs.pop(); + return { + type: "continue", + components: [ + buildMathAccent( + docx, + pendingAccent.accentChar, + docxNodes.filter(isMathRun), + ), + ], + }; } - return docxNodes; + return { type: "continue", components: docxNodes }; }; /** Parse latex and convert to DOCX MathRun nodes */ -export const parseLatex = ( - docx: typeof DOCX, - value: string, -): DOCX.MathRun[][] => { +export const parseLatex = (docx: DocxApi, value: string): DOCX.MathRun[][] => { const latexNodes = parseMath(value); - const paragraphs: DOCX.MathRun[][] = [[]]; - let runs: DOCX.MathRun[] = paragraphs[0]; + const paragraphs: MathComponent[][] = [[]]; + let ctx: MapContext = { runs: paragraphs[0], binom: { phase: "idle" } }; for (const node of latexNodes) { - const res = mapNode(docx, node, runs); - if (!res) { - // line break - runs = []; + const result = mapNode(docx, node, ctx); + if (result.type === "break") { + const runs: MathComponent[] = []; paragraphs.push(runs); + ctx = { runs, binom: { phase: "idle" } }; } else { - runs.push(...res); + ctx.runs.push(...result.components); } } - return paragraphs; + + return paragraphs.map((paragraph) => + paragraph.map((component) => + isMathRun(component) ? component : finalizeComponent(docx, component), + ), + ); }; /** @@ -433,19 +841,29 @@ export const mathPlugin: () => IPlugin<{ return { inline: (docx, node) => { if (node.type !== "inlineMath" && node.type !== "math") return []; - (node as unknown as EmptyNode)._type = node.type; + (node as EmptyNode)._type = node.type; node.type = ""; - return [ - new docx.Math({ children: parseLatex(docx, node.value ?? "").flat() }), - ]; + const latex = node.value ?? ""; + const children = parseLatex(docx, latex).flat(); + if (!children.length) { + logSkippedEmptyMath(latex, "inline"); + return []; + } + return [new docx.Math({ children })]; }, block: (docx, node) => { if (node.type !== "math" && node.type !== "inlineMath") return []; node.type = ""; - return parseLatex(docx, node.value ?? "").map( - (runs) => + const latex = node.value ?? ""; + return parseLatex(docx, latex).flatMap((runs) => { + if (!runs.length) { + logSkippedEmptyMath(latex, "block"); + return []; + } + return [ new docx.Paragraph({ children: [new docx.Math({ children: runs })] }), - ); + ]; + }); }, }; }; diff --git a/lib/src/katexData.ts b/lib/src/katexData.ts new file mode 100644 index 0000000..c85c44d --- /dev/null +++ b/lib/src/katexData.ts @@ -0,0 +1,746 @@ +/** KaTeX v0.16.22 — regenerate via `pnpm generate:katex` (fetches from https://raw.githubusercontent.com/KaTeX/KaTeX/v0.16.22/src). */ +export const KATEX_SYMBOLS: Record = { + " ": " ", + _: "_", + "-": "−", + "--": "–", + "---": "—", + ",": ",", + ";": ";", + ":": ":", + "!": "!", + "?": "?", + ".": "˙", + "'": "’", + "''": "”", + "{": "{", + "}": "}", + "@cdots": "⋯", + "@gvertneqq": "", + "@imath": "", + "@jmath": "", + "@llcorner": "└", + "@lrcorner": "┘", + "@lvertneqq": "", + "@ngeqq": "", + "@ngeqslant": "", + "@nleqq": "", + "@nleqslant": "", + "@not": "", + "@nshortmid": "", + "@nshortparallel": "", + "@nsubseteqq": "", + "@nsupseteqq": "", + "@ulcorner": "┌", + "@urcorner": "┐", + "@varsubsetneq": "", + "@varsubsetneqq": "", + "@varsupsetneq": "", + "@varsupsetneqq": "", + "*": "∗", + "&": "&", + "#": "#", + "%": "%", + "`": "‘", + "``": "“", + "^": "ˆ", + "+": "+", + "=": "ˉ", + "|": "∥", + "~": "˜", + $: "$", + acute: "ˊ", + ae: "æ", + AE: "Æ", + alef: "ℵ", + alefsym: "ℵ", + aleph: "ℵ", + alpha: "α", + amalg: "⨿", + And: "&", + angle: "∠", + approx: "≈", + approxeq: "≊", + ast: "∗", + asymp: "≍", + backepsilon: "∍", + backprime: "‵", + backsim: "∽", + backsimeq: "⋍", + backslash: "\\", + bar: "ˉ", + barwedge: "⊼", + because: "∵", + beta: "β", + beth: "ℶ", + between: "≬", + bgroup: "{", + bigcap: "⋂", + bigcirc: "◯", + bigcup: "⋃", + bigodot: "⨀", + bigoplus: "⨁", + bigotimes: "⨂", + bigsqcup: "⨆", + bigstar: "★", + bigtriangledown: "▽", + bigtriangleup: "△", + biguplus: "⨄", + bigvee: "⋁", + bigwedge: "⋀", + blacklozenge: "⧫", + blacksquare: "■", + blacktriangle: "▲", + blacktriangledown: "▼", + blacktriangleleft: "◀", + blacktriangleright: "▶", + bot: "⊥", + bowtie: "⋈", + Box: "□", + boxdot: "⊡", + boxminus: "⊟", + boxplus: "⊞", + boxtimes: "⊠", + breve: "˘", + bull: "∙", + bullet: "∙", + bumpeq: "≏", + Bumpeq: "≎", + c: "¸", + cap: "∩", + Cap: "⋒", + cdot: "⋅", + cdotp: "⋅", + cdots: "⋯", + centerdot: "⋅", + check: "ˇ", + checkmark: "✓", + chi: "χ", + circ: "∘", + circeq: "≗", + circlearrowleft: "↺", + circlearrowright: "↻", + circledast: "⊛", + circledcirc: "⊚", + circleddash: "⊝", + circledR: "®", + circledS: "Ⓢ", + clubs: "♣", + clubsuit: "♣", + coloneqq: "≔", + Coloneqq: "⩴", + complement: "∁", + cong: "≅", + coprod: "∐", + copyright: "©", + cup: "∪", + Cup: "⋓", + curlyeqprec: "⋞", + curlyeqsucc: "⋟", + curlyvee: "⋎", + curlywedge: "⋏", + curvearrowleft: "↶", + curvearrowright: "↷", + dag: "†", + dagger: "†", + Dagger: "‡", + daleth: "ℸ", + darr: "↓", + dArr: "⇓", + Darr: "⇓", + dashleftarrow: "⇠", + dashrightarrow: "⇢", + dashv: "⊣", + dblcolon: "∷", + ddag: "‡", + ddagger: "‡", + ddot: "¨", + ddots: "⋱", + degree: "°", + delta: "δ", + Delta: "Δ", + diagdown: "╲", + diagup: "╱", + diamond: "⋄", + Diamond: "◊", + diamonds: "♢", + diamondsuit: "♢", + digamma: "ϝ", + div: "÷", + divideontimes: "⋇", + dot: "˙", + doteq: "≐", + Doteq: "≑", + doteqdot: "≑", + dotplus: "∔", + doublebarwedge: "⩞", + doublecap: "⋒", + doublecup: "⋓", + downarrow: "↓", + Downarrow: "⇓", + downdownarrows: "⇊", + downharpoonleft: "⇃", + downharpoonright: "⇂", + egroup: "}", + ell: "ℓ", + empty: "∅", + emptyset: "∅", + epsilon: "ϵ", + eqcirc: "≖", + eqcolon: "∹", + eqqcolon: "≕", + eqsim: "≂", + eqslantgtr: "⪖", + eqslantless: "⪕", + equiv: "≡", + eta: "η", + eth: "ð", + exist: "∃", + exists: "∃", + fallingdotseq: "≒", + Finv: "Ⅎ", + flat: "♭", + forall: "∀", + frown: "⌢", + Game: "⅁", + gamma: "γ", + Gamma: "Γ", + ge: "≥", + geq: "≥", + geqq: "≧", + geqslant: "⩾", + gets: "←", + gg: "≫", + ggg: "⋙", + gggtr: "⋙", + gimel: "ℷ", + gnapprox: "⪊", + gneq: "⪈", + gneqq: "≩", + gnsim: "⋧", + grave: "ˋ", + gt: ">", + gtrapprox: "⪆", + gtrdot: "⋗", + gtreqless: "⋛", + gtreqqless: "⪌", + gtrless: "≷", + gtrsim: "≳", + H: "˝", + harr: "↔", + hArr: "⇔", + Harr: "⇔", + hat: "^", + hbar: "ℏ", + hearts: "♡", + heartsuit: "♡", + hookleftarrow: "↩", + hookrightarrow: "↪", + hslash: "ℏ", + i: "ı", + iiint: "∭", + iint: "∬", + Im: "ℑ", + image: "ℑ", + imageof: "⊷", + in: "∈", + infin: "∞", + infty: "∞", + int: "∫", + intercal: "⊺", + intop: "∫", + iota: "ι", + isin: "∈", + j: "ȷ", + Join: "⋈", + kappa: "κ", + lambda: "λ", + Lambda: "Λ", + land: "∧", + lang: "⟨", + langle: "⟨", + larr: "←", + lArr: "⇐", + Larr: "⇐", + lbrace: "{", + lBrace: "⦃", + lbrack: "[", + lceil: "⌈", + ldotp: ".", + ldots: "…", + le: "≤", + leadsto: "⇝", + leftarrow: "←", + Leftarrow: "⇐", + leftarrowtail: "↢", + leftharpoondown: "↽", + leftharpoonup: "↼", + leftleftarrows: "⇇", + leftrightarrow: "↔", + Leftrightarrow: "⇔", + leftrightarrows: "⇆", + leftrightharpoons: "⇋", + leftrightsquigarrow: "↭", + leftthreetimes: "⋋", + leq: "≤", + leqq: "≦", + leqslant: "⩽", + lessapprox: "⪅", + lessdot: "⋖", + lesseqgtr: "⋚", + lesseqqgtr: "⪋", + lessgtr: "≶", + lesssim: "≲", + lfloor: "⌊", + lgroup: "⟮", + lhd: "⊲", + ll: "≪", + llbracket: "⟦", + llcorner: "⌞", + Lleftarrow: "⇚", + lll: "⋘", + llless: "⋘", + lmoustache: "⎰", + lnapprox: "⪉", + lneq: "⪇", + lneqq: "≨", + lnot: "¬", + lnsim: "⋦", + longleftarrow: "⟵", + Longleftarrow: "⟸", + longleftrightarrow: "⟷", + Longleftrightarrow: "⟺", + longmapsto: "⟼", + longrightarrow: "⟶", + Longrightarrow: "⟹", + looparrowleft: "↫", + looparrowright: "↬", + lor: "∨", + lozenge: "◊", + lparen: "(", + lq: "`", + lrarr: "↔", + lrArr: "⇔", + Lrarr: "⇔", + lrcorner: "⌟", + Lsh: "↰", + lt: "<", + ltimes: "⋉", + lvert: "∣", + lVert: "∥", + maltese: "✠", + mapsto: "↦", + mathellipsis: "…", + mathring: "˚", + mathsterling: "£", + measuredangle: "∡", + medspace: ":", + mho: "℧", + mid: "∣", + models: "⊨", + mp: "∓", + mu: "μ", + multimap: "⊸", + nabla: "∇", + natural: "♮", + ncong: "≆", + ne: "≠", + nearrow: "↗", + neg: "¬", + negthinspace: "!", + neq: "≠", + nexists: "∄", + ngeq: "≱", + ngtr: "≯", + ni: "∋", + nleftarrow: "↚", + nLeftarrow: "⇍", + nleftrightarrow: "↮", + nLeftrightarrow: "⇎", + nleq: "≰", + nless: "≮", + nmid: "∤", + nobreakspace: " ", + notin: "∉", + notni: "∌", + nparallel: "∦", + nprec: "⊀", + npreceq: "⋠", + nrightarrow: "↛", + nRightarrow: "⇏", + nsim: "≁", + nsubseteq: "⊈", + nsucc: "⊁", + nsucceq: "⋡", + nsupseteq: "⊉", + ntriangleleft: "⋪", + ntrianglelefteq: "⋬", + ntriangleright: "⋫", + ntrianglerighteq: "⋭", + nu: "ν", + nvdash: "⊬", + nvDash: "⊭", + nVdash: "⊮", + nVDash: "⊯", + nwarrow: "↖", + o: "ø", + O: "Ø", + odot: "⊙", + oe: "œ", + OE: "Œ", + oiiint: "∰", + oiint: "∯", + oint: "∮", + omega: "ω", + Omega: "Ω", + omicron: "ο", + ominus: "⊖", + oplus: "⊕", + ordinarycolon: ":", + origof: "⊶", + oslash: "⊘", + otimes: "⊗", + owns: "∋", + P: "¶", + parallel: "∥", + partial: "∂", + perp: "⊥", + phi: "ϕ", + Phi: "Φ", + pi: "π", + Pi: "Π", + pitchfork: "⋔", + plusmn: "±", + pm: "±", + pounds: "£", + prec: "≺", + precapprox: "⪷", + preccurlyeq: "≼", + preceq: "⪯", + precnapprox: "⪹", + precneqq: "⪵", + precnsim: "⋨", + precsim: "≾", + prime: "′", + prod: "∏", + propto: "∝", + psi: "ψ", + Psi: "Ψ", + qquad: "  ", + quad: " ", + r: "˚", + rang: "⟩", + rangle: "⟩", + rarr: "→", + rArr: "⇒", + Rarr: "⇒", + rbrace: "}", + rBrace: "⦄", + rbrack: "]", + rceil: "⌉", + Re: "ℜ", + real: "ℜ", + restriction: "↾", + rfloor: "⌋", + rgroup: "⟯", + rhd: "⊳", + rho: "ρ", + rightarrow: "→", + Rightarrow: "⇒", + rightarrowtail: "↣", + rightharpoondown: "⇁", + rightharpoonup: "⇀", + rightleftarrows: "⇄", + rightleftharpoons: "⇌", + rightrightarrows: "⇉", + rightsquigarrow: "⇝", + rightthreetimes: "⋌", + risingdotseq: "≓", + rmoustache: "⎱", + rparen: ")", + rq: "'", + rrbracket: "⟧", + Rrightarrow: "⇛", + Rsh: "↱", + rtimes: "⋊", + rvert: "∣", + rVert: "∥", + S: "§", + sdot: "⋅", + searrow: "↘", + sect: "§", + setminus: "∖", + sharp: "♯", + shortmid: "∣", + shortparallel: "∥", + sigma: "σ", + Sigma: "Σ", + sim: "∼", + simeq: "≃", + smallfrown: "⌢", + smallint: "∫", + smallsetminus: "∖", + smallsmile: "⌣", + smile: "⌣", + space: " ", + spades: "♠", + spadesuit: "♠", + sphericalangle: "∢", + sqcap: "⊓", + sqcup: "⊔", + sqsubset: "⊏", + sqsubseteq: "⊑", + sqsupset: "⊐", + sqsupseteq: "⊒", + square: "□", + ss: "ß", + star: "⋆", + sub: "⊂", + sube: "⊆", + subset: "⊂", + Subset: "⋐", + subseteq: "⊆", + subseteqq: "⫅", + subsetneq: "⊊", + subsetneqq: "⫋", + succ: "≻", + succapprox: "⪸", + succcurlyeq: "≽", + succeq: "⪰", + succnapprox: "⪺", + succneqq: "⪶", + succnsim: "⋩", + succsim: "≿", + sum: "∑", + supe: "⊇", + supset: "⊃", + Supset: "⋑", + supseteq: "⊇", + supseteqq: "⫆", + supsetneq: "⊋", + supsetneqq: "⫌", + surd: "√", + swarrow: "↙", + tau: "τ", + textasciicircum: "^", + textasciitilde: "~", + textbackslash: "\\", + textbar: "|", + textbardbl: "∥", + textbraceleft: "{", + textbraceright: "}", + textcircled: "◯", + textdagger: "†", + textdaggerdbl: "‡", + textdegree: "°", + textdollar: "$", + textellipsis: "…", + textemdash: "—", + textendash: "–", + textgreater: ">", + textless: "<", + textquotedblleft: "“", + textquotedblright: "”", + textquoteleft: "‘", + textquoteright: "’", + textregistered: "®", + textsterling: "£", + textunderscore: "_", + therefore: "∴", + theta: "θ", + Theta: "Θ", + thetasym: "ϑ", + thickapprox: "≈", + thicksim: "∼", + thickspace: ";", + thinspace: ",", + tilde: "~", + times: "×", + to: "→", + top: "⊤", + triangle: "△", + triangledown: "▽", + triangleleft: "◃", + trianglelefteq: "⊴", + triangleq: "≜", + triangleright: "▹", + trianglerighteq: "⊵", + twoheadleftarrow: "↞", + twoheadrightarrow: "↠", + u: "˘", + u00f0: "ð", + u0131: "ı", + u0237: "ȷ", + u0391: "A", + u0392: "B", + u0395: "E", + u0396: "Z", + u0397: "H", + u0399: "I", + u039A: "K", + u039C: "M", + u039D: "N", + u039F: "O", + u03A1: "P", + u03A4: "T", + u03A7: "X", + u2102: "C", + u210D: "H", + u210E: "h", + u2115: "N", + u2119: "P", + u211A: "Q", + u211D: "R", + u2124: "Z", + uarr: "↑", + uArr: "⇑", + Uarr: "⇑", + ulcorner: "⌜", + unlhd: "⊴", + unrhd: "⊵", + uparrow: "↑", + Uparrow: "⇑", + updownarrow: "↕", + Updownarrow: "⇕", + upharpoonleft: "↿", + upharpoonright: "↾", + uplus: "⊎", + upsilon: "υ", + Upsilon: "Υ", + upuparrows: "⇈", + urcorner: "⌝", + v: "ˇ", + varepsilon: "ε", + varkappa: "ϰ", + varnothing: "∅", + varphi: "φ", + varpi: "ϖ", + varpropto: "∝", + varrho: "ϱ", + varsigma: "ς", + vartheta: "ϑ", + vartriangle: "△", + vartriangleleft: "⊲", + vartriangleright: "⊳", + varvdots: "⋮", + vdash: "⊢", + vDash: "⊨", + Vdash: "⊩", + vdots: "⋮", + vec: "⃗", + vee: "∨", + veebar: "⊻", + vert: "∣", + Vert: "∥", + Vvdash: "⊪", + wedge: "∧", + weierp: "℘", + wp: "℘", + wr: "≀", + xi: "ξ", + Xi: "Ξ", + yen: "¥", + zeta: "ζ", +}; + +export const KATEX_ACCENTS = { + acute: "ˊ", + grave: "ˋ", + ddot: "¨", + tilde: "~", + bar: "ˉ", + breve: "˘", + check: "ˇ", + hat: "^", + vec: "⃗", + dot: "˙", + mathring: "˚", + "'": "ˊ", + "`": "ˋ", + "^": "ˆ", + "~": "˜", + "=": "ˉ", + u: "˘", + ".": "˙", + c: "¸", + r: "˚", + v: "ˇ", + H: "˝", + textcircled: "◯", +} as Record; + +export const KATEX_FUNCTIONS = new Set([ + "arccos", + "arcctg", + "arcsin", + "arctan", + "arctg", + "arg", + "ch", + "cos", + "cosec", + "cosh", + "cot", + "cotg", + "coth", + "csc", + "ctg", + "cth", + "deg", + "dim", + "exp", + "hom", + "ker", + "lg", + "ln", + "log", + "sec", + "sh", + "sin", + "sinh", + "tan", + "tanh", + "tg", + "th", +]); + +export type KatexNAryOp = { accent: string; limitLocationVal?: "subSup" }; + +export const KATEX_NARY_OPS: Record = { + bigcap: { accent: "⋂" }, + bigcup: { accent: "⋃" }, + bigodot: { accent: "⨀" }, + bigoplus: { accent: "⨁" }, + bigotimes: { accent: "⨂" }, + bigsqcup: { accent: "⨆" }, + biguplus: { accent: "⨄" }, + bigvee: { accent: "⋁" }, + bigwedge: { accent: "⋀" }, + coprod: { accent: "∐" }, + intop: { accent: "∫" }, + prod: { accent: "∏" }, + smallint: { accent: "∫" }, + sum: { accent: "∑" }, +}; + +export const KATEX_INTEGRAL_OPS: Record = { + iiint: { accent: "∭", limitLocationVal: "subSup" }, + iint: { accent: "∬", limitLocationVal: "subSup" }, + int: { accent: "∫", limitLocationVal: "subSup" }, + oiiint: { accent: "∰", limitLocationVal: "subSup" }, + oiint: { accent: "∯", limitLocationVal: "subSup" }, + oint: { accent: "∮", limitLocationVal: "subSup" }, +}; + +export const KATEX_LIMITS_TEXT_OPS = new Set([ + "Pr", + "det", + "gcd", + "inf", + "lim", + "liminf", + "limsup", + "max", + "min", + "sup", +]); diff --git a/lib/tsconfig-build.json b/lib/tsconfig-build.json index eb7efb1..3c6224a 100644 --- a/lib/tsconfig-build.json +++ b/lib/tsconfig-build.json @@ -7,6 +7,6 @@ "emitDeclarationOnly": true, "declarationMap": false }, - "include": ["src"], + "include": ["src/index.ts"], "exclude": ["dist", "node_modules", "**/*.test.*", "**/*.spec.*"] } diff --git a/lib/tsup.config.ts b/lib/tsup.config.ts index aaa6752..0ca18a7 100644 --- a/lib/tsup.config.ts +++ b/lib/tsup.config.ts @@ -6,7 +6,7 @@ export default defineConfig( ({ format: ["cjs", "esm"], target: "es2019", - entry: ["./src/**"], + entry: ["./src/index.ts"], sourcemap: false, clean: !options.watch, bundle: true, diff --git a/package.json b/package.json index 1830d37..da333cb 100644 --- a/package.json +++ b/package.json @@ -35,4 +35,4 @@ "cross-spawn@<6.0.6": ">=6.0.6" } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 653339d..3d8e70b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.9 version: 4.1.9(vitest@4.1.9) + '@xarsh/ooxml-validator': + specifier: ^0.3.0 + version: 0.3.0 docx: specifier: ^9.7.1 version: 9.7.1 @@ -1621,6 +1624,41 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@xarsh/ooxml-validator-darwin-arm64@0.3.0': + resolution: {integrity: sha512-hgxBf6YzMJCvgEKJN3wVmd4KhIkAx0eCl3ODMoi8a9m8o6N7xE0UKHJFjAku0WhAdbVrZN4w3K5oUVLCC6GnTA==} + cpu: [arm64] + os: [darwin] + + '@xarsh/ooxml-validator-darwin-x64@0.3.0': + resolution: {integrity: sha512-Mj0IEDx4lnDbz+cWqhbyQ3zrJTrtcOUhgU13qXgM4m1yS+SNhsojwfIw6NfPjeI6QTvh5rq9oocgewBe7W9LYw==} + cpu: [x64] + os: [darwin] + + '@xarsh/ooxml-validator-linux-arm64@0.3.0': + resolution: {integrity: sha512-5D8M0PF8J3eIrnUYNiHfOKyjiwnTkWRfcmUMrwMsqzSTBX0LmTRa1QjbIpAwTjKalkGq8nzutSO6eu/UmNothg==} + cpu: [arm64] + os: [linux] + + '@xarsh/ooxml-validator-linux-x64@0.3.0': + resolution: {integrity: sha512-GltV9YzcOwLdxhTvw9MIw1kgMbsjnpUvYYaKovDUwMcvwIOlb72Lealp4mZRugLDS1XRDqHbjoChL6Ko+awLpw==} + cpu: [x64] + os: [linux] + + '@xarsh/ooxml-validator-win32-arm64@0.3.0': + resolution: {integrity: sha512-mx7RqjopCZ3mNVlNyPar935FbMA61hOCyIA854L/3trLt4vmj62/spSfjkpgyKx6TKq5/HXTjMf+rNM/WKayaw==} + cpu: [arm64] + os: [win32] + + '@xarsh/ooxml-validator-win32-x64@0.3.0': + resolution: {integrity: sha512-VflQjYjMfq6Up3sclSiVL5p2dVz17RrubZF+QqXhoWCiOPlMlaMN07hx99J6Ngh6TPboAK6TjcuG8DlGIhcp0w==} + cpu: [x64] + os: [win32] + + '@xarsh/ooxml-validator@0.3.0': + resolution: {integrity: sha512-CaAu5dGQj8YKarMUxGMkxnG3ajNQ9IKEYQWsZ2/bZdoE85HVqmLTtfqMySMa0iLHdyRJ6cS7bhF+iMoBpuT6fw==} + engines: {node: '>=18'} + hasBin: true + acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} @@ -4472,6 +4510,33 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@xarsh/ooxml-validator-darwin-arm64@0.3.0': + optional: true + + '@xarsh/ooxml-validator-darwin-x64@0.3.0': + optional: true + + '@xarsh/ooxml-validator-linux-arm64@0.3.0': + optional: true + + '@xarsh/ooxml-validator-linux-x64@0.3.0': + optional: true + + '@xarsh/ooxml-validator-win32-arm64@0.3.0': + optional: true + + '@xarsh/ooxml-validator-win32-x64@0.3.0': + optional: true + + '@xarsh/ooxml-validator@0.3.0': + optionalDependencies: + '@xarsh/ooxml-validator-darwin-arm64': 0.3.0 + '@xarsh/ooxml-validator-darwin-x64': 0.3.0 + '@xarsh/ooxml-validator-linux-arm64': 0.3.0 + '@xarsh/ooxml-validator-linux-x64': 0.3.0 + '@xarsh/ooxml-validator-win32-arm64': 0.3.0 + '@xarsh/ooxml-validator-win32-x64': 0.3.0 + acorn@8.17.0: {} ansi-colors@4.1.3: {} diff --git a/sample.md b/sample.md index a3cea26..24693d7 100644 --- a/sample.md +++ b/sample.md @@ -48,13 +48,16 @@ Here are some common mathematical symbols: - Fractions: $\frac{1}{2}$, $\frac{x+y}{z}$ - Square roots: $\sqrt{x}$, $\sqrt[3]{y}$ - Summations and products: $\sum_{i=1}^n i$, $\prod_{j=1}^m j$ -- Integrals: $\int_a^b f(x) dx$, $\oint_C \vec{F} \cdot d\vec{r}$ -- Limits: $\lim_{x \to \infty} \frac{1}{x}$ +- Integrals: $\int_a^b f(x) dx$, $\int_0^1 f(x)\,dx$, $\oint_C \vec{F} \cdot d\vec{r}$ +- Limits: $\lim_{x \to \infty} \frac{1}{x}$, $\lim_{n \to \infty} a_n$ - Vectors: $\vec{v}$, $\mathbf{v}$ +- Accents: $\tilde{x}$, $\bar{x}$, $\overline{AB}$, $\hat{x}$ - Matrices: $\begin{pmatrix} a & b \\ c & d \end{pmatrix}$ - Partial derivatives: $\frac{\partial f}{\partial x}$ - Infinity: $\infty$ -- Logical symbols: $\forall$, $\exists$, $\in$, $\notin$, $\subseteq$, $\supseteq$, $\land$, $\lor$, $\neg$ +- Logical symbols: $\forall$, $\exists$, $\in$, $\notin$, $\subseteq$, $\supseteq$, $\land$, $\lor$, $\neg$, $\wedge$, $\ne$, $\triangle ABC$, $\cdots$ +- Binomial coefficients: $\binom{n}{k}$ +- Stackrel: $\stackrel{\mathrm{def}}{=}$ - Trigonometric functions: $\sin(x)$, $\cos(y)$, $\tan(z)$ - Exponential and logarithmic functions: $e^x$, $\ln(y)$, $\log_{10}(z)$ @@ -140,18 +143,6 @@ $$ \text{Let } x \text{ be a real number.} $$ -### Colored Math - -You can color math expressions using the `\textcolor{color}{math}` command: - -$$ -\textcolor{red}{E=mc^2} -$$ - -$$ -\textcolor{blue}{\sum_{i=1}^n i} -$$ - ### Math Macros You can define custom macros: