Skip to content

Commit 47446fd

Browse files
PttCodingManclaude
andcommitted
fix: render mermaid in transclusion; replace Export PDF with Print.
The PDF export just returned HTML with an auto-triggered window.print(), which is less honest than a Print button invoking window.print() directly on the live page — the new action uses @media print to hide chrome, so Save-as-PDF from the browser dialog produces the same artifact without an extra route. Mermaid diagrams inside transcluded pages stayed at 'Loading diagram...' because the mermaid effect ran before transclusion injected content. Extracted renderMermaidIn(root) and re-run it on each transcluded subtree. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8898181 commit 47446fd

6 files changed

Lines changed: 65 additions & 130 deletions

File tree

backend/app/routers/export.py

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -103,33 +103,6 @@ def _replace(match: re.Match) -> str:
103103
</body>
104104
</html>"""
105105

106-
PDF_PRINT_CSS = """
107-
.print-hint { background: #fef3c7; border: 1px solid #fde68a; border-radius: 8px; padding: 0.75em 1em; margin-bottom: 1.5em; color: #78350f; font-size: 0.9em; }
108-
.print-hint kbd { background: #fffbeb; border: 1px solid #fcd34d; border-radius: 4px; padding: 0 0.35em; font-family: inherit; font-size: 0.85em; }
109-
@media print {
110-
body { margin: 0; max-width: 100%; }
111-
@page { margin: 1.5cm; }
112-
.print-hint { display: none; }
113-
}
114-
"""
115-
116-
PDF_HINT_BANNER = (
117-
'<div class="print-hint">'
118-
'已自動開啟列印對話框 — 若未彈出,請按 '
119-
'<kbd>Ctrl</kbd>+<kbd>P</kbd>(Windows)或 '
120-
'<kbd>⌘</kbd>+<kbd>P</kbd>(Mac),'
121-
'在印表機選擇「另存為 PDF / Save as PDF」即可下載。'
122-
'</div>'
123-
)
124-
125-
PDF_AUTO_PRINT_SCRIPT = (
126-
"<script>"
127-
"window.addEventListener('load', function () { "
128-
"setTimeout(function () { window.print(); }, 150); "
129-
"});"
130-
"</script>"
131-
)
132-
133106
SITE_INDEX_TEMPLATE = """<!DOCTYPE html>
134107
<html lang="zh-TW">
135108
<head>
@@ -269,7 +242,6 @@ def parse_table(m):
269242
@router.get("/page/{slug}")
270243
async def export_page(
271244
slug: str,
272-
format: str = Query("html", pattern="^(html|pdf)$"),
273245
user=Depends(get_current_user),
274246
):
275247
db = await get_db()
@@ -293,21 +265,6 @@ async def export_page(
293265
content=html_content,
294266
)
295267

296-
if format == "pdf":
297-
# Browser-print approach: inject print CSS + user hint banner +
298-
# auto-trigger window.print() on load. The user picks "Save as PDF"
299-
# from the print dialog. Response stays HTML (filename .html) — we
300-
# don't claim to return a real PDF.
301-
pdf_html = (
302-
full_html
303-
.replace("</style>", PDF_PRINT_CSS + "</style>", 1)
304-
.replace("<body>", "<body>" + PDF_HINT_BANNER, 1)
305-
.replace("</body>", PDF_AUTO_PRINT_SCRIPT + "</body>", 1)
306-
)
307-
return HTMLResponse(content=pdf_html, headers={
308-
"Content-Disposition": f'inline; filename="{slug}.html"',
309-
})
310-
311268
return HTMLResponse(content=full_html, headers={
312269
"Content-Disposition": f'attachment; filename="{slug}.html"',
313270
})

backend/tests/test_export.py

Lines changed: 5 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -20,68 +20,23 @@ async def test_export_page(auth_client):
2020
assert b"Export content" in response.content
2121

2222
@pytest.mark.asyncio
23-
async def test_export_page_pdf_auto_prints(auth_client):
24-
"""format=pdf returns HTML that auto-opens the browser print dialog."""
25-
await auth_client.post("/api/pages", json={
26-
"title": "PDF Page",
27-
"content_md": "PDF content body",
28-
"slug": "pdf-page",
29-
})
30-
31-
response = await auth_client.get("/api/export/page/pdf-page?format=pdf")
32-
assert response.status_code == 200
33-
assert response.headers["Content-Type"] == "text/html; charset=utf-8"
34-
# inline (browser displays it) rather than attachment (which would download
35-
# a useless .html pretending to be a PDF).
36-
assert "inline" in response.headers["content-disposition"]
37-
38-
body = response.text
39-
assert "PDF content body" in body
40-
assert "window.print()" in body
41-
assert "print-hint" in body
42-
# Print CSS hides the hint banner in the rendered PDF.
43-
assert "@media print" in body
44-
assert ".print-hint { display: none; }" in body
45-
46-
47-
@pytest.mark.asyncio
48-
async def test_export_page_html_has_no_auto_print(auth_client):
49-
"""format=html must NOT auto-print — it's a plain download."""
23+
async def test_export_page_is_plain_download(auth_client):
24+
"""Page export is a plain HTML attachment — printing is handled client-side
25+
via the browser's own print dialog on the live page view, so the export
26+
must not ship auto-print scripts or hint banners."""
5027
await auth_client.post("/api/pages", json={
5128
"title": "Html Only",
5229
"content_md": "Plain html export",
5330
"slug": "html-only",
5431
})
5532

56-
response = await auth_client.get("/api/export/page/html-only?format=html")
33+
response = await auth_client.get("/api/export/page/html-only")
5734
assert response.status_code == 200
5835
assert "attachment" in response.headers["content-disposition"]
5936
assert "window.print()" not in response.text
6037
assert "print-hint" not in response.text
6138

6239

63-
@pytest.mark.asyncio
64-
async def test_export_page_pdf_content_not_double_injected(auth_client):
65-
"""Page content containing '<body>' literal must not trigger re-injection.
66-
67-
md_to_simple_html escapes `<` and `>`, so user content cannot smuggle real
68-
`<body>` / `</style>` tags into the template — but guard the assumption
69-
with a test so regressions in the markdown escaping are caught here.
70-
"""
71-
await auth_client.post("/api/pages", json={
72-
"title": "Tricky",
73-
"content_md": "Body tag test: <body> </style> </body>",
74-
"slug": "tricky-tags",
75-
})
76-
77-
response = await auth_client.get("/api/export/page/tricky-tags?format=pdf")
78-
assert response.status_code == 200
79-
body = response.text
80-
# Exactly one auto-print script and one hint banner.
81-
assert body.count("window.print()") == 1
82-
assert body.count('class="print-hint"') == 1
83-
84-
8540
@pytest.mark.asyncio
8641
async def test_export_page_inlines_media_images(auth_client):
8742
"""Images served from /api/media must be embedded as data URIs so the
@@ -110,12 +65,6 @@ async def test_export_page_inlines_media_images(auth_client):
11065
assert expected in body
11166
# Raw /api/media path must not leak through in the exported src.
11267
assert f'src="/api/media/{filename}"' not in body
113-
114-
# PDF export goes through the same inlining path.
115-
pdf_response = await auth_client.get(
116-
"/api/export/page/with-image?format=pdf"
117-
)
118-
assert expected in pdf_response.text
11968
finally:
12069
filepath.unlink(missing_ok=True)
12170

frontend/src/components/Layout/Layout.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default function Layout({ children }) {
3939
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
4040

4141
{/* Navbar */}
42-
<nav className="h-12 bg-surface border-b border-border flex items-center px-4 shrink-0">
42+
<nav className="h-12 bg-surface border-b border-border flex items-center px-4 shrink-0 no-print">
4343
<button
4444
onClick={() => setSidebarOpen(!sidebarOpen)}
4545
className="p-1.5 rounded hover:bg-surface-hover mr-2 text-text-secondary"
@@ -102,7 +102,7 @@ export default function Layout({ children }) {
102102
/>
103103
)}
104104
<div
105-
className={`shrink-0 overflow-hidden transition-all duration-200 ease-in-out z-40
105+
className={`shrink-0 overflow-hidden transition-all duration-200 ease-in-out z-40 no-print
106106
max-md:fixed max-md:top-12 max-md:bottom-0 max-md:left-0 max-md:shadow-lg`}
107107
style={{ width: sidebarOpen ? '240px' : '0px' }}
108108
>

frontend/src/components/Viewer/MarkdownViewer.jsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,33 @@ export default function MarkdownViewer({
121121
[navigate, onDiagramClick],
122122
)
123123

124+
// Hoisted so both the top-level effect and the transclusion loader can
125+
// process mermaid blocks — transcluded content arrives after the initial
126+
// effect runs, so those diagrams would otherwise stay stuck at the loader.
127+
const renderMermaidIn = useCallback(async (root) => {
128+
if (!root) return
129+
const blocks = root.querySelectorAll('[data-mermaid]:not([data-mermaid-rendered])')
130+
if (blocks.length === 0) return
131+
const isDark = document.documentElement.classList.contains('dark')
132+
mermaid.initialize({
133+
startOnLoad: false,
134+
theme: isDark ? 'dark' : 'default',
135+
securityLevel: 'strict',
136+
})
137+
const stamp = Date.now()
138+
blocks.forEach(async (el, i) => {
139+
el.setAttribute('data-mermaid-rendered', '1')
140+
const code = decodeURIComponent(el.dataset.mermaid)
141+
try {
142+
const id = `mermaid-${stamp}-${Math.random().toString(36).slice(2, 8)}-${i}`
143+
const { svg } = await mermaid.render(id, code)
144+
el.innerHTML = svg
145+
} catch (err) {
146+
el.innerHTML = `<pre class="mermaid-error">Mermaid error: ${escapeHtml(err.message || 'Unknown error')}</pre>`
147+
}
148+
})
149+
}, [])
150+
124151
// Load transclusions (disabled in publicMode: we never fetch private page
125152
// content anonymously; show a placeholder instead — see Q2 in to-do.md)
126153
useEffect(() => {
@@ -138,34 +165,17 @@ export default function MarkdownViewer({
138165
try {
139166
const res = await api.get(`/pages/${slug}`)
140167
el.innerHTML = DOMPurify.sanitize(renderMarkdown(res.data.content_md || ''))
168+
await renderMermaidIn(el)
141169
} catch {
142170
el.innerHTML = '<em class="text-gray-400">Page not found</em>'
143171
}
144172
})
145-
}, [html, publicMode])
173+
}, [html, publicMode, renderMermaidIn])
146174

147175
// Render Mermaid diagrams
148176
useEffect(() => {
149-
if (!containerRef.current) return
150-
const blocks = containerRef.current.querySelectorAll('[data-mermaid]')
151-
if (blocks.length === 0) return
152-
const isDark = document.documentElement.classList.contains('dark')
153-
mermaid.initialize({
154-
startOnLoad: false,
155-
theme: isDark ? 'dark' : 'default',
156-
securityLevel: 'strict',
157-
})
158-
blocks.forEach(async (el, i) => {
159-
const code = decodeURIComponent(el.dataset.mermaid)
160-
try {
161-
const id = `mermaid-${Date.now()}-${i}`
162-
const { svg } = await mermaid.render(id, code)
163-
el.innerHTML = svg
164-
} catch (err) {
165-
el.innerHTML = `<pre class="mermaid-error">Mermaid error: ${escapeHtml(err.message || 'Unknown error')}</pre>`
166-
}
167-
})
168-
}, [html])
177+
renderMermaidIn(containerRef.current)
178+
}, [html, renderMermaidIn])
169179

170180
// Load Draw.io diagram SVGs.
171181
//

frontend/src/index.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,3 +1003,18 @@ mark {
10031003

10041004
/* TOC dark */
10051005
.dark .toc-rail { background: var(--color-surface); border-color: var(--color-border); }
1006+
1007+
/* Print — strip chrome so Ctrl+P / "Save as PDF" produces a clean article */
1008+
@media print {
1009+
.no-print { display: none !important; }
1010+
html, body { background: #ffffff !important; color: #000000 !important; }
1011+
/* Unfreeze the flex scroll container so the article flows across pages */
1012+
.h-screen { height: auto !important; }
1013+
.overflow-hidden, .overflow-auto { overflow: visible !important; }
1014+
main { padding: 0 !important; }
1015+
article { max-width: 100% !important; }
1016+
.bg-surface { background: transparent !important; box-shadow: none !important; border: none !important; }
1017+
a { color: inherit; text-decoration: none; }
1018+
pre, code, table, img, .mermaid-block { break-inside: avoid; page-break-inside: avoid; }
1019+
@page { margin: 1.5cm; }
1020+
}

frontend/src/pages/PageView.jsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -343,14 +343,16 @@ export default function PageView() {
343343
<button
344344
onClick={() => {
345345
setMenuOpen(false)
346-
window.open(`/api/export/page/${slug}?format=pdf`, '_blank')
346+
window.print()
347347
}}
348348
className="w-full text-left px-3 py-2 text-sm text-text hover:bg-surface-hover flex items-center gap-2"
349349
>
350-
<svg className="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
351-
<path d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
350+
<svg className="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24">
351+
<path d="M6 9V2h12v7" />
352+
<path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2" />
353+
<path d="M6 14h12v8H6z" />
352354
</svg>
353-
Export PDF
355+
Print / Save as PDF
354356
</button>
355357
{writable && (
356358
<>
@@ -387,7 +389,7 @@ export default function PageView() {
387389
</div>
388390

389391
{/* Mobile: inline actions under the title. Desktop uses the right-rail dock. */}
390-
<div className="mb-4 lg:hidden">
392+
<div className="mb-4 lg:hidden no-print">
391393
{renderActions('inline')}
392394
</div>
393395

@@ -438,7 +440,7 @@ export default function PageView() {
438440

439441
{/* Backlinks */}
440442
{backlinks.length > 0 && (
441-
<div className="mt-6 p-4 bg-surface rounded-xl shadow-sm border border-border">
443+
<div className="mt-6 p-4 bg-surface rounded-xl shadow-sm border border-border no-print">
442444
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3">
443445
Linked from ({backlinks.length})
444446
</h3>
@@ -457,11 +459,13 @@ export default function PageView() {
457459
)}
458460

459461
{/* Discussion */}
460-
<Comments slug={slug} />
462+
<div className="no-print">
463+
<Comments slug={slug} />
464+
</div>
461465
</article>
462466

463467
{/* Right rail: actions pinned above TOC so Edit has one consistent home */}
464-
<aside className="hidden lg:block">
468+
<aside className="hidden lg:block no-print">
465469
<div className="page-right-rail">
466470
<div className="page-action-dock-wrap">
467471
{renderActions('dock')}

0 commit comments

Comments
 (0)