feat(markdown): add LaTeX math support

This commit is contained in:
alam00000 committed 2026-10-26 15:00:18 +05:30
1 parent f7a8a60440
commit 9c796a873c
11 files changed
+1592 -1464

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, beforeEach } from 'vitest';
import MarkdownIt from 'markdown-it';
import { MarkdownEditor } from '@/js/utils/markdown-editor';
import { useMarkdownPlugins } from '@/js/utils/markdown-plugins';
let container: HTMLDivElement;
function preview(markdown?: string): HTMLElement {
new MarkdownEditor(
container,
markdown === undefined ? {} : { initialContent: markdown }
);
return container.querySelector('#mdPreview') as HTMLElement;
}
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
describe('Markdown to PDF rendering', () => {
it('renders every feature of the default sample', () => {
const root = preview();
for (const selector of [
'sub',
'sup',
'mark',
'ins',
'abbr',
'dl dd',
'.footnotes',
'input[type="checkbox"]',
'.katex',
'.katex-display',
'pre code.hljs, pre code .hljs-keyword',
]) {
expect(root.querySelector(selector), selector).not.toBeNull();
}
const tocLinks = root.querySelectorAll('.table-of-contents a');
expect(tocLinks.length).toBeGreaterThan(5);
for (const link of tocLinks) {
const id = decodeURIComponent(link.getAttribute('href')!.slice(1));
expect(document.getElementById(id), id).not.toBeNull();
}
});
it('renders every math delimiter and leaves prices and code alone', () => {
const root = preview(
[
'Dollar $a*b*c$ and paren \\(x^2\\) and escaped \\\\(y\\\\).',
'',
'$$\nx\\,y\n$$',
'',
'\\[\n\\frac{a}{b}\n\\]',
'',
'Mid \\[z\\] and \\\\[w\\\\] here.',
'',
'```math\n\\int x\n```',
'',
'Price $5 and $10, escaped \\$3, code `$q$`.',
].join('\n')
);
expect(root.querySelectorAll('.katex').length).toBe(8);
expect(root.querySelectorAll('.katex-display').length).toBe(5);
expect(root.querySelector('em')).toBeNull();
expect(root.textContent).toContain('Price $5 and $10, escaped $3');
expect(root.querySelector('code')?.textContent).toBe('$q$');
});
it('links accented and duplicate headings from the table of contents', () => {
const root = preview('[[toc]]\n\n## Café & Crème\n\n## Same\n\n## Same');
const hrefs = Array.from(
root.querySelectorAll('.table-of-contents a'),
(a) => decodeURIComponent(a.getAttribute('href')!.slice(1))
);
expect(hrefs).toHaveLength(3);
expect(new Set(hrefs).size).toBe(3);
for (const id of hrefs)
expect(document.getElementById(id), id).not.toBeNull();
});
it('only treats a line holding nothing but the marker as a TOC', () => {
const root = preview('[TOC] is short for table of contents.');
expect(root.querySelector('.table-of-contents')).toBeNull();
expect(root.textContent).toContain('is short for table of contents.');
});
it('still links bare domains', () => {
const root = preview('Visit www.example.com or example.com today');
expect(root.querySelectorAll('a[href^="http"]').length).toBe(2);
});
it('keeps KaTeX from creating links, images or raw HTML', () => {
const root = preview(
[
'$\\href{javascript:alert(1)}{x}$',
'$\\url{javascript:alert(2)}$',
'$\\includegraphics{https://example.com/x.png}$',
'$\\htmlClass{x}{y}\\htmlStyle{position:fixed}{z}\\htmlId{app}{w}$',
'\\[\\text{<img src=x onerror=alert(3)>}\\]',
'$\\def\\a{\\a\\a}\\a$',
].join('\n\n')
);
expect(root.querySelector('a, img, script, [onerror], #app')).toBeNull();
for (const el of root.querySelectorAll('*')) {
for (const attr of el.attributes) {
expect(attr.value, attr.name).not.toMatch(/javascript:|fixed/i);
}
}
});
it('strips script and handlers from raw HTML next to math', () => {
const root = preview(
'<img src=x onerror=alert(1)> $x$ <svg onload=alert(2)></svg>\n\n<a href="javascript:alert(3)">x</a>'
);
expect(root.querySelector('[onerror], [onload], script')).toBeNull();
expect(root.innerHTML).not.toMatch(/javascript:/i);
});
it('removes elements that can restyle or cover the rest of the page', () => {
const root = preview(
[
'x <style>body{display:none}</style>',
'<svg><style>body{display:none}</style></svg>',
'<form action="https://example.com"><input type="password"></form>',
'<button popovertarget="p">x</button><div popover id="p">x</div>',
'<dialog open>x</dialog>',
'- [x] done',
].join('\n\n')
);
expect(root.querySelector('style, form, dialog, [popover]')).toBeNull();
expect(root.querySelector('input[type="checkbox"]')).not.toBeNull();
});
it('keeps editor controls ahead of document ids with the same name', () => {
preview('<div id="mdSettingsModal"></div><input id="mdOptHtml">');
const modal = document.getElementById('mdSettingsModal');
expect(modal?.classList.contains('md-editor-modal-overlay')).toBe(true);
expect(
document.getElementById('mdOptHtml')?.closest('#mdPreview')
).toBeNull();
});
});
describe('Workflow Markdown renderer', () => {
it('renders the shared extensions and leaves emoji and task markers as text', () => {
const html = useMarkdownPlugins(new MarkdownIt({ linkify: true })).render(
'[[toc]]\n\n## Notes\n\nH~2~O x^2^ ==m== ++i++ :smile:\n\n- [x] done\n\nTerm\n: def\n\nx[^1]\n\n[^1]: n\n\nsee example.com'
);
for (const tag of [
'<sub>',
'<sup>',
'<mark>',
'<ins>',
'<dd>',
'footnote',
'table-of-contents',
'href="http://example.com"',
]) {
expect(html, tag).toContain(tag);
}
expect(html).toContain(':smile:');
expect(html).toContain('[x] done');
});
});
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest';
import forge from 'node-forge';
import { validateSignature } from '../js/logic/validate-signature-pdf';
type KeyPair = forge.pki.rsa.KeyPair;
function keys(e = 0x10001): KeyPair {
return forge.pki.rsa.generateKeyPair({ bits: 1024, e });
}
function issue(
name: string,
serial: string,
subjectKeys: KeyPair,
issuer: { name: string; keys: KeyPair },
isCa: boolean
): forge.pki.Certificate {
const cert = forge.pki.createCertificate();
cert.publicKey = subjectKeys.publicKey;
cert.serialNumber = serial;
cert.setSubject([{ name: 'commonName', value: name }]);
cert.setIssuer([{ name: 'commonName', value: issuer.name }]);
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 86400000);
cert.setExtensions([{ name: 'basicConstraints', cA: isCa }]);
cert.sign(issuer.keys.privateKey, forge.md.sha256.create());
return cert;
}
function sign(
embedded: forge.pki.Certificate[],
namedSigner: forge.pki.Certificate,
signingKey: forge.pki.rsa.PrivateKey,
trustedCert: forge.pki.Certificate
) {
const before = new TextEncoder().encode('%PDF-1.4\nsigned A\n');
const after = new TextEncoder().encode('\nsigned B\n%%EOF\n');
const pdfBytes = new Uint8Array(before.length + after.length);
pdfBytes.set(before, 0);
pdfBytes.set(after, before.length);
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(String.fromCharCode(...pdfBytes));
for (const cert of embedded) p7.addCertificate(cert);
p7.addSigner({
key: signingKey,
certificate: namedSigner,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
],
});
p7.sign({ detached: true });
const der = forge.asn1.toDer(p7.toAsn1()).getBytes();
const contents = Uint8Array.from(der, (c) => c.charCodeAt(0));
return validateSignature(
{
index: 0,
contents,
byteRange: [0, before.length, before.length, after.length],
},
pdfBytes,
trustedCert
);
}
function chain(signerExponent = 0x10001, caExponent = 0x10001) {
const caKeys = keys(caExponent);
const signerKeys = keys(signerExponent);
const ca = issue(
'Test CA',
'01',
caKeys,
{ name: 'Test CA', keys: caKeys },
true
);
const signer = issue(
'Test Signer',
'02',
signerKeys,
{ name: 'Test CA', keys: caKeys },
false
);
return { ca, signer, signerKeys };
}
describe('RSA keys with a low public exponent (GHSA-86w9-cpqp-85rv)', () => {
it('still verifies a genuine signature from an e=3 signer key', async () => {
const { ca, signer, signerKeys } = chain(3);
const result = await sign([signer, ca], signer, signerKeys.privateKey, ca);
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(true);
});
it('does not trust a chain through an e=3 CA key', async () => {
const { ca, signer, signerKeys } = chain(0x10001, 3);
const result = await sign([signer, ca], signer, signerKeys.privateKey, ca);
expect(result.cryptoVerified).toBe(true);
expect(result.isTrusted).toBe(false);
});
});
describe('signer certificate binding', () => {
it('rejects a signature made with another embedded certificate key', async () => {
const { ca, signer: victim } = chain();
const attackerKeys = keys();
const attacker = issue(
'Attacker',
'03',
attackerKeys,
{ name: 'Attacker', keys: attackerKeys },
false
);
const result = await sign(
[attacker, victim, ca],
victim,
attackerKeys.privateKey,
ca
);
expect(result.signerName).toBe('Test Signer');
expect(result.cryptoVerified).toBe(false);
expect(result.isValid).toBe(false);
});
it('rejects a signature whose named signer certificate is missing', async () => {
const { ca, signer: victim } = chain();
const attackerKeys = keys();
const attacker = issue(
'Attacker',
'03',
attackerKeys,
{ name: 'Attacker', keys: attackerKeys },
false
);
const result = await sign(
[attacker, ca],
victim,
attackerKeys.privateKey,
ca
);
expect(result.isValid).toBe(false);
expect(result.isTrusted).toBe(false);
expect(result.errorMessage).toBeDefined();
});
});
+7 -9
View File
@@ -1,20 +1,18 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import forge from 'node-forge';
import { escapeHtml, sanitizeEmailHtml } from '../js/utils/helpers';
import { MarkdownEditor } from '../js/utils/markdown-editor';
import { validateSignature } from '../js/logic/validate-signature-pdf';
import type { ExtractedSignature } from '@/types';
function renderAsPreviewWould(markdown: string): string {
const md = new MarkdownIt({
html: true,
breaks: false,
linkify: true,
typographer: true,
});
const raw = md.render(markdown);
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target'] });
const container = document.createElement('div');
document.body.appendChild(container);
new MarkdownEditor(container, { initialContent: markdown });
const html = container.querySelector('#mdPreview')!.innerHTML;
container.remove();
return html;
}
function assertNoExecutableContent(html: string) {