feat(export): 多格式后台导出、主题与警告框渲染 #43
@@ -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:
|
||||
|
||||
@@ -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"<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("<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:
|
||||
parts.append(f'<h1 class="title">{html.escape(title)}</h1>')
|
||||
if options.include_metadata:
|
||||
@@ -145,6 +165,17 @@ class HtmlExporter:
|
||||
def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
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:
|
||||
return f"<blockquote>{self._render_children(node.children, warnings)}</blockquote>"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'}
|
||||
@@ -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="</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"]]
|
||||
|
||||
@@ -141,3 +141,18 @@ uv run pytest -q
|
||||
回归覆盖陡峭正负直线、百万斜率、可见中点混合极点、极小纵轴范围、两端均在可见范围内的极点、极点恰好位于中点、常见连续函数及 log/sqrt 定义域边界;验证区间与整条曲线共享求值预算,耗尽后保留断点和 warning,SVG/PDF 曲线坐标不得包含 NaN/Infinity。
|
||||
|
||||
补充检测: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 的颜色;已在五套内置导出主题中检查嵌套配色及表格显示。
|
||||
|
||||
Reference in New Issue
Block a user