diff --git a/backend/app/export/exporters/docx.py b/backend/app/export/exporters/docx.py
index 74c9d9c..d3f03c0 100644
--- a/backend/app/export/exporters/docx.py
+++ b/backend/app/export/exporters/docx.py
@@ -17,6 +17,7 @@ from docx.oxml.ns import qn
from docx.shared import Inches, Mm, Pt, RGBColor
from app.contracts import ExportOptions
+from app.export.themes import CALLOUTS, print_theme_warning
from app.export.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import (
MERMAID_WARNING,
@@ -53,6 +54,7 @@ class DocxExporter:
self._configure_normal_style()
self._configure_page(options)
warnings: list[str] = []
+ print_theme_warning(options, warnings, "DOCX")
self._render_header(document, options, warnings)
self._render_children(document.children, warnings)
@@ -126,6 +128,19 @@ class DocxExporter:
p = self._doc.add_paragraph()
self._render_inline(p, node.children, warnings)
+ def _block_callout(self, node, warnings):
+ icon, color = CALLOUTS[node.attributes['kind']]
+ p = self._doc.add_paragraph()
+ p.add_run(icon+' ')
+ self._render_inline(p,node.children[0].children,warnings)
+ for run in p.runs:
+ run.bold = True
+ run.font.color.rgb = RGBColor.from_string(color[1:])
+ shading = OxmlElement('w:shd')
+ shading.set(qn('w:fill'),'F6F8FA')
+ p._p.get_or_add_pPr().append(shading)
+ self._render_children(node.children[1:],warnings)
+
def _block_blockquote(self, node: DocumentNode, warnings: list[str]) -> None:
# 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器,
# 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。
@@ -171,6 +186,27 @@ class DocxExporter:
if child.type == "list":
self._block_list(child, warnings, level + 1, color)
continue
+ if child.type != "paragraph" and hasattr(self, f"_block_{child.type}"):
+ if first:
+ marker_p = self._doc.add_paragraph()
+ marker_p.paragraph_format.left_indent = indent
+ self._add_run(marker_p, marker)
+ first = False
+ before = len(self._doc.paragraphs)
+ before_tables = len(self._doc.tables)
+ self._render_block(child, warnings)
+ for nested_p in self._doc.paragraphs[before:]:
+ current = nested_p.paragraph_format.left_indent or 0
+ nested_p.paragraph_format.left_indent = current + indent
+ for table in self._doc.tables[before_tables:]:
+ table_indent = table._tbl.tblPr.find(qn("w:tblInd"))
+ if table_indent is None:
+ table_indent = OxmlElement("w:tblInd")
+ table._tbl.tblPr.append(table_indent)
+ current_twips = int(table_indent.get(qn("w:w"), "0"))
+ table_indent.set(qn("w:w"), str(current_twips + indent.twips))
+ table_indent.set(qn("w:type"), "dxa")
+ continue
p = self._doc.add_paragraph()
p.paragraph_format.left_indent = indent
if first:
diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py
index 54af119..ac8c8e8 100644
--- a/backend/app/export/exporters/html.py
+++ b/backend/app/export/exporters/html.py
@@ -12,6 +12,7 @@ from datetime import datetime
from urllib.parse import urlparse
from app.contracts import ExportOptions
+from app.export.themes import html_theme, CALLOUTS
from app.export.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import FunctionPlotBudget, format_plot_diagnostic
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
@@ -34,32 +35,50 @@ def _safe_url(url: str) -> str | None:
return url
_BASE_CSS = """
-body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; }
-article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: #fff; }
-article.theme-dark { background: #0d1117; color: #c9d1d9; }
+body { margin: 0; background: var(--page); color: var(--text); font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; }
+article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: var(--surface); }
+
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.4em 0 0.6em; }
h1.title { margin-top: 0; }
p { margin: 0.6em 0; }
-a { color: #0969da; }
-code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: #f0f1f3; padding: 0.15em 0.35em; border-radius: 3px; }
-pre { background: #f6f8fa; padding: 14px 16px; border-radius: 6px; overflow-x: auto; }
+a { color: var(--accent); }
+code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: var(--code); padding: 0.15em 0.35em; border-radius: 3px; }
+pre { background: var(--code); padding: 14px 16px; border-radius: 6px; overflow-x: auto; }
+pre.code-theme-github-light { background: #f6f8fa; color: #1f2328; }
pre.code-theme-github-dark { background: #0d1117; color: #c9d1d9; }
pre code { background: none; padding: 0; }
-pre.mermaid, pre.function-plot { border: 1px dashed #d0d7de; }
+pre.mermaid, pre.function-plot { border: 1px dashed var(--border); }
figure.function-plot { margin: 1em 0; text-align: center; }
figure.function-plot svg { max-width: 100%; height: auto; }
-blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 4px solid #d0d7de; color: #57606a; }
+blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 4px solid var(--border); color: var(--muted); }
img { max-width: 100%; }
table { border-collapse: collapse; margin: 0.8em 0; }
-th, td { border: 1px solid #d0d7de; padding: 6px 12px; }
-th { background: #f6f8fa; }
-dl.metadata { font-size: 0.85em; color: #57606a; border-top: 1px solid #eaeef2; border-bottom: 1px solid #eaeef2; padding: 0.6em 0; }
+th, td { border: 1px solid var(--border); padding: 6px 12px; }
+th { background: var(--code); }
+dl.metadata { font-size: 0.85em; color: var(--muted); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); padding: 0.6em 0; }
dl.metadata dt { display: inline; font-weight: 600; margin-right: 0.4em; }
dl.metadata dd { display: inline; margin: 0 1.2em 0 0; }
.math, .math-block { overflow-x: auto; padding: 0.4em 0; }
.task-list-item { list-style: none; }
.task-list-item input { margin-right: 0.4em; }
-hr { border: none; border-top: 1px solid #d0d7de; margin: 1.4em 0; }
+hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
+.callout { --callout:var(--accent); border:1px solid var(--border); border-left:4px solid var(--callout,var(--accent)); border-radius:6px; margin:1em 0; padding:.8em 1em; }
+.callout-title { display:block; font-weight:bold; color:var(--callout,var(--accent)); }
+.callout-content { color:var(--text); }
+.callout[data-kind="warning"], .callout[data-kind="question"] { --callout:#805400; }
+.callout[data-kind="danger"], .callout[data-kind="failure"], .callout[data-kind="bug"] { --callout:#b42318; }
+.callout[data-kind="tip"], .callout[data-kind="success"] { --callout:#176f41; }
+.callout[data-kind="example"], .callout[data-kind="abstract"], .callout[data-kind="important"] { --callout:#7041a0; }
+.theme-dark .callout, .theme-midnight-purple .callout { --callout:#a5d6ff; }
+.theme-dark .callout[data-kind="warning"], .theme-midnight-purple .callout[data-kind="warning"], .theme-dark .callout[data-kind="question"], .theme-midnight-purple .callout[data-kind="question"] { --callout:#f2cc60; }
+.theme-dark .callout[data-kind="danger"], .theme-midnight-purple .callout[data-kind="danger"], .theme-dark .callout[data-kind="failure"], .theme-midnight-purple .callout[data-kind="failure"], .theme-dark .callout[data-kind="bug"], .theme-midnight-purple .callout[data-kind="bug"] { --callout:#ffa198; }
+.theme-dark .callout[data-kind="tip"], .theme-midnight-purple .callout[data-kind="tip"], .theme-dark .callout[data-kind="success"], .theme-midnight-purple .callout[data-kind="success"] { --callout:#7ee787; }
+.theme-dark .callout[data-kind="important"], .theme-midnight-purple .callout[data-kind="important"], .theme-dark .callout[data-kind="abstract"], .theme-midnight-purple .callout[data-kind="abstract"], .theme-dark .callout[data-kind="example"], .theme-midnight-purple .callout[data-kind="example"] { --callout:#d2a8ff; }
+figure.function-plot svg text { fill:var(--muted); }
+figure.function-plot svg line { stroke:var(--border); }
+figure.function-plot svg line[stroke="#57606a"] { stroke:var(--muted); }
+summary.callout-title { cursor:pointer; display:list-item; }
+.callout { overflow-wrap:anywhere; }
""".strip()
@@ -72,6 +91,7 @@ class HtmlExporter:
self._plot_budget = FunctionPlotBudget()
self._plot_renderer = FunctionPlotStaticRenderer()
warnings: list[str] = []
+ self._theme_id, self._theme_css = html_theme(options.theme_id, warnings)
body = self._render_children(document.children, warnings)
content = self._assemble(document, options, body, warnings)
return ExportResult(
@@ -95,10 +115,10 @@ class HtmlExporter:
]
if title:
parts.append(f"
{html.escape(title)}")
- parts.append(f"")
+ parts.append(f"")
parts.append("")
parts.append("")
- parts.append(f'')
+ parts.append(f'')
if options.include_title and title:
parts.append(f'{html.escape(title)}
')
if options.include_metadata:
@@ -145,6 +165,17 @@ class HtmlExporter:
def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str:
return f"{self._render_children(node.children, warnings)}
"
+ def _render_callout(self, node, warnings):
+ kind = node.attributes['kind']
+ title = self._render_children(node.children[0].children,warnings)
+ icon = html.escape(CALLOUTS[kind][0])
+ body = self._render_children(node.children[1:],warnings)
+ heading = f'{icon} {title}'
+ if node.attributes.get('fold'):
+ opened = ' open' if node.attributes['fold'] == '+' else ''
+ return f'{heading}
{body}
'
+ return f''
+
def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str:
return f"{self._render_children(node.children, warnings)}
"
diff --git a/backend/app/export/exporters/pdf.py b/backend/app/export/exporters/pdf.py
index e15f742..2f44d22 100644
--- a/backend/app/export/exporters/pdf.py
+++ b/backend/app/export/exporters/pdf.py
@@ -19,6 +19,7 @@ from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.platypus import (
Paragraph,
+ Indenter,
Preformatted,
SimpleDocTemplate,
Spacer,
@@ -28,6 +29,7 @@ from reportlab.platypus import (
from reportlab.platypus.flowables import HRFlowable
from app.contracts import ExportOptions
+from app.export.themes import CALLOUTS, print_theme_warning
from app.export.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import (
MERMAID_WARNING,
@@ -119,6 +121,7 @@ class PdfExporter:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
self._styles = _make_styles()
warnings: list[str] = []
+ print_theme_warning(options, warnings, "PDF")
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
self._options = options
@@ -180,6 +183,15 @@ class PdfExporter:
def _block_paragraph(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["body"]))
+ def _block_callout(self, node, story, warnings):
+ kind = node.attributes['kind']
+ icon, color = CALLOUTS[kind]
+ title = self._render_inline(node.children[0].children,warnings)
+ style = ParagraphStyle('callout-'+kind,parent=self._styles['body'],textColor=color,
+ backColor='#f6f8fa',borderColor=color,borderWidth=1,borderPadding=6,spaceBefore=8,spaceAfter=8)
+ story.append(Paragraph(_html.escape(icon)+' '+title,style))
+ self._render_children(node.children[1:],story,warnings)
+
def _block_blockquote(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
# 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器,
# 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。
@@ -251,6 +263,12 @@ class PdfExporter:
self._block_list(child, story, warnings, indent + 14, color)
elif child.type == "paragraph":
parts.append(self._render_inline(child.children, warnings))
+ elif hasattr(self, f"_block_{child.type}"):
+ flush()
+ # Keep block content inside the list frame, including tables and callouts.
+ story.append(Indenter(left=indent))
+ self._render_block(child, story, warnings)
+ story.append(Indenter(left=-indent))
else:
parts.append(self._render_inline_node(child, warnings))
flush()
diff --git a/backend/app/export/markdown.py b/backend/app/export/markdown.py
index bf9934d..436ebda 100644
--- a/backend/app/export/markdown.py
+++ b/backend/app/export/markdown.py
@@ -8,6 +8,10 @@
from __future__ import annotations
import mistune
+from mistune.plugins.table import table_in_list, table_in_quote
+import re
+from copy import deepcopy
+from app.export.themes import CALLOUTS, ALIASES
from app.export.document import Document, DocumentNode
@@ -21,6 +25,8 @@ _FUNCTION_PLOT_LANGS = {"function-plot", "function_plot", "functionplot"}
def parse_document(markdown: str) -> Document:
"""把 Markdown 文本解析为 Document AST 根节点。"""
renderer = mistune.create_markdown(renderer="ast", plugins=_PLUGINS)
+ table_in_quote(renderer)
+ table_in_list(renderer)
tokens = renderer(markdown)
mapper = _AstMapper()
return Document(node_id=mapper.next_id(), children=mapper.map_blocks(tokens))
@@ -70,6 +76,28 @@ class _AstMapper:
if kind == "block_code":
return self._map_code(token)
if kind == "block_quote":
+ children = deepcopy(token.get('children', []))
+ first = children[0] if children else {}
+ inline = first.get('children', [])
+ if first.get('type') == 'paragraph' and inline and inline[0].get('type') == 'text':
+ match = re.match(r'^\[!([\w-]+)\]([+-]?)[ \t]*', inline[0].get('raw', ''))
+ if match:
+ name = match[1].lower()
+ name = ALIASES.get(name, name)
+ if name not in CALLOUTS:
+ name = 'note'
+ inline[0]['raw'] = inline[0]['raw'][match.end():]
+ split = next((i for i,t in enumerate(inline) if t['type'] in ('softbreak','linebreak')),len(inline))
+ title = inline[:split]
+ if not any(t.get('raw') or t.get('children') for t in title):
+ title = [{'type':'text','raw':match[1].lower().capitalize()}]
+ first['children'] = inline[split+1:]
+ if not first['children']:
+ children.pop(0)
+ heading = DocumentNode(type='paragraph',node_id=self.next_id(),children=self.map_inline(title))
+ return DocumentNode(type='callout',node_id=self.next_id(),
+ attributes={'kind':name,'fold':match[2]},
+ children=[heading,*self.map_blocks(children)])
return DocumentNode(
type="blockquote",
node_id=self.next_id(),
diff --git a/backend/app/export/themes.py b/backend/app/export/themes.py
new file mode 100644
index 0000000..94e07ee
--- /dev/null
+++ b/backend/app/export/themes.py
@@ -0,0 +1,33 @@
+"""Export palettes are fixed data; arbitrary theme CSS is never executed."""
+PALETTES = {
+ 'light': ('#f6f7f9','#ffffff','#1f2328','#57606a','#eaeef2','#d0d7de','#0969da'),
+ 'dark': ('#010409','#0d1117','#e6edf3','#b1bac4','#21262d','#57606a','#79c0ff'),
+ 'sepia': ('#eee5d2','#faf4e6','#463b2d','#6b5943','#eae0cd','#b5a58b','#80532a'),
+ 'paper-moments': ('#f4ede0','#fffdf4','#514638','#79654f','#eee7d8','#b8a58f','#8c503b'),
+ 'midnight-purple': ('#100c18','#191322','#eee7f8','#c0accf','#30253f','#705a85','#d3a7ff'),
+}
+
+def html_theme(theme_id, warnings):
+ if theme_id not in PALETTES:
+ warnings.append(f'HTML 不支持主题 {theme_id},已使用 light 导出配色')
+ theme_id = 'light'
+ names = ('page','surface','text','muted','code','border','accent')
+ return theme_id, ':root{' + ';'.join(f'--{k}:{v}' for k,v in zip(names,PALETTES[theme_id])) + '}'
+
+def print_theme_warning(options, warnings, format_name):
+ if options.theme_id != 'light':
+ warnings.append(f'{format_name} 使用浅色打印样式,不支持主题 {options.theme_id};需要主题配色请导出 HTML')
+
+# Semantic type, portable title symbol and contrasting print color.
+CALLOUTS = {
+ 'note': ('i','#0969da'), 'abstract': ('=','#7041a0'),
+ 'info': ('i','#0969da'), 'todo': ('[ ]','#0969da'),
+ 'tip': ('+','#176f41'), 'success': ('+','#176f41'),
+ 'question': ('?','#805400'), 'warning': ('!','#805400'),
+ 'failure': ('x','#b42318'), 'danger': ('!','#b42318'),
+ 'bug': ('!','#b42318'), 'important': ('!','#7041a0'), 'example': ('*','#7041a0'), 'quote': ('>','#57606a'),
+}
+ALIASES = {'summary':'abstract','tldr':'abstract','hint':'tip',
+ 'check':'success','done':'success','help':'question','faq':'question',
+ 'caution':'warning','attention':'warning','fail':'failure','missing':'failure',
+ 'error':'danger','cite':'quote'}
diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py
index cef356a..b5bdc09 100644
--- a/backend/tests/test_export.py
+++ b/backend/tests/test_export.py
@@ -791,3 +791,166 @@ def test_export_limits_concurrent_rendering(monkeypatch) -> None:
finished = asyncio.run(_go())
assert all(j.status == ExportStatus.completed for j in finished)
assert peak <= export_service.MAX_CONCURRENT_RENDERS
+
+
+from app.export.themes import CALLOUTS, ALIASES, PALETTES
+
+@pytest.mark.parametrize("theme", list(PALETTES))
+def test_export_theme_palette(theme):
+ result = HtmlExporter().render(parse_document("`inline`"), ExportOptions(theme_id=theme))
+ text = result.content.decode()
+ assert f"--surface:{PALETTES[theme][1]}" in text
+ assert f"--text:{PALETTES[theme][2]}" in text
+ assert 'pre.code-theme-github-light { background: #f6f8fa; color: #1f2328; }' in text
+ assert not result.warnings
+
+
+def test_unknown_theme_is_not_injected():
+ result = HtmlExporter().render(parse_document("body"), ExportOptions(theme_id=""))
+ assert result.warnings
+ assert '