fix(export): preserve themed callouts and nested table layouts

This commit is contained in:
2026-09-07 01:33:13 +08:00
parent c853add07e
commit cc652508ef
7 changed files with 338 additions and 14 deletions
+36
View File
@@ -17,6 +17,7 @@ from docx.oxml.ns import qn
from docx.shared import Inches, Mm, Pt, RGBColor from docx.shared import Inches, Mm, Pt, RGBColor
from app.contracts import ExportOptions 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.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import ( from app.export.exporters._common import (
MERMAID_WARNING, MERMAID_WARNING,
@@ -53,6 +54,7 @@ class DocxExporter:
self._configure_normal_style() self._configure_normal_style()
self._configure_page(options) self._configure_page(options)
warnings: list[str] = [] warnings: list[str] = []
print_theme_warning(options, warnings, "DOCX")
self._render_header(document, options, warnings) self._render_header(document, options, warnings)
self._render_children(document.children, warnings) self._render_children(document.children, warnings)
@@ -126,6 +128,19 @@ class DocxExporter:
p = self._doc.add_paragraph() p = self._doc.add_paragraph()
self._render_inline(p, node.children, warnings) 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: def _block_blockquote(self, node: DocumentNode, warnings: list[str]) -> None:
# 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器, # 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器,
# 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。 # 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。
@@ -171,6 +186,27 @@ class DocxExporter:
if child.type == "list": if child.type == "list":
self._block_list(child, warnings, level + 1, color) self._block_list(child, warnings, level + 1, color)
continue 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 = self._doc.add_paragraph()
p.paragraph_format.left_indent = indent p.paragraph_format.left_indent = indent
if first: if first:
+45 -14
View File
@@ -12,6 +12,7 @@ from datetime import datetime
from urllib.parse import urlparse from urllib.parse import urlparse
from app.contracts import ExportOptions from app.contracts import ExportOptions
from app.export.themes import html_theme, CALLOUTS
from app.export.document import Document, DocumentNode, ExportResult from app.export.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import FunctionPlotBudget, format_plot_diagnostic from app.export.exporters._common import FunctionPlotBudget, format_plot_diagnostic
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
@@ -34,32 +35,50 @@ def _safe_url(url: str) -> str | None:
return url return url
_BASE_CSS = """ _BASE_CSS = """
body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; } 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: #fff; } article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: var(--surface); }
article.theme-dark { background: #0d1117; color: #c9d1d9; }
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.4em 0 0.6em; } h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.4em 0 0.6em; }
h1.title { margin-top: 0; } h1.title { margin-top: 0; }
p { margin: 0.6em 0; } p { margin: 0.6em 0; }
a { color: #0969da; } a { color: var(--accent); }
code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: #f0f1f3; padding: 0.15em 0.35em; border-radius: 3px; } code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: var(--code); padding: 0.15em 0.35em; border-radius: 3px; }
pre { background: #f6f8fa; padding: 14px 16px; border-radius: 6px; overflow-x: auto; } 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-theme-github-dark { background: #0d1117; color: #c9d1d9; }
pre code { background: none; padding: 0; } 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 { margin: 1em 0; text-align: center; }
figure.function-plot svg { max-width: 100%; height: auto; } 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%; } img { max-width: 100%; }
table { border-collapse: collapse; margin: 0.8em 0; } table { border-collapse: collapse; margin: 0.8em 0; }
th, td { border: 1px solid #d0d7de; padding: 6px 12px; } th, td { border: 1px solid var(--border); padding: 6px 12px; }
th { background: #f6f8fa; } th { background: var(--code); }
dl.metadata { font-size: 0.85em; color: #57606a; border-top: 1px solid #eaeef2; border-bottom: 1px solid #eaeef2; padding: 0.6em 0; } 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 dt { display: inline; font-weight: 600; margin-right: 0.4em; }
dl.metadata dd { display: inline; margin: 0 1.2em 0 0; } dl.metadata dd { display: inline; margin: 0 1.2em 0 0; }
.math, .math-block { overflow-x: auto; padding: 0.4em 0; } .math, .math-block { overflow-x: auto; padding: 0.4em 0; }
.task-list-item { list-style: none; } .task-list-item { list-style: none; }
.task-list-item input { margin-right: 0.4em; } .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() """.strip()
@@ -72,6 +91,7 @@ class HtmlExporter:
self._plot_budget = FunctionPlotBudget() self._plot_budget = FunctionPlotBudget()
self._plot_renderer = FunctionPlotStaticRenderer() self._plot_renderer = FunctionPlotStaticRenderer()
warnings: list[str] = [] warnings: list[str] = []
self._theme_id, self._theme_css = html_theme(options.theme_id, warnings)
body = self._render_children(document.children, warnings) body = self._render_children(document.children, warnings)
content = self._assemble(document, options, body, warnings) content = self._assemble(document, options, body, warnings)
return ExportResult( return ExportResult(
@@ -95,10 +115,10 @@ class HtmlExporter:
] ]
if title: if title:
parts.append(f"<title>{html.escape(title)}</title>") parts.append(f"<title>{html.escape(title)}</title>")
parts.append(f"<style>{_BASE_CSS}</style>") parts.append(f"<style>{self._theme_css}{_BASE_CSS}</style>")
parts.append("</head>") parts.append("</head>")
parts.append("<body>") parts.append("<body>")
parts.append(f'<article class="theme-{html.escape(options.theme_id)}">') parts.append(f'<article class="theme-{html.escape(self._theme_id)}">')
if options.include_title and title: if options.include_title and title:
parts.append(f'<h1 class="title">{html.escape(title)}</h1>') parts.append(f'<h1 class="title">{html.escape(title)}</h1>')
if options.include_metadata: if options.include_metadata:
@@ -145,6 +165,17 @@ class HtmlExporter:
def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str: def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<p>{self._render_children(node.children, warnings)}</p>" return f"<p>{self._render_children(node.children, warnings)}</p>"
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'<span aria-hidden="true">{icon}</span> {title}'
if node.attributes.get('fold'):
opened = ' open' if node.attributes['fold'] == '+' else ''
return f'<details class="callout" data-kind="{kind}"{opened}><summary class="callout-title">{heading}</summary><div class="callout-content">{body}</div></details>'
return f'<aside class="callout" data-kind="{kind}"><div class="callout-title">{heading}</div><div class="callout-content">{body}</div></aside>'
def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str: def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<blockquote>{self._render_children(node.children, warnings)}</blockquote>" return f"<blockquote>{self._render_children(node.children, warnings)}</blockquote>"
+18
View File
@@ -19,6 +19,7 @@ from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.platypus import ( from reportlab.platypus import (
Paragraph, Paragraph,
Indenter,
Preformatted, Preformatted,
SimpleDocTemplate, SimpleDocTemplate,
Spacer, Spacer,
@@ -28,6 +29,7 @@ from reportlab.platypus import (
from reportlab.platypus.flowables import HRFlowable from reportlab.platypus.flowables import HRFlowable
from app.contracts import ExportOptions 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.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import ( from app.export.exporters._common import (
MERMAID_WARNING, MERMAID_WARNING,
@@ -119,6 +121,7 @@ class PdfExporter:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。""" """同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
self._styles = _make_styles() self._styles = _make_styles()
warnings: list[str] = [] warnings: list[str] = []
print_theme_warning(options, warnings, "PDF")
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4) page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
self._options = options self._options = options
@@ -180,6 +183,15 @@ class PdfExporter:
def _block_paragraph(self, node: DocumentNode, story: list, warnings: list[str]) -> None: def _block_paragraph(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["body"])) 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: def _block_blockquote(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
# 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器, # 引用块的直接子节点是块级节点(paragraph/list 等),不能交给行内渲染器,
# 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。 # 否则正文会被当作「无法表示的行内节点」丢弃;逐个渲染并继承引用缩进/颜色。
@@ -251,6 +263,12 @@ class PdfExporter:
self._block_list(child, story, warnings, indent + 14, color) self._block_list(child, story, warnings, indent + 14, color)
elif child.type == "paragraph": elif child.type == "paragraph":
parts.append(self._render_inline(child.children, warnings)) 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: else:
parts.append(self._render_inline_node(child, warnings)) parts.append(self._render_inline_node(child, warnings))
flush() flush()
+28
View File
@@ -8,6 +8,10 @@
from __future__ import annotations from __future__ import annotations
import mistune 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 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: def parse_document(markdown: str) -> Document:
"""把 Markdown 文本解析为 Document AST 根节点。""" """把 Markdown 文本解析为 Document AST 根节点。"""
renderer = mistune.create_markdown(renderer="ast", plugins=_PLUGINS) renderer = mistune.create_markdown(renderer="ast", plugins=_PLUGINS)
table_in_quote(renderer)
table_in_list(renderer)
tokens = renderer(markdown) tokens = renderer(markdown)
mapper = _AstMapper() mapper = _AstMapper()
return Document(node_id=mapper.next_id(), children=mapper.map_blocks(tokens)) return Document(node_id=mapper.next_id(), children=mapper.map_blocks(tokens))
@@ -70,6 +76,28 @@ class _AstMapper:
if kind == "block_code": if kind == "block_code":
return self._map_code(token) return self._map_code(token)
if kind == "block_quote": 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( return DocumentNode(
type="blockquote", type="blockquote",
node_id=self.next_id(), node_id=self.next_id(),
+33
View File
@@ -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'}
+163
View File
@@ -791,3 +791,166 @@ def test_export_limits_concurrent_rendering(monkeypatch) -> None:
finished = asyncio.run(_go()) finished = asyncio.run(_go())
assert all(j.status == ExportStatus.completed for j in finished) assert all(j.status == ExportStatus.completed for j in finished)
assert peak <= export_service.MAX_CONCURRENT_RENDERS 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="</style><script>bad</script>"))
assert result.warnings
assert '<script>' not in result.content.decode()
@pytest.mark.parametrize("name", list(CALLOUTS) + list(ALIASES))
def test_callout_formats(name):
from app.export.exporters.docx import DocxExporter
from app.export.exporters.pdf import PdfExporter
from io import BytesIO
from zipfile import ZipFile
doc = parse_document(f"> [!{name.upper()}]- **Title**\n> Body `code`\n>\n> - item\n> - second")
assert doc.children[0].attributes == {"kind": ALIASES.get(name, name), "fold": "-"}
text = HtmlExporter().render(doc, ExportOptions()).content.decode()
assert '<details class="callout"' in text and '<strong>Title</strong>' in text
assert 'item' in text and '[!' not in text
result = DocxExporter().render(doc, ExportOptions(theme_id="dark"))
assert len(result.warnings) == 1
with ZipFile(BytesIO(result.content)) as z:
xml = z.read("word/document.xml").decode()
assert all(word in xml for word in ["Title", "Body", "item", "second", "w:shd"])
result = PdfExporter().render(doc, ExportOptions(theme_id="sepia"))
assert result.content.startswith(b"%PDF") and len(result.warnings) == 1
@pytest.mark.parametrize("fold", ["", "+", "-"])
def test_callout_fold_and_nested_content(fold):
doc = parse_document(f"> [!NOTE]{fold}\n> body\n>\n> > [!TIP] Nested\n> > child")
text = HtmlExporter().render(doc, ExportOptions()).content.decode()
assert "Note" in text and "Nested" in text and "child" in text
assert (' open>' in text) == (fold == "+")
assert ("<details" in text) == bool(fold)
def test_callout_code_literal():
doc = parse_document("```md\n> [!NOTE] literal\n```\n\n> ordinary quote")
assert [n.type for n in doc.children] == ["code_block", "blockquote"]
@pytest.mark.parametrize("marker,kind,title", [
("[!WARNING]Title", "warning", "Title"),
("[!custom-type] Title", "note", "Title"),
("[!custom_type]+", "note", "Custom_type"),
("[!NOTE]", "note", "Note"),
("[!TIP]-**Title**", "tip", "Title"),
])
def test_export_callout_matches_workspace_syntax(marker, kind, title):
doc = parse_document(f"> {marker}\n> Body")
assert doc.children[0].type == "callout"
assert doc.children[0].attributes["kind"] == kind
result = HtmlExporter().render(doc, ExportOptions())
assert title in result.content.decode() and "Body" in result.content.decode()
assert not result.warnings
@pytest.mark.parametrize("prefix", ["- Parent", "1. Parent", "- [x] Parent"])
def test_list_callout_preserves_export_content_and_order(prefix):
from app.export.exporters.docx import DocxExporter
from app.export.exporters.pdf import PdfExporter
from docx import Document as WordDocument
from io import BytesIO
md = prefix + "\n\n > [!WARNING] NestedTitle\n > NestedBody\n >\n > - Inside\n >\n > > [!TIP] DeepTitle\n > > DeepBody\n\n After\n\n- Sibling"
md = md.replace("\n ", "\n ")
doc = parse_document(md)
result = DocxExporter().render(doc, ExportOptions())
assert not result.warnings
word = WordDocument(BytesIO(result.content))
paragraphs = word.paragraphs
text = " ".join(p.text for p in paragraphs)
expected = ["Parent", "NestedTitle", "NestedBody", "Inside", "DeepTitle", "DeepBody", "After", "Sibling"]
positions = [text.index(part) for part in expected]
assert positions == sorted(positions)
for p in paragraphs:
if any(part in p.text for part in ["NestedTitle", "NestedBody", "DeepBody"]):
assert p.paragraph_format.left_indent.pt >= 18
result = PdfExporter().render(doc, ExportOptions())
assert not result.warnings
text = _extract_pdf_text(result.content)
positions = [text.index(part) for part in expected]
assert positions == sorted(positions)
@pytest.mark.parametrize("container", ["quote", "callout", "list", "list_callout", "callout_list"])
def test_container_tables_export_as_tables(container):
from app.export.exporters.docx import DocxExporter
from app.export.exporters.pdf import PdfExporter
from docx import Document as WordDocument
from io import BytesIO
table = "| HeaderA | HeaderB |\n|---|---|\n| CellA | CellB |"
def quote(text):
return "\n".join("> " + line for line in text.splitlines())
def item(text):
return "- Parent\n\n" + "\n".join(" " + line for line in text.splitlines())
callout = "[!NOTE] Title\n\n"
md = {
"quote": quote(table),
"callout": quote(callout + table),
"list": item(table),
"list_callout": item(quote(callout + table)),
"callout_list": quote(callout + item(table)),
}[container]
doc = parse_document(md)
html = HtmlExporter().render(doc, ExportOptions())
assert not html.warnings
assert "<table>" in html.content.decode() and "<th" in html.content.decode()
result = DocxExporter().render(doc, ExportOptions())
assert not result.warnings
word = WordDocument(BytesIO(result.content))
assert len(word.tables) == 1
assert [[cell.text for cell in row.cells] for row in word.tables[0].rows] == [
["HeaderA", "HeaderB"], ["CellA", "CellB"]]
exporter = PdfExporter()
tables = []
render_table = exporter._block_table
def capture_table(node, story, warnings):
render_table(node, story, warnings)
tables.append(story[-1])
exporter._block_table = capture_table
result = exporter.render(doc, ExportOptions())
assert not result.warnings and len(tables) == 1
from reportlab.platypus import Table
assert isinstance(tables[0], Table)
text = _extract_pdf_text(result.content)
assert all(value in text for value in ["HeaderA", "HeaderB", "CellA", "CellB"])
@pytest.mark.parametrize("depth", [1, 2, 3])
def test_docx_nested_table_indent_accumulates_once(depth):
from app.export.exporters.docx import DocxExporter
from docx import Document as WordDocument
from docx.oxml.ns import qn
from io import BytesIO
md = "| A | B |\n|---|---|\n| x | y |"
for level in range(depth):
callout = f"[!NOTE] Level{level}\n\n" + md
quote = "\n".join("> " + line for line in callout.splitlines())
md = "- Parent\n\n" + "\n".join(" " + line for line in quote.splitlines())
result = DocxExporter().render(parse_document(md), ExportOptions())
assert not result.warnings
word = WordDocument(BytesIO(result.content))
assert len(word.tables) == 1
indents = word.tables[0]._tbl.tblPr.findall(qn("w:tblInd"))
assert len(indents) == 1
assert indents[0].get(qn("w:type")) == "dxa"
assert int(indents[0].get(qn("w:w"))) == 360 * depth
title = next(p for p in word.paragraphs if "Level0" in p.text)
assert title.paragraph_format.left_indent.twips == 360 * depth
assert [[c.text for c in r.cells] for r in word.tables[0].rows] == [["A", "B"], ["x", "y"]]
+15
View File
@@ -141,3 +141,18 @@ uv run pytest -q
回归覆盖陡峭正负直线、百万斜率、可见中点混合极点、极小纵轴范围、两端均在可见范围内的极点、极点恰好位于中点、常见连续函数及 log/sqrt 定义域边界;验证区间与整条曲线共享求值预算,耗尽后保留断点和 warning,SVG/PDF 曲线坐标不得包含 NaN/Infinity。 回归覆盖陡峭正负直线、百万斜率、可见中点混合极点、极小纵轴范围、两端均在可见范围内的极点、极点恰好位于中点、常见连续函数及 log/sqrt 定义域边界;验证区间与整条曲线共享求值预算,耗尽后保留断点和 warning,SVG/PDF 曲线坐标不得包含 NaN/Infinity。
补充检测:36 组不同系数和极点位置的几何检查通过。一次本机测量中,百万斜率直线和普通倒数曲线约 3 ms,高频 `sin(1000000000*x)` 达到预算并返回 warning,约 45 ms;该数据用于验证有界退出,不作为性能承诺。 补充检测:36 组不同系数和极点位置的几何检查通过。一次本机测量中,百万斜率直线和普通倒数曲线约 3 ms,高频 `sin(1000000000*x)` 达到预算并返回 warning,约 45 ms;该数据用于验证有界退出,不作为性能承诺。
### PR #41:主题与警告框导出(2026-09-07)
HTML 支持 light、dark、sepia、paper-moments、midnight-purple 五套固定导出配色,覆盖正文、代码、表格、链接、引用和函数图像坐标文字。代码块独立设置前景与背景;不加载任意主题 CSS,也不复刻编辑器装饰。未知主题回退 light 并返回 warning。
PDF、DOCX 保持浅色打印样式;选择其他主题时返回明确 warning,需要主题配色请导出 HTML。警告框保留类型、富文本标题、正文与嵌套块;HTML 使用 details 支持默认展开和折叠,PDF、DOCX 始终输出完整内容,以彩色标题区区分类型。
验证:test_export.py 覆盖五套配色、未知主题安全回退、所有内置警告框类型与别名、折叠状态、嵌套正文和打印回退提示。浏览器检查深色导出的代码、表格及警告框对比度。
列表内的警告框和其他已支持块级节点使用块级渲染,PDF 保留列表缩进及可用宽度,DOCX 累加段落和表格缩进。回归测试检查有序、无序、任务列表中的警告框标题、正文、多层嵌套及后续段落,直接验证 PDF 文本和 DOCX 段落的内容顺序。
警告框识别与工作区一致:标记与标题之间可不留空格,类型允许数字、下划线和连字符;自定义类型回退 note 配色并保留自定义标题,省略标题时使用类型名称首字母大写。
警告框、普通引用、列表及交叉嵌套中的 Markdown 表格均启用容器内部解析,HTML 输出 table、PDF 输出 Table、DOCX 输出原生表格。测试逐一检查单元格内容和产物结构。每个 HTML 警告框独立初始化颜色变量,避免 NOTE 等类型继承外层 WARNING 的颜色;已在五套内置导出主题中检查嵌套配色及表格显示。