removed client-side export/added modern latex notation

This commit is contained in:
Gamosoft 2026-04-06 12:01:52 +02:00
parent cd9b5e7d7b
commit 96b5fd8cfc
6 changed files with 209 additions and 271 deletions

View File

@ -348,8 +348,8 @@ def generate_export_html(
<script>
MathJax = {{
tex: {{
inlineMath: [['$', '$']],
displayMath: [['$$', '$$']],
inlineMath: [['\\\\(', '\\\\)'], ['$', '$']],
displayMath: [['\\\\[', '\\\\]'], ['$$', '$$']],
processEscapes: true,
processEnvironments: true
}},
@ -674,6 +674,21 @@ def generate_export_html(
<div class="markdown-preview" id="content"></div>
<script>
// Protect LaTeX delimiters \\(...\\) and \\[...\\] from marked.js escaping
marked.use({{
extensions: [{{
name: 'protectLatexMath',
level: 'inline',
start(src) {{ return src.match(/\\\\[\\(\\[]/)?.index; }},
tokenizer(src) {{
const match = src.match(/^(\\\\[\\(\\[])([\\s\\S]*?)(\\\\[\\)\\]])/);
if (match) {{
return {{ type: 'html', raw: match[0], text: match[0] }};
}}
}}
}}]
}});
// Configure marked
marked.setOptions({{
gfm: true,
@ -681,7 +696,7 @@ def generate_export_html(
headerIds: true,
mangle: false
}});
// Raw markdown content
const markdown = `{escaped_content}`;
@ -691,6 +706,11 @@ def generate_export_html(
const safeHtml = DOMPurify.sanitize(rawHtml);
document.getElementById('content').innerHTML = safeHtml;
// Typeset math after content is inserted
if (typeof MathJax !== 'undefined' && MathJax.typeset) {{
MathJax.typeset();
}}
// Add copy buttons to code blocks
document.querySelectorAll('.markdown-preview pre').forEach(pre => {{
const btn = document.createElement('button');

View File

@ -1228,6 +1228,89 @@ async def remove_note(request: Request, note_path: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete note"))
@api_router.get("/export/{note_path:path}", tags=["Export"])
@limiter.limit("30/minute")
async def export_note_to_html(request: Request, note_path: str, theme: Optional[str] = None):
"""
Export a note as a standalone HTML file.
The HTML includes all necessary CSS, MathJax, Mermaid, and syntax highlighting
for offline viewing. Images are embedded as base64.
Query Parameters:
theme: Optional theme name (defaults to current theme or 'light')
Returns:
HTML file download
"""
try:
notes_dir = Path(config['storage']['notes_dir'])
# Read note content
content = get_note_content(str(notes_dir), note_path)
if content is None:
raise HTTPException(status_code=404, detail="Note not found")
# Run on_note_load hook (can transform content, e.g., decrypt)
transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content)
if transformed_content is not None:
content = transformed_content
# Strip YAML frontmatter (like the preview does)
content = strip_frontmatter(content)
# Get note folder for resolving relative image paths
note_file_path = notes_dir / note_path
note_folder = note_file_path.parent
# Embed images as base64
content_with_images = embed_images_as_base64(content, note_folder, notes_dir)
# Convert wikilinks to decorative HTML links
content_with_links = convert_wikilinks_to_html(content_with_images)
# Get theme CSS
themes_dir = Path(__file__).parent.parent / "themes"
theme_name = theme or 'light'
theme_css = get_theme_css(str(themes_dir), theme_name)
if not theme_css:
theme_css = get_theme_css(str(themes_dir), "light")
theme_name = "light"
# Strip data-theme selector
theme_css = theme_css.replace(f':root[data-theme="{theme_name}"]', ':root')
theme_css = theme_css.replace(':root[data-theme="light"]', ':root')
theme_css = theme_css.replace(':root[data-theme="dark"]', ':root')
# Determine if dark theme
is_dark = 'dark' in theme_name.lower() or theme_name in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark']
# Get note title
title = Path(note_path).stem
# Generate HTML
html_content = generate_export_html(
title=title,
content=content_with_links,
theme_css=theme_css,
is_dark=is_dark
)
# Return as downloadable file
filename = f"{title}.html"
return Response(
content=html_content,
media_type="text/html",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
}
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to export note"))
@api_router.get("/search", tags=["Search"])
async def search(
q: str,

View File

@ -444,6 +444,49 @@ Returns the relationship graph between notes with link detection.
- **Markdown links** - `[text](note.md)` standard internal links
- **Edge types** - `"wikilink"` or `"markdown"` to distinguish link source
---
## 📤 Export
### Export Note as HTML
```http
GET /api/export/{note_path}?theme={theme_name}
```
Exports a note as a standalone HTML file with all dependencies embedded for offline viewing.
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `note_path` | path | Path to the note (e.g., `folder/note.md`) |
| `theme` | query (optional) | Theme name for styling (defaults to `light`) |
**Response:**
Returns an HTML file download with `Content-Disposition: attachment` header.
**Features:**
- Fully self-contained HTML with embedded CSS
- Images converted to base64 data URLs
- MathJax for LaTeX math rendering (supports `$...$`, `$$...$$`, `\(...\)`, `\[...\]`)
- Mermaid.js for diagram rendering
- Highlight.js for syntax highlighting
- Wikilinks converted to decorative spans
- YAML frontmatter stripped
- Responsive design with print support
**Rate Limit:** 30 requests/minute
**Example:**
```bash
# Export with default theme
curl -O http://localhost:8000/api/export/notes/Welcome.md
# Export with dark theme
curl -O "http://localhost:8000/api/export/docs/API.md?theme=dracula"
```
---
## ⚙️ System
### Get Config

View File

@ -4,14 +4,18 @@ NoteDiscovery supports **LaTeX mathematical notation** powered by MathJax 3. Wri
## Syntax Overview
### Inline Math (within text)
Use `$...$` for inline equations:
| Delimiter | Type | Behavior |
|-----------|------|----------|
| `$...$` | Inline | Flows with text, not centered |
| `\(...\)` | Inline | Same as `$...$` (LaTeX standard) |
| `$$...$$` | Display | Own paragraph, centered, larger |
| `\[...\]` | Display | Same as `$$...$$` (LaTeX standard) |
- `$E = mc^2$` renders as: $E = mc^2$
- `$x^2 + y^2 = r^2$` renders as: $x^2 + y^2 = r^2$
### Inline Math (within text)
Inline math flows with your text: `$E = mc^2$` renders as $E = mc^2$
### Display Math (centered, on its own line)
Use `$$...$$` for display equations:
Display math gets its own centered paragraph:
```markdown
$$
@ -19,6 +23,13 @@ x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}
$$
```
Or using LaTeX-style delimiters:
```markdown
\[
x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}
\]
```
$$
x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}
$$

View File

@ -4164,6 +4164,26 @@ function noteApp() {
return codeBlocks[parseInt(index)];
});
// Protect LaTeX \(...\) and \[...\] delimiters from marked.js escaping
marked.use({
extensions: [{
name: 'protectLatexMath',
level: 'inline',
start(src) { return src.match(/\\[\(\[]/)?.index; },
tokenizer(src) {
// Match \(...\) or \[...\]
const match = src.match(/^(\\[\(\[])([\s\S]*?)(\\[\)\]])/);
if (match) {
return {
type: 'html',
raw: match[0],
text: match[0]
};
}
}
}]
});
// Configure marked with syntax highlighting
marked.setOptions({
breaks: true,
@ -5045,7 +5065,7 @@ function noteApp() {
}, CONFIG.SCROLL_SYNC_DELAY);
},
// Export current note as HTML
// Export current note as HTML via backend API
async exportToHTML() {
if (!this.currentNote || !this.noteContent) {
alert(this.t('notes.no_content'));
@ -5053,278 +5073,39 @@ function noteApp() {
}
try {
// Get the note name without extension
const noteName = this.currentNoteName || 'note';
// Get current rendered HTML (this already has markdown converted and will have LaTeX delimiters)
let renderedHTML = this.renderedMarkdown;
// Convert non-image media (audio, video, PDF) to placeholders first
// These shouldn't be embedded as base64 (too large)
// Use CSS variables with fallbacks for theme-aware styling (matches backend export.py)
const mediaPlaceholder = (type, name) => {
const icons = { audio: '🎵', video: '🎬', document: '📄' };
const labels = { audio: 'Audio file', video: 'Video file', document: 'PDF document' };
const icon = icons[type] || '📎';
const label = labels[type] || 'Media file';
return `<div style="margin:1.5rem 0;padding:1.5rem;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;display:flex;align-items:center;gap:1rem;">
<span style="font-size:2rem;">${icon}</span>
<div>
<div style="font-weight:600;color:var(--text-primary,#212529);">${name}</div>
<div style="font-size:0.875rem;color:var(--text-secondary,#6c757d);">${label} not available in exported view</div>
</div>
</div>`;
};
// Replace audio embeds with placeholders
renderedHTML = renderedHTML.replace(
/<div class="media-embed media-audio">.*?<audio[^>]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs,
(match, filename) => mediaPlaceholder('audio', decodeURIComponent(filename).replace(/\.[^.]+$/, ''))
);
// Replace video embeds with placeholders
renderedHTML = renderedHTML.replace(
/<div class="media-embed media-video">.*?<video[^>]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs,
(match, filename) => mediaPlaceholder('video', decodeURIComponent(filename).replace(/\.[^.]+$/, ''))
);
// Replace PDF embeds with placeholders
renderedHTML = renderedHTML.replace(
/<div class="media-embed media-pdf">.*?<iframe[^>]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs,
(match, filename) => mediaPlaceholder('document', decodeURIComponent(filename).replace(/\.[^.]+$/, ''))
);
// Embed local images as base64 for fully self-contained HTML
// Handle both /api/media/ and legacy /api/images/ paths
const imgRegex = /src="\/api\/(?:media|images)\/([^"]+)"/g;
const imgMatches = [...renderedHTML.matchAll(imgRegex)];
for (const match of imgMatches) {
const encodedPath = match[1];
// Skip non-image files (already handled above)
const ext = encodedPath.split('.').pop().toLowerCase();
if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
continue;
}
try {
// Fetch the image
const imgResponse = await fetch(`/api/media/${encodedPath}`);
if (imgResponse.ok) {
const blob = await imgResponse.blob();
// Convert to base64 data URL
const base64 = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
// Replace the src with base64 data URL
renderedHTML = renderedHTML.replace(match[0], `src="${base64}"`);
}
} catch (e) {
console.warn(`Failed to embed image: ${encodedPath}`, e);
// Fall back to relative path
const decodedPath = decodeURIComponent(encodedPath);
renderedHTML = renderedHTML.replace(match[0], `src="${decodedPath}"`);
}
}
// Get current theme CSS
// Build API URL with current theme
const currentTheme = this.currentTheme || 'light';
const themeResponse = await fetch(`/api/themes/${currentTheme}`);
const themeText = await themeResponse.text();
const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}`;
// Check if response is JSON or plain CSS
let themeCss;
try {
const themeJson = JSON.parse(themeText);
// If it's JSON, extract the css field
themeCss = themeJson.css || themeText;
} catch (e) {
// If it's not JSON, use it as-is
themeCss = themeText;
// Fetch the exported HTML from backend
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Export failed' }));
throw new Error(error.detail || 'Export failed');
}
// Theme CSS uses :root[data-theme="..."] selector, but we need plain :root for export
// Strip the data-theme attribute selector so variables apply globally
themeCss = themeCss.replace(/:root\[data-theme="[^"]+"\]/g, ':root');
// Get highlight.js theme URL from current page
const highlightLinkElement = document.getElementById('highlight-theme');
if (!highlightLinkElement || !highlightLinkElement.href) {
console.warn('Could not detect highlight.js theme, export may not match preview exactly');
}
const highlightTheme = highlightLinkElement ? highlightLinkElement.href : '';
// Extract all markdown preview styles from current page
let markdownStyles = '';
const styleSheets = Array.from(document.styleSheets);
for (const sheet of styleSheets) {
try {
// Skip external stylesheets (CDN resources) to avoid CORS errors
// We link them directly in the exported HTML anyway
if (sheet.href && (sheet.href.startsWith('http://') || sheet.href.startsWith('https://'))) {
const currentOrigin = window.location.origin;
const sheetURL = new URL(sheet.href);
if (sheetURL.origin !== currentOrigin) {
// Skip cross-origin stylesheets (they're linked directly in export)
continue;
}
}
const rules = Array.from(sheet.cssRules || []);
for (const rule of rules) {
const cssText = rule.cssText;
// Include rules that target markdown-preview, mjx-container, or mermaid-rendered
if (cssText.includes('.markdown-preview') ||
cssText.includes('mjx-container') ||
cssText.includes('.MathJax') ||
cssText.includes('.mermaid-rendered')) {
markdownStyles += cssText + '\n';
}
}
} catch (e) {
// Gracefully skip stylesheets that can't be accessed
// (This should rarely happen now that we skip external stylesheets)
console.debug('Skipping stylesheet:', sheet.href);
// Get filename from Content-Disposition header or use note name
let filename = (this.currentNoteName || 'note') + '.html';
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const match = contentDisposition.match(/filename="([^"]+)"/);
if (match) {
filename = match[1];
}
}
// Create standalone HTML document with MathJax
const htmlDocument = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${noteName}</title>
<!-- Highlight.js for code syntax highlighting (v11.9.0) -->
${highlightTheme ? `<link rel="stylesheet" href="${highlightTheme}">` : '<!-- No highlight.js theme detected -->'}
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- MathJax for LaTeX math rendering (v3.2.2) -->
<script>
MathJax = {
tex: {
inlineMath: [['$', '$']],
displayMath: [['$$', '$$']],
processEscapes: true,
processEnvironments: true
},
options: {
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
},
startup: {
pageReady: () => {
return MathJax.startup.defaultPageReady().then(() => {
// Highlight code blocks after MathJax is done (exclude diagram renderers)
document.querySelectorAll('pre code:not(.language-mermaid)').forEach((block) => {
hljs.highlightElement(block);
});
});
}
}
};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js"></script>
<!-- Mermaid.js for diagrams -->
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.esm.min.mjs';
const isDark = ${this.getThemeType() === 'dark'};
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'inherit',
flowchart: { useMaxWidth: true },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
state: { useMaxWidth: true },
er: { useMaxWidth: true },
pie: { useMaxWidth: true },
mindmap: { useMaxWidth: true },
gitGraph: { useMaxWidth: true }
});
// Render any Mermaid code blocks
document.addEventListener('DOMContentLoaded', async () => {
const mermaidBlocks = document.querySelectorAll('pre code.language-mermaid');
for (let i = 0; i < mermaidBlocks.length; i++) {
const block = mermaidBlocks[i];
const pre = block.parentElement;
try {
const code = block.textContent;
const id = 'mermaid-diagram-' + i;
const { svg } = await mermaid.render(id, code);
const container = document.createElement('div');
container.className = 'mermaid-rendered';
container.style.cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;';
container.innerHTML = svg;
pre.parentElement.replaceChild(container, pre);
} catch (error) {
console.error('Mermaid rendering error:', error);
}
}
});
</script>
<style>
/* Theme CSS */
${themeCss}
/* Base styles */
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 2rem;
max-width: 900px;
margin-left: auto;
margin-right: auto;
background-color: var(--bg-primary);
color: var(--text-primary);
}
/* Markdown preview styles extracted from current page */
${markdownStyles}
@media (max-width: 768px) {
body {
padding: 1rem;
}
}
@media print {
body {
padding: 0.5in;
max-width: none;
}
}
</style>
</head>
<body>
<div class="markdown-preview">
${renderedHTML}
</div>
</body>
</html>`;
// Create blob and download
const blob = new Blob([htmlDocument], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
// Download as blob
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${noteName}.html`;
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
// Cleanup
URL.revokeObjectURL(url);
URL.revokeObjectURL(blobUrl);
document.body.removeChild(a);
} catch (error) {

View File

@ -70,8 +70,8 @@
<script>
MathJax = {
tex: {
inlineMath: [['$', '$']],
displayMath: [['$$', '$$']],
inlineMath: [['\\(', '\\)'], ['$', '$']],
displayMath: [['\\[', '\\]'], ['$$', '$$']],
processEscapes: true,
processEnvironments: true
},