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' {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 '