mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-08 16:57:14 +01:00
feat(markdown): add LaTeX math support
This commit is contained in:
1 parent
f7a8a60440
commit
9c796a873c
11 files changed
+1592
-1464
No files matched your search
Generated
+1035
-1045
File diff suppressed because it is too large.
Load diff
+17
-16
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 <br></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 <br></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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+10
-52
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user