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

+1035 -1045
View File
File diff suppressed because it is too large. Load diff
+17 -16
View File
@@ -65,7 +65,7 @@
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.16",
"vite": "^8.3.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-handlebars": "^2.0.0",
"vite-plugin-node-polyfills": "^0.26.0",
@@ -83,12 +83,22 @@
"@fontsource/merriweather": "^5.2.11",
"@kenjiuno/msgreader": "^1.28.0",
"@matbee/libreoffice-converter": "^2.5.0",
"@mdit/plugin-abbr": "^1.1.3",
"@mdit/plugin-anchor": "^2.2.3",
"@mdit/plugin-dl": "^1.1.3",
"@mdit/plugin-emoji": "^1.2.3",
"@mdit/plugin-footnote": "^1.1.3",
"@mdit/plugin-ins": "^2.1.3",
"@mdit/plugin-katex-slim": "^1.1.4",
"@mdit/plugin-mark": "^2.1.3",
"@mdit/plugin-sub": "^1.1.3",
"@mdit/plugin-sup": "^1.1.3",
"@mdit/plugin-tasklist": "^1.1.4",
"@neslinesli93/qpdf-wasm": "^0.3.0",
"@pdf-lib/fontkit": "^1.1.1",
"@phosphor-icons/web": "^2.1.2",
"@retejs/lit-plugin": "^2.0.7",
"@tailwindcss/vite": "^4.2.1",
"@types/markdown-it": "^14.1.2",
"@types/node-forge": "^1.3.14",
"@types/papaparse": "^5.5.2",
"archiver": "^7.0.1",
@@ -101,7 +111,7 @@
"dompurify": "^3.4.17",
"fast-xml-parser": "^5.7.1",
"heic2any": "^0.0.4",
"highlight.js": "^11.11.1",
"highlight.js": "^11.12.0",
"html2canvas": "^1.4.1",
"hyper-compress-wasm": "file:vendor/hyper-compress-wasm/hyper-compress-wasm-1.0.3.tgz",
"i18next": "^25.8.13",
@@ -110,21 +120,12 @@
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.2",
"jszip": "^3.10.1",
"katex": "^0.19.1",
"lit": "^3.3.2",
"lucide": "^0.575.0",
"markdown-it": "^14.3.3",
"markdown-it-abbr": "^2.0.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-deflist": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-ins": "^4.0.0",
"markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1",
"markdown-it-toc-done-right": "^4.2.0",
"mermaid": "^11.16.1",
"markdown-it": "^15.0.3",
"markdown-it-table-of-contents": "^1.2.0",
"mermaid": "^12.2.0",
"microdiff": "^1.5.0",
"node-forge": "^1.4.0",
"papaparse": "^5.5.3",
+2 -1
View File
@@ -194,6 +194,7 @@
flex: 1;
padding: 1.5rem;
overflow-y: auto;
contain: paint;
background: rgb(31, 41, 55);
color: rgb(229, 231, 235);
}
@@ -743,7 +744,7 @@
}
.md-editor-preview .mermaid-diagram svg {
max-width: 100% !important;
max-height: 85vh;
}
}
+25 -7
View File
@@ -9,6 +9,8 @@ const INSECURE_DIGEST_OIDS = new Set<string>([
'1.3.14.3.2.26',
]);
const MIN_RSA_EXPONENT = new forge.jsbn.BigInteger('65537');
const PDF_WHITESPACE = new Set('\0\t\n\f\r ');
const PDF_TOKEN_TERMINATORS = new Set('\0\t\n\f\r ()<>[]{}/%');
const PDF_STRING_ESCAPES: Record<string, string> = {
@@ -379,6 +381,10 @@ export async function validateSignature(
p7.rawCapture?.issuer,
p7.rawCapture?.serial
);
if (!signerCert) {
result.errorMessage = trText('Could not parse signer info');
return result;
}
const subjectCN = signerCert.subject.getField('CN');
const subjectO = signerCert.subject.getField('O');
@@ -512,7 +518,7 @@ function findSignerCertificate(
certificates: forge.pki.Certificate[],
issuer: forge.asn1.Asn1 | undefined,
serial: string | undefined
): forge.pki.Certificate {
): forge.pki.Certificate | null {
if (issuer && serial) {
const serialHex = forge.util.createBuffer(serial).toHex();
const issuerDer = forge.asn1.toDer(issuer).getBytes();
@@ -523,15 +529,21 @@ function findSignerCertificate(
.toDer(forge.pki.distinguishedNameToAsn1(cert.issuer))
.getBytes() === issuerDer
);
if (match) return match;
return match ?? null;
}
return certificates[0];
}
function hasLowRsaExponent(key: unknown): boolean {
const exponent = (key as Partial<forge.pki.rsa.PublicKey> | undefined)?.e;
return !!exponent && exponent.compareTo(MIN_RSA_EXPONENT) < 0;
}
function isIssuedBy(
issuer: forge.pki.Certificate,
child: forge.pki.Certificate
): boolean {
if (hasLowRsaExponent(issuer.publicKey)) return false;
try {
return issuer.verify(child);
} catch {
@@ -804,14 +816,19 @@ function parsePssParams(paramsNode: forge.asn1.Asn1 | undefined): {
function extractSpkiDer(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: { certificates?: forge.asn1.Asn1 };
}
},
index: number
): Uint8Array | null {
try {
const certsNode = p7.rawCapture?.certificates;
if (!certsNode || !Array.isArray(certsNode.value) || !certsNode.value[0]) {
if (
!certsNode ||
!Array.isArray(certsNode.value) ||
!certsNode.value[index]
) {
return null;
}
const certAsn1 = certsNode.value[0];
const certAsn1 = certsNode.value[index];
if (!Array.isArray(certAsn1.value) || !certAsn1.value[0]) return null;
const tbs = certAsn1.value[0];
if (!Array.isArray(tbs.value)) return null;
@@ -1093,7 +1110,7 @@ async function performCryptoVerification(
signedBytesForVerify = signedContent;
}
if (scheme.kind === 'rsa-pkcs1') {
if (scheme.kind === 'rsa-pkcs1' && !hasLowRsaExponent(signerCert.publicKey)) {
try {
const publicKey = signerCert.publicKey as forge.pki.rsa.PublicKey;
const md2 = createMd(fields.digestOid)!;
@@ -1105,7 +1122,8 @@ async function performCryptoVerification(
}
}
const spkiDer = extractSpkiDer(p7);
const signerIndex = (p7.certificates ?? []).indexOf(signerCert);
const spkiDer = signerIndex < 0 ? null : extractSpkiDer(p7, signerIndex);
if (!spkiDer) {
return {
status: 'unsupported',
+151 -333
View File
@@ -1,4 +1,7 @@
import MarkdownIt from 'markdown-it';
import MarkdownIt, {
type MarkdownIt as MarkdownItParser,
type StateInline,
} from 'markdown-it';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
@@ -17,18 +20,13 @@ import go from 'highlight.js/lib/languages/go';
import rust from 'highlight.js/lib/languages/rust';
import yaml from 'highlight.js/lib/languages/yaml';
import 'highlight.js/styles/github.css';
import mermaid from 'mermaid';
import sub from 'markdown-it-sub';
import sup from 'markdown-it-sup';
import footnote from 'markdown-it-footnote';
import deflist from 'markdown-it-deflist';
import abbr from 'markdown-it-abbr';
import { full as emoji } from 'markdown-it-emoji';
import ins from 'markdown-it-ins';
import mark from 'markdown-it-mark';
import taskLists from 'markdown-it-task-lists';
import anchor from 'markdown-it-anchor';
import tocDoneRight from 'markdown-it-toc-done-right';
import type { Mermaid } from 'mermaid';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import { fullEmoji } from '@mdit/plugin-emoji';
import { tasklist } from '@mdit/plugin-tasklist';
import { katex as katexPlugin } from '@mdit/plugin-katex-slim';
import { useMarkdownPlugins } from './markdown-plugins';
import { applyTranslations, trHtml, trText } from '../i18n/i18n';
import type {
WindowWithLucide,
@@ -77,6 +75,36 @@ export type { MarkdownItOptions } from '@/types';
const MIN_TABLE_ZOOM = 0.6;
const TABLE_BREAK_CLASS = 'md-table-break-anywhere';
const PREVIEW_SANITIZE_OPTIONS = {
ADD_ATTR: ['target'],
FORBID_TAGS: ['style', 'form', 'dialog'],
FORBID_ATTR: ['popover'],
};
const ESCAPED_MATH: ReadonlyArray<[string, string, boolean]> = [
['\\\\[', '\\\\]', true],
['\\\\(', '\\\\)', false],
['\\[', '\\]', true],
];
function escapedMath(state: StateInline, silent: boolean): boolean {
for (const [open, close, display] of ESCAPED_MATH) {
if (!state.src.startsWith(open, state.pos)) continue;
const start = state.pos + open.length;
const end = state.src.indexOf(close, start);
if (end <= start || end + close.length > state.posMax) return false;
if (!silent) {
const token = state.push(
display ? 'math_display_inline' : 'math_inline',
'math',
0
);
token.content = state.src.slice(start, end);
}
state.pos = end + close.length;
return true;
}
return false;
}
const DEFAULT_MARKDOWN = `# Welcome to BentoPDF Markdown Editor
@@ -131,6 +159,32 @@ def fibonacci(n):
| Tables | ✓ | With alignment |
| Emoji | ✓ | :white_check_mark: |
| Mermaid | ✓ | Diagrams! |
| Math | ✓ | LaTeX via KaTeX |
## Math
Inline math with dollar signs like $E = mc^2$, or with brackets like \\(a^2 + b^2 = c^2\\).
Display math with double dollar signs:
$$
\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}
$$
Or with square brackets:
\\[
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
\\]
Or in a math code block:
\`\`\`math
\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}
\\begin{pmatrix} x \\\\ y \\end{pmatrix}
=
\\begin{pmatrix} ax + by \\\\ cx + dy \\end{pmatrix}
\`\`\`
## Mermaid Diagrams
@@ -265,13 +319,13 @@ Start editing to see the magic happen!
export class MarkdownEditor {
private container: HTMLElement;
private md: MarkdownIt;
private md: MarkdownItParser;
private editor: HTMLTextAreaElement | null = null;
private preview: HTMLElement | null = null;
private onBack?: () => void;
private syncScroll: boolean = false;
private isSyncing: boolean = false;
private mermaidInitialized: boolean = false;
private mermaid: Promise<Mermaid> | null = null;
private mdOptions: MarkdownItOptions = {
html: true,
breaks: false,
@@ -283,7 +337,6 @@ export class MarkdownEditor {
this.container = container;
this.onBack = options.onBack;
this.initMermaid();
this.md = this.createMarkdownIt();
this.configureLinkRenderer();
@@ -296,18 +349,26 @@ export class MarkdownEditor {
}
}
private initMermaid(): void {
if (!this.mermaidInitialized) {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'strict',
htmlLabels: false,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
});
this.mermaidInitialized = true;
}
private loadMermaid(): Promise<Mermaid> {
this.mermaid ??= import('mermaid').then(
({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
look: 'classic',
securityLevel: 'strict',
htmlLabels: false,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
});
return mermaid;
},
(error: unknown) => {
this.mermaid = null;
throw error;
}
);
return this.mermaid;
}
private configureLinkRenderer(): void {
@@ -338,6 +399,44 @@ export class MarkdownEditor {
private render(): void {
this.container.innerHTML = `
<!-- Settings Modal (hidden by default) -->
<div class="md-editor-modal-overlay" id="mdSettingsModal" style="display: none;">
<div class="md-editor-modal">
<div class="md-editor-modal-header">
<h2 class="md-editor-modal-title" data-i18n="tools:markdownToPdf.settingsTitle">Markdown Settings</h2>
<button class="md-editor-modal-close" id="mdCloseSettings">
<i data-lucide="x" width="20" height="20"></i>
</button>
</div>
<div class="md-editor-settings-group">
<h3 data-i18n="tools:markdownToPdf.settingsPreset">Preset</h3>
<select id="mdPreset">
<option value="default" selected data-i18n="tools:markdownToPdf.presetDefault">Default (GFM-like)</option>
<option value="commonmark" data-i18n="tools:markdownToPdf.presetCommonmark">CommonMark (strict)</option>
<option value="zero" data-i18n="tools:markdownToPdf.presetZero">Minimal (no features)</option>
</select>
</div>
<div class="md-editor-settings-group">
<h3 data-i18n="tools:markdownToPdf.settingsOptions">Markdown Options</h3>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptHtml" ${this.mdOptions.html ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optAllowHtml">Allow HTML tags</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptBreaks" ${this.mdOptions.breaks ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optBreaks">Convert newlines to &lt;br&gt;</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptLinkify" ${this.mdOptions.linkify ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optLinkify">Auto-convert URLs to links</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptTypographer" ${this.mdOptions.typographer ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optTypographer">Typographer (smart quotes, etc.)</span>
</label>
</div>
</div>
</div>
<div class="md-editor light-mode">
<div class="md-editor-wrapper">
<div class="md-editor-header">
@@ -384,44 +483,6 @@ export class MarkdownEditor {
</div>
</div>
<!-- Settings Modal (hidden by default) -->
<div class="md-editor-modal-overlay" id="mdSettingsModal" style="display: none;">
<div class="md-editor-modal">
<div class="md-editor-modal-header">
<h2 class="md-editor-modal-title" data-i18n="tools:markdownToPdf.settingsTitle">Markdown Settings</h2>
<button class="md-editor-modal-close" id="mdCloseSettings">
<i data-lucide="x" width="20" height="20"></i>
</button>
</div>
<div class="md-editor-settings-group">
<h3 data-i18n="tools:markdownToPdf.settingsPreset">Preset</h3>
<select id="mdPreset">
<option value="default" selected data-i18n="tools:markdownToPdf.presetDefault">Default (GFM-like)</option>
<option value="commonmark" data-i18n="tools:markdownToPdf.presetCommonmark">CommonMark (strict)</option>
<option value="zero" data-i18n="tools:markdownToPdf.presetZero">Minimal (no features)</option>
</select>
</div>
<div class="md-editor-settings-group">
<h3 data-i18n="tools:markdownToPdf.settingsOptions">Markdown Options</h3>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptHtml" ${this.mdOptions.html ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optAllowHtml">Allow HTML tags</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptBreaks" ${this.mdOptions.breaks ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optBreaks">Convert newlines to &lt;br&gt;</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptLinkify" ${this.mdOptions.linkify ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optLinkify">Auto-convert URLs to links</span>
</label>
<label class="md-editor-checkbox">
<input type="checkbox" id="mdOptTypographer" ${this.mdOptions.typographer ? 'checked' : ''} />
<span data-i18n="tools:markdownToPdf.optTypographer">Typographer (smart quotes, etc.)</span>
</label>
</div>
</div>
</div>
`;
this.editor = document.getElementById('mdTextarea') as HTMLTextAreaElement;
@@ -646,9 +707,9 @@ export class MarkdownEditor {
}
}
private createMarkdownIt(): MarkdownIt {
private createMarkdownIt(): MarkdownItParser {
// Use preset if commonmark or zero
let md: MarkdownIt;
let md: MarkdownItParser;
if (this.currentPreset === 'commonmark') {
md = new MarkdownIt('commonmark');
} else if (this.currentPreset === 'zero') {
@@ -676,17 +737,26 @@ export class MarkdownEditor {
// Apply plugins only for default preset (plugins may not work well with commonmark/zero)
if (this.currentPreset === 'default') {
md.use(sub) // Subscript: ~text~ -> <sub>text</sub>
.use(sup) // Superscript: ^text^ -> <sup>text</sup>
.use(footnote) // Footnotes: [^1] and [^1]: footnote text
.use(deflist) // Definition lists
.use(abbr) // Abbreviations: *[abbr]: full text
.use(emoji) // Emoji: :smile: -> 😄
.use(ins) // Inserted text: ++text++ -> <ins>text</ins>
.use(mark) // Marked text: ==text== -> <mark>text</mark>
.use(taskLists, { enabled: true, label: false }) // Task lists: - [x] done
.use(anchor, { permalink: false }) // Header anchors
.use(tocDoneRight); // Table of contents: ${toc}
useMarkdownPlugins(md)
.use(fullEmoji) // Emoji: :smile: -> 😄
.use(tasklist, {
disabled: false,
label: false,
containerClass: 'contains-task-list',
}) // Task lists: - [x] done
.use(katexPlugin, {
delimiters: 'all' as const,
mathFence: true,
throwOnError: false,
});
md.inline.ruler.before('escape', 'math_escaped', escapedMath);
md.renderer.rules.math_display_inline = (tokens, idx) =>
katex.renderToString(tokens[idx].content, {
displayMode: true,
throwOnError: false,
strict: (code: string) =>
code === 'newLineInDisplayMode' ? 'ignore' : 'warn',
});
}
return md;
@@ -703,9 +773,7 @@ export class MarkdownEditor {
const markdown = this.editor.value;
const html = this.md.render(markdown);
this.preview.innerHTML = DOMPurify.sanitize(html, {
ADD_ATTR: ['target'],
});
this.preview.innerHTML = DOMPurify.sanitize(html, PREVIEW_SANITIZE_OPTIONS);
this.renderMermaidDiagrams();
}
@@ -724,6 +792,7 @@ export class MarkdownEditor {
if (pre && code.trim()) {
try {
const id = `mermaid-diagram-${i}-${Date.now()}`;
const mermaid = await this.loadMermaid();
const { svg } = await mermaid.render(id, code.trim());
const wrapper = document.createElement('div');
@@ -758,12 +827,6 @@ export class MarkdownEditor {
return this.editor?.value || '';
}
public getHtml(): string {
return DOMPurify.sanitize(this.md.render(this.getContent()), {
ADD_ATTR: ['target'],
});
}
private fitTablesToPage(printing: boolean): void {
if (!this.preview) return;
const available = this.preview.clientWidth;
@@ -789,251 +852,6 @@ export class MarkdownEditor {
window.print();
}
private getStyledHtml(): string {
const content = this.getHtml();
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
h4 { font-size: 1em; }
p { margin: 1em 0; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.9em;
background: #f6f8fa;
padding: 0.2em 0.4em;
border-radius: 3px;
}
pre {
background: #f6f8fa;
padding: 16px;
overflow: auto;
border-radius: 6px;
line-height: 1.45;
}
pre code {
background: none;
padding: 0;
}
blockquote {
margin: 1em 0;
padding: 0 1em;
color: #6a737d;
border-left: 4px solid #dfe2e5;
}
ul, ol {
margin: 1em 0;
padding-left: 2em;
}
li { margin: 0.25em 0; }
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
th, td {
border: 1px solid #dfe2e5;
padding: 8px 12px;
text-align: left;
}
th {
background: #f6f8fa;
font-weight: 600;
}
tr:nth-child(even) { background: #f6f8fa; }
hr {
border: none;
border-top: 1px solid #eee;
margin: 2em 0;
}
img {
max-width: 100%;
height: auto;
}
/* Syntax highlighting - GitHub style */
.hljs {
color: #24292e;
background: #f6f8fa;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-subst {
color: #d73a49;
}
.hljs-number,
.hljs-literal,
.hljs-variable,
.hljs-template-variable,
.hljs-tag .hljs-attr {
color: #005cc5;
}
.hljs-string,
.hljs-doctag {
color: #032f62;
}
.hljs-title,
.hljs-section,
.hljs-selector-id {
color: #6f42c1;
font-weight: bold;
}
.hljs-type,
.hljs-class .hljs-title {
color: #6f42c1;
}
.hljs-tag,
.hljs-name,
.hljs-attribute {
color: #22863a;
}
.hljs-regexp,
.hljs-link {
color: #032f62;
}
.hljs-symbol,
.hljs-bullet {
color: #e36209;
}
.hljs-built_in,
.hljs-builtin-name {
color: #005cc5;
}
.hljs-meta {
color: #6a737d;
font-weight: bold;
}
.hljs-deletion {
color: #b31d28;
background-color: #ffeef0;
}
.hljs-addition {
color: #22863a;
background-color: #f0fff4;
}
/* Plugin styles */
mark {
background-color: #fff3cd;
padding: 0.1em 0.2em;
border-radius: 2px;
}
ins {
text-decoration: none;
background-color: #d4edda;
padding: 0.1em 0.2em;
border-radius: 2px;
}
sub, sup {
font-size: 0.75em;
}
.task-list-item {
list-style-type: none;
margin-left: -1.5em;
}
.task-list-item input[type="checkbox"] {
margin-right: 0.5em;
}
.footnotes {
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid #eee;
font-size: 0.9em;
}
.footnotes-sep {
display: none;
}
.footnote-ref {
font-size: 0.75em;
vertical-align: super;
}
.footnote-backref {
font-size: 0.75em;
margin-left: 0.25em;
}
dl {
margin: 1em 0;
}
dt {
font-weight: 600;
margin-top: 1em;
}
dd {
margin-left: 2em;
margin-top: 0.25em;
color: #6a737d;
}
abbr {
text-decoration: underline dotted;
cursor: help;
}
.table-of-contents {
background: #f6f8fa;
padding: 1em 1.5em;
border-radius: 6px;
margin: 1em 0;
}
.table-of-contents ul {
margin: 0;
padding-left: 1.5em;
}
.table-of-contents li {
margin: 0.25em 0;
}
/* Mermaid diagrams */
.mermaid-diagram {
display: flex;
justify-content: center;
margin: 1.5em 0;
padding: 1em;
background: #f6f8fa;
border-radius: 6px;
}
.mermaid-diagram svg {
max-width: 100%;
height: auto;
}
.mermaid-error {
color: #cb2431;
background: #ffeef0;
padding: 1em;
border-radius: 6px;
font-family: monospace;
font-size: 0.9em;
}
</style>
</head>
<body>
${content}
</body>
</html>`;
}
private applyI18n(): void {
// Apply translations to elements within this component
applyTranslations();
+28
View File
@@ -0,0 +1,28 @@
import type { MarkdownIt } from 'markdown-it';
import { sub } from '@mdit/plugin-sub';
import { sup } from '@mdit/plugin-sup';
import { footnote } from '@mdit/plugin-footnote';
import { dl } from '@mdit/plugin-dl';
import { abbr } from '@mdit/plugin-abbr';
import { ins } from '@mdit/plugin-ins';
import { mark } from '@mdit/plugin-mark';
import { anchor } from '@mdit/plugin-anchor';
import tableOfContents from 'markdown-it-table-of-contents';
export function useMarkdownPlugins(md: MarkdownIt): MarkdownIt {
md.linkify.set({ fuzzyLink: true });
return md
.use(sub) // Subscript: ~text~ -> <sub>text</sub>
.use(sup) // Superscript: ^text^ -> <sup>text</sup>
.use(footnote) // Footnotes: [^1] and [^1]: footnote text
.use(dl) // Definition lists
.use(abbr) // Abbreviations: *[abbr]: full text
.use(ins) // Inserted text: ++text++ -> <ins>text</ins>
.use(mark) // Marked text: ==text== -> <mark>text</mark>
.use(anchor) // Header anchors
.use(tableOfContents, {
markerPattern: /^(?:\$\{toc\}|\[\[?_?toc_?\]?\])[ \t]*$/im,
includeLevel: [1, 2, 3, 4, 5, 6],
listType: 'ol',
}); // Table of contents: ${toc}
}
@@ -5,9 +5,12 @@ import type { PDFData, SocketData, MultiPDFData } from '../types';
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
import MarkdownIt from 'markdown-it';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
import { useMarkdownPlugins } from '../../utils/markdown-plugins.js';
import { wfError } from '../errors';
const md = new MarkdownIt({ html: true, linkify: true, typographer: true });
const md = useMarkdownPlugins(
new MarkdownIt({ html: true, linkify: true, typographer: true })
);
export class MarkdownToPdfNode extends BaseWorkflowNode {
readonly category = 'Input' as const;
+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) {
+10 -52
View File
@@ -1,54 +1,12 @@
declare module 'markdown-it-sub' {
import type MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginSimple;
export default plugin;
}
declare module 'markdown-it-sup' {
import type MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginSimple;
export default plugin;
}
declare module 'markdown-it-footnote' {
import type MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginSimple;
export default plugin;
}
declare module 'markdown-it-deflist' {
import type MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginSimple;
export default plugin;
}
declare module 'markdown-it-abbr' {
import type MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginSimple;
export default plugin;
}
declare module 'markdown-it-emoji' {
import type MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginSimple;
export { plugin as full };
export default plugin;
}
declare module 'markdown-it-ins' {
import type MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginSimple;
export default plugin;
}
declare module 'markdown-it-mark' {
import type MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginSimple;
export default plugin;
}
declare module 'markdown-it-task-lists' {
import type MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginSimple;
declare module 'markdown-it-table-of-contents' {
import type { MarkdownIt } from 'markdown-it';
const plugin: (
md: MarkdownIt,
options?: {
markerPattern?: RegExp;
includeLevel?: number[];
listType?: 'ul' | 'ol';
}
) => void;
export default plugin;
}