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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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*

Expand Down
5 changes: 5 additions & 0 deletions lib/__tests__/fixtures/accents/hat-tilde-bar-vec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Accents: $\tilde{x}$, $\bar{x}$, $\hat{x}$, $\vec{v}$

$$
\hat{x}
$$
5 changes: 5 additions & 0 deletions lib/__tests__/fixtures/accents/overline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Overline: $\overline{AB}$

$$
\overline{AB}
$$
5 changes: 5 additions & 0 deletions lib/__tests__/fixtures/basic/block-equation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
A simple block equation:

$$
x + y = z
$$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/basic/inline-equation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Einstein's mass-energy equivalence: $E = mc^2$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/basic/inline-variable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A single variable: $x$
3 changes: 3 additions & 0 deletions lib/__tests__/fixtures/basic/plain-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Hello world.

This paragraph has no math.
13 changes: 13 additions & 0 deletions lib/__tests__/fixtures/display/multiple-block-equations.md
Original file line number Diff line number Diff line change
@@ -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}
$$
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions lib/__tests__/fixtures/inline/mixed-inline.md
Original file line number Diff line number Diff line change
@@ -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}$.
18 changes: 18 additions & 0 deletions lib/__tests__/fixtures/lists/common-symbols-list.md
Original file line number Diff line number Diff line change
@@ -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)$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/operators/contour-integral.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Contour integral: $\oint_C \vec{F} \cdot d\vec{r}$
5 changes: 5 additions & 0 deletions lib/__tests__/fixtures/operators/fraction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fractions: $\frac{1}{2}$, $\frac{x+y}{z}$, and a block form:

$$
\frac{a^2 + b^2}{c^2} = 1
$$
5 changes: 5 additions & 0 deletions lib/__tests__/fixtures/operators/integral-definite.md
Original file line number Diff line number Diff line change
@@ -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}
$$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/operators/integral-indefinite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Indefinite integral with bounds: $\int_a^b f(x)\,dx$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/operators/nth-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Nth root: $\sqrt[3]{y}$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/operators/product.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Product notation: $\prod_{j=1}^{m} j$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/operators/sqrt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Square roots: $\sqrt{x}$ and nested $\sqrt{a^2 + b^2}$
5 changes: 5 additions & 0 deletions lib/__tests__/fixtures/operators/summation.md
Original file line number Diff line number Diff line change
@@ -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}
$$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/scripts/superscripts-subscripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Superscripts and subscripts: $x^2$, $y_i$, $a^{b+c}$, $e^{-i\omega t}$
5 changes: 5 additions & 0 deletions lib/__tests__/fixtures/structures/binomial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Binomial coefficient: $\binom{n}{k}$

$$
\binom{n}{k}
$$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/structures/limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Limits: $\lim_{x \to \infty} \frac{1}{x}$, $\lim_{n \to \infty} a_n$
7 changes: 7 additions & 0 deletions lib/__tests__/fixtures/structures/parentheses-fraction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Bold vector: $\mathbf{v}$

Parentheses with fraction:

$$
\frac{d}{dx} \left( \frac{1}{x} \right) = -\frac{1}{x^2}
$$
5 changes: 5 additions & 0 deletions lib/__tests__/fixtures/structures/stackrel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Stackrel: $\stackrel{\mathrm{def}}{=}$

$$
\stackrel{\mathrm{def}}{=}
$$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/symbols/exponential-logarithmic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Exponential and logarithmic functions: $e^x$, $\ln(y)$, $\log_{10}(z)$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/symbols/greek-letters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Greek letters: $\alpha$, $\beta$, $\gamma$, $\Gamma$, $\Delta$, $\pi$, $\Pi$, $\Sigma$, $\omega$, $\Omega$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/symbols/logical-symbols.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Logical symbols: $\forall$, $\exists$, $\in$, $\notin$, $\subseteq$, $\supseteq$, $\land$, $\lor$, $\neg$, $\wedge$, $\ne$
7 changes: 7 additions & 0 deletions lib/__tests__/fixtures/symbols/misc-symbols.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Partial derivative: $\frac{\partial f}{\partial x}$

Infinity: $\infty$

Triangle notation: $\triangle ABC$

Dots: $\cdots$
1 change: 1 addition & 0 deletions lib/__tests__/fixtures/symbols/trigonometric.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Trigonometric functions: $\sin(x)$, $\cos(y)$, $\tan(z)$
7 changes: 7 additions & 0 deletions lib/__tests__/fixtures/text/text-in-math.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Text within math:

$$
\text{Let } x \text{ be a real number.}
$$

Inline with text macro: $\text{if } x > 0$
9 changes: 9 additions & 0 deletions lib/__tests__/fixtures/text/textcolor.md
Original file line number Diff line number Diff line change
@@ -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}
$$
32 changes: 32 additions & 0 deletions lib/__tests__/fixtures/unsupported/align-and-cases-skipped.md
Original file line number Diff line number Diff line change
@@ -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}
$$
34 changes: 34 additions & 0 deletions lib/__tests__/fixtures/unsupported/matrices-skipped.md
Original file line number Diff line number Diff line change
@@ -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}
$$
123 changes: 123 additions & 0 deletions lib/__tests__/helpers/assert-valid-docx.ts
Original file line number Diff line number Diff line change
@@ -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<typeof toDocx>[0];

export type DocxValidationResult = Awaited<ReturnType<typeof validateFile>>;

/** 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<Buffer> => {
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<DocxValidationResult> => {
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");
Loading