Complete phase two benchmarks, plot previews and static export workflow

This commit is contained in:
2026-09-07 02:54:52 +08:00
parent 95095197df
commit 89df10bc4e
59 changed files with 2535 additions and 83 deletions
+31 -3
View File
@@ -1,7 +1,7 @@
"""DocxExporterDocument AST → DOCXpython-docx)。
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
function_plot 与 mermaid 保留源码占位并记 warning。中文字体通过 Normal 样式挂载
标题段落、列表、表格等使用原生 Word 元素;函数图、已准备的 Mermaid、
受支持的公式与 Vault 图片使用静态图片,无法表示的资源保留源码并记 warning。中文字体通过 Normal 样式挂载
w:eastAsia=宋体,保证 Word 打开时中文正常显示;bold/italic 由 Word 原生渲染。
"""
@@ -50,6 +50,8 @@ class DocxExporter:
def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
from app.export.exporters._common import FunctionPlotBudget
self._plot_budget = FunctionPlotBudget()
self._doc = DocxDocument()
self._configure_normal_style()
self._configure_page(options)
@@ -109,6 +111,13 @@ class DocxExporter:
self._render_block(child, warnings)
def _render_block(self, node: DocumentNode, warnings: list[str]) -> None:
if node.attributes.get('static_png'):
from PIL import Image
png = node.attributes['static_png']
with Image.open(BytesIO(png)) as image:
width = min(5.8, image.width / (180 if node.type == 'math_block' else 96))
self._doc.add_picture(BytesIO(png), width=Inches(width))
return
handler = getattr(self, f"_block_{node.type}", None)
if handler is not None:
handler(node, warnings)
@@ -270,7 +279,20 @@ class DocxExporter:
self._block_code_block(node, warnings)
def _block_function_plot(self, node: DocumentNode, warnings: list[str]) -> None:
warnings.append(PLOT_PLACEHOLDER_WARNING)
from app.plot.parser import parse_source
from app.export.assets import plot_png
over = self._plot_budget.check_count()
if not over:
parsed = parse_source(node.text)
warnings.extend(d.message for d in parsed.diagnostics)
if parsed.plot:
over = self._plot_budget.check_nodes(parsed.plot.node_count)
if not over:
png, messages = plot_png(parsed.plot)
warnings.extend(messages)
self._doc.add_picture(BytesIO(png), width=Inches(5.8))
return
warnings.append(over or '函数图像无法绘制,已保留源码')
self._block_code_block(node, warnings)
def _block_math_block(self, node: DocumentNode, warnings: list[str]) -> None:
@@ -303,6 +325,12 @@ class DocxExporter:
bold: bool = False,
italic: bool = False,
) -> None:
if node.attributes.get('static_png'):
from PIL import Image
with Image.open(BytesIO(node.attributes['static_png'])) as image:
width = min(5.8, image.width / (180 if node.type.startswith('math') else 96))
paragraph.add_run().add_picture(BytesIO(node.attributes['static_png']), width=Inches(width))
return
t = node.type
if t == "text":
self._add_run(paragraph, node.text, bold=bold, italic=italic)
+12
View File
@@ -151,6 +151,16 @@ class HtmlExporter:
return "".join(self._render_node(child, warnings) for child in children)
def _render_node(self, node: DocumentNode, warnings: list[str]) -> str:
if node.attributes.get('static_png'):
import base64
data = base64.b64encode(node.attributes['static_png']).decode()
from PIL import Image
from io import BytesIO
width = ''
if node.type.startswith('math'):
with Image.open(BytesIO(node.attributes['static_png'])) as image:
width = f'width:{image.width*96/180:.1f}px;vertical-align:middle;'
return f'<img alt="{html.escape(node.text or node.type)}" src="data:image/png;base64,{data}" style="{width}max-width:100%">'
handler = getattr(self, f"_render_{node.type}", None)
if handler is not None:
return handler(node, warnings)
@@ -257,6 +267,8 @@ class HtmlExporter:
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc}")
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
warnings.extend(rendered.warnings)
from app.plot.render import theme_svg
rendered.content = theme_svg(rendered.content, self._options.theme_id)
return f'<figure class="function-plot">{rendered.content}</figure>'
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
+19 -2
View File
@@ -42,8 +42,7 @@ from app.export.exporters._common import (
from app.plot.render_reportlab import render_drawing
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
_FONT = "STSong-Light"
pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
from app.export.fonts import FONT as _FONT
_MIME = "application/pdf"
@@ -122,6 +121,7 @@ class PdfExporter:
self._styles = _make_styles()
warnings: list[str] = []
print_theme_warning(options, warnings, "PDF")
if _FONT == "STSong-Light": warnings.append("PDF 使用 CID 字体,阅读器需提供中文字体;可配置 APP_EXPORT_FONT 嵌入 TrueType 字体")
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
self._options = options
@@ -169,6 +169,14 @@ class PdfExporter:
self._render_block(child, story, warnings)
def _render_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
if node.attributes.get('static_png'):
from reportlab.platypus import Image
image = Image(BytesIO(node.attributes['static_png']))
scale = min(1, self._plot_width / image.imageWidth, 600 / image.imageHeight)
image.drawWidth = image.imageWidth * scale
image.drawHeight = image.imageHeight * scale
story.append(image)
return
handler = getattr(self, f"_block_{node.type}", None)
if handler is not None:
handler(node, story, warnings)
@@ -362,6 +370,15 @@ class PdfExporter:
return "".join(self._render_inline_node(child, warnings) for child in children)
def _render_inline_node(self, node: DocumentNode, warnings: list[str]) -> str:
if node.attributes.get('static_png'):
import base64
from PIL import Image as PILImage
raw = node.attributes['static_png']
with PILImage.open(BytesIO(raw)) as image:
scale = min(.4 if node.type.startswith('math') else 1, 350/image.width, 160/image.height)
width, height = image.width*scale, image.height*scale
data = base64.b64encode(raw).decode()
return f'<img src="data:image/png;base64,{data}" width="{width}" height="{height}" valign="middle"/>'
t = node.type
if t == "text":
return _html.escape(node.text)