1+ import base64
12import io
3+ import mimetypes
24import re
35import urllib .parse
46import zipfile
7+ from pathlib import Path
58
69from fastapi import APIRouter , Depends , HTTPException , Query
710from fastapi .responses import StreamingResponse , HTMLResponse
811
912from app .auth import get_current_user , require_admin
13+ from app .config import settings
1014from app .database import get_db
1115from app .services .acl import resolve_page_permission
1216
@@ -18,6 +22,43 @@ def _sanitize_url(url: str) -> str:
1822 return "about:blank"
1923 return url
2024
25+
26+ # Matches `src="/api/media/<name>"` (optionally with a scheme+host prefix or
27+ # query/fragment). Only attribute-form srcs — URLs in user text aren't rewritten.
28+ _MEDIA_SRC_PATTERN = re .compile (
29+ r'(?P<prefix>src=")'
30+ r'(?:https?://[^/"]+)?'
31+ r'/api/media/'
32+ r'(?P<filename>[^"?#]+)'
33+ r'(?:[?#][^"]*)?"'
34+ )
35+
36+
37+ def _inline_media_srcs (html : str ) -> str :
38+ """Rewrite ``/api/media/<file>`` image srcs into ``data:`` URIs.
39+
40+ Without this, downloaded HTML opened via ``file://`` and print-dialog
41+ PDFs saved from the browser can't resolve the media URLs, so embedded
42+ images silently disappear from the export. Inlining keeps the artifact
43+ self-contained.
44+ """
45+ media_dir = Path (settings .MEDIA_DIR ).resolve ()
46+
47+ def _replace (match : re .Match ) -> str :
48+ filename = match .group ("filename" )
49+ try :
50+ filepath = (media_dir / filename ).resolve ()
51+ if not filepath .is_relative_to (media_dir ) or not filepath .is_file ():
52+ return match .group (0 )
53+ data = filepath .read_bytes ()
54+ except OSError :
55+ return match .group (0 )
56+ mime = mimetypes .guess_type (str (filepath ))[0 ] or "application/octet-stream"
57+ b64 = base64 .b64encode (data ).decode ("ascii" )
58+ return f'{ match .group ("prefix" )} data:{ mime } ;base64,{ b64 } "'
59+
60+ return _MEDIA_SRC_PATTERN .sub (_replace , html )
61+
2162router = APIRouter (prefix = "/api/export" , tags = ["export" ])
2263
2364HTML_TEMPLATE = """<!DOCTYPE html>
@@ -27,20 +68,26 @@ def _sanitize_url(url: str) -> str:
2768<meta name="viewport" content="width=device-width, initial-scale=1.0">
2869<title>{title}</title>
2970<style>
30- body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; color: #1e293b; line-height: 1.7; }}
31- h1 {{ border-bottom: 2px solid #e2e8f0; padding-bottom: 0.3em; }}
32- h2 {{ border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3em; }}
33- pre {{ background: #1e293b; color: #e2e8f0; padding: 1em; border-radius: 8px; overflow-x: auto; }}
34- code {{ background: #f1f5f9; padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; }}
35- pre code {{ background: none; padding: 0; color: inherit; }}
36- blockquote {{ border-left: 4px solid #e2e8f0; padding-left: 1em; color: #64748b; margin: 0.5em 0; }}
71+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang TC', 'Noto Sans CJK TC', sans-serif; max-width: 820px; margin: 2rem auto; padding: 0 1.25rem; color: #1e293b; line-height: 1.7; background: #ffffff; }}
72+ h1, h2, h3, h4, h5, h6 {{ margin: 1.4em 0 0.6em; font-weight: 600; line-height: 1.3; }}
73+ h1 {{ font-size: 2em; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.3em; }}
74+ h2 {{ font-size: 1.5em; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3em; }}
75+ h3 {{ font-size: 1.25em; }}
76+ p {{ margin: 0.5em 0; }}
77+ ul, ol {{ padding-left: 1.6em; margin: 0.5em 0; }}
78+ li {{ margin: 0.25em 0; }}
79+ pre {{ background: #1e293b; color: #e2e8f0; padding: 1em; border-radius: 8px; overflow-x: auto; font-size: 0.9em; }}
80+ code {{ background: #f1f5f9; padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; font-family: 'SF Mono', Menlo, Consolas, monospace; }}
81+ pre code {{ background: none; padding: 0; color: inherit; font-size: 1em; }}
82+ blockquote {{ border-left: 4px solid #e2e8f0; padding-left: 1em; color: #64748b; margin: 0.75em 0; }}
3783 table {{ border-collapse: collapse; width: 100%; margin: 0.75em 0; }}
3884 th, td {{ border: 1px solid #e2e8f0; padding: 0.5em 0.75em; text-align: left; }}
3985 th {{ background: #f8fafc; font-weight: 600; }}
40- img {{ max-width: 100%; }}
41- a {{ color: #2563eb; }}
86+ img {{ max-width: 100%; border-radius: 6px; display: block; margin: 0.5em 0; }}
87+ a {{ color: #2563eb; text-decoration: underline; }}
88+ a.wikilink {{ color: #2563eb; text-decoration: none; border-bottom: 1px dashed #93c5fd; }}
4289 hr {{ border: none; border-top: 2px solid #e2e8f0; margin: 1.5em 0; }}
43- .callout {{ border: 1px solid; border-radius: 8px ; padding: 0.75em 1em; margin: 1em 0; }}
90+ .callout {{ border: 1px solid; border-left-width: 4px; border- radius: 6px ; padding: 0.75em 1em; margin: 1em 0; }}
4491 .callout-info {{ border-color: #bfdbfe; background: #f0f7ff; }}
4592 .callout-warning {{ border-color: #fde68a; background: #fffdf5; }}
4693 .callout-tip {{ border-color: #a7f3d0; background: #f0fdf8; }}
@@ -55,6 +102,33 @@ def _sanitize_url(url: str) -> str:
55102</body>
56103</html>"""
57104
105+ PDF_PRINT_CSS = """
106+ .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; }
107+ .print-hint kbd { background: #fffbeb; border: 1px solid #fcd34d; border-radius: 4px; padding: 0 0.35em; font-family: inherit; font-size: 0.85em; }
108+ @media print {
109+ body { margin: 0; max-width: 100%; }
110+ @page { margin: 1.5cm; }
111+ .print-hint { display: none; }
112+ }
113+ """
114+
115+ PDF_HINT_BANNER = (
116+ '<div class="print-hint">'
117+ '已自動開啟列印對話框 — 若未彈出,請按 '
118+ '<kbd>Ctrl</kbd>+<kbd>P</kbd>(Windows)或 '
119+ '<kbd>⌘</kbd>+<kbd>P</kbd>(Mac),'
120+ '在印表機選擇「另存為 PDF / Save as PDF」即可下載。'
121+ '</div>'
122+ )
123+
124+ PDF_AUTO_PRINT_SCRIPT = (
125+ "<script>"
126+ "window.addEventListener('load', function () { "
127+ "setTimeout(function () { window.print(); }, 150); "
128+ "});"
129+ "</script>"
130+ )
131+
58132SITE_INDEX_TEMPLATE = """<!DOCTYPE html>
59133<html lang="zh-TW">
60134<head>
@@ -102,6 +176,20 @@ def save_block(m):
102176 for i , b in enumerate (blocks ):
103177 html = html .replace (f"%%BLOCK_{ i } %%" , b )
104178
179+ # Inline code — stash before other transforms so content can't be mangled
180+ # (e.g. `**x**` inside code must stay literal). Double-backtick form first so
181+ # an escaped backtick inside ``…`` doesn't desync single-backtick pairing —
182+ # otherwise every `code` span later in the document gets flipped (</code>
183+ # where <code> was expected), which is how the 部署方式 section broke.
184+ inline_codes = []
185+ def save_inline (content ):
186+ idx = len (inline_codes )
187+ inline_codes .append (content )
188+ return f"%%INLINE_{ idx } %%"
189+
190+ html = re .sub (r"``\s*(.+?)\s*``" , lambda m : save_inline (m .group (1 )), html )
191+ html = re .sub (r"`([^`\n]+)`" , lambda m : save_inline (m .group (1 )), html )
192+
105193 # Headers
106194 for i in range (6 , 0 , - 1 ):
107195 html = re .sub (rf"^{ '#' * i } \s+(.+)$" , rf"<h{ i } >\1</h{ i } >" , html , flags = re .MULTILINE )
@@ -114,9 +202,6 @@ def save_block(m):
114202 html = re .sub (r"\*\*(.+?)\*\*" , r"<strong>\1</strong>" , html )
115203 html = re .sub (r"\*(.+?)\*" , r"<em>\1</em>" , html )
116204
117- # Inline code
118- html = re .sub (r"`([^`]+)`" , r"<code>\1</code>" , html )
119-
120205 # Wikilinks
121206 html = re .sub (r"\[\[([^\]|]+?)\|([^\]]+?)\]\]" , r'<a href="\1.html">\2</a>' , html )
122207 html = re .sub (r"\[\[([^\]|]+?)\]\]" , r'<a href="\1.html">\1</a>' , html )
@@ -173,6 +258,10 @@ def parse_table(m):
173258 # Paragraphs
174259 html = re .sub (r"^(?!<[a-z/])((?!\s*$).+)$" , r"<p>\1</p>" , html , flags = re .MULTILINE )
175260
261+ # Restore stashed inline code
262+ for i , content in enumerate (inline_codes ):
263+ html = html .replace (f"%%INLINE_{ i } %%" , f"<code>{ content } </code>" )
264+
176265 return html
177266
178267
@@ -193,21 +282,24 @@ async def export_page(
193282 page = dict (rows [0 ])
194283 if await resolve_page_permission (db , user , page ["id" ]) == "none" :
195284 raise HTTPException (status_code = 404 , detail = "Page not found" )
196- html_content = md_to_simple_html (page ["content_md" ])
285+ html_content = _inline_media_srcs ( md_to_simple_html (page ["content_md" ]) )
197286 full_html = HTML_TEMPLATE .format (
198287 title = page ["title" ],
199288 slug = page ["slug" ],
200289 content = html_content ,
201290 )
202291
203292 if format == "pdf" :
204- # Return HTML with print-friendly styling — browser can use Ctrl+P
205- pdf_html = full_html .replace ("</style>" , """
206- @media print {
207- body { margin: 0; max-width: 100%; }
208- @page { margin: 1.5cm; }
209- }
210- </style>""" )
293+ # Browser-print approach: inject print CSS + user hint banner +
294+ # auto-trigger window.print() on load. The user picks "Save as PDF"
295+ # from the print dialog. Response stays HTML (filename .html) — we
296+ # don't claim to return a real PDF.
297+ pdf_html = (
298+ full_html
299+ .replace ("</style>" , PDF_PRINT_CSS + "</style>" , 1 )
300+ .replace ("<body>" , "<body>" + PDF_HINT_BANNER , 1 )
301+ .replace ("</body>" , PDF_AUTO_PRINT_SCRIPT + "</body>" , 1 )
302+ )
211303 return HTMLResponse (content = pdf_html , headers = {
212304 "Content-Disposition" : f'inline; filename="{ slug } .html"' ,
213305 })
@@ -238,7 +330,7 @@ async def export_site(
238330 page_links = []
239331 for p in pages :
240332 page = dict (p )
241- html_content = md_to_simple_html (page ["content_md" ])
333+ html_content = _inline_media_srcs ( md_to_simple_html (page ["content_md" ]) )
242334 full_html = HTML_TEMPLATE .format (
243335 title = page ["title" ],
244336 slug = page ["slug" ],
0 commit comments