feat(phase2): 完成第二阶段评测、函数图与多格式导出 #44
@@ -1426,7 +1426,18 @@ class ExportSource(Contract):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ExportPalette(Contract):
|
||||||
|
page: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||||
|
surface: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||||
|
text: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||||
|
muted: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||||
|
code: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||||
|
border: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||||
|
accent: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
|
||||||
|
|
||||||
|
|
||||||
class ExportOptions(Contract):
|
class ExportOptions(Contract):
|
||||||
|
palette: ExportPalette | None = None
|
||||||
theme_id: str = "light"
|
theme_id: str = "light"
|
||||||
include_title: bool = True
|
include_title: bool = True
|
||||||
include_metadata: bool = False
|
include_metadata: bool = False
|
||||||
@@ -1437,16 +1448,23 @@ class ExportOptions(Contract):
|
|||||||
class ExportAsset(Contract):
|
class ExportAsset(Contract):
|
||||||
kind: Literal['mermaid', 'math_block', 'math_inline', 'image']
|
kind: Literal['mermaid', 'math_block', 'math_inline', 'image']
|
||||||
source_hash: str = Field(pattern=r'^[a-f0-9]{64}$')
|
source_hash: str = Field(pattern=r'^[a-f0-9]{64}$')
|
||||||
png_base64: str = Field(max_length=2800000)
|
png_base64: str
|
||||||
|
|
||||||
|
|
||||||
class ExportRequest(Contract):
|
class ExportRequest(Contract):
|
||||||
assets: list[ExportAsset] = Field(default_factory=list, max_length=64)
|
assets: list[ExportAsset] = Field(default_factory=list)
|
||||||
title: str = Field(default="", max_length=200)
|
title: str = Field(default="", max_length=200)
|
||||||
source: ExportSource
|
source: ExportSource
|
||||||
format: ExportFormat
|
format: ExportFormat
|
||||||
options: ExportOptions = Field(default_factory=ExportOptions)
|
options: ExportOptions = Field(default_factory=ExportOptions)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _asset_limits(self) -> "ExportRequest":
|
||||||
|
if self.format != ExportFormat.pdf:
|
||||||
|
if len(self.assets) > 64 or any(len(asset.png_base64) > 2800000 for asset in self.assets):
|
||||||
|
raise ValueError("export asset count or size limit exceeded")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ExportProgress(Contract):
|
class ExportProgress(Contract):
|
||||||
phase: str
|
phase: str
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Bounded raster-only resource boundary. No URLs, XML or filesystem paths accepted."""
|
"""Raster-only resources; PDF bypasses export quotas but retains path/format validation."""
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import threading
|
import threading
|
||||||
@@ -8,12 +8,14 @@ from app.errors import ApiError
|
|||||||
|
|
||||||
_math_lock = threading.Lock()
|
_math_lock = threading.Lock()
|
||||||
|
|
||||||
def enrich_document(document, file_path=None):
|
def enrich_document(document, file_path=None, unlimited=False, options=None):
|
||||||
"""Embed local vault images and bounded MathText. Unsupported TeX stays explicit."""
|
"""Embed Vault images and MathText, with format-specific quotas and palette."""
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from urllib.parse import unquote, urlsplit
|
from urllib.parse import unquote, urlsplit
|
||||||
vault = get_settings().vault_path.resolve()
|
vault = get_settings().vault_path.resolve()
|
||||||
base = (vault / (file_path or '')).parent if file_path else vault
|
base = (vault / (file_path or '')).parent if file_path else vault
|
||||||
|
from app.export.themes import pdf_palette
|
||||||
|
palette = pdf_palette(options, []) if unlimited and options else None
|
||||||
warnings = []
|
warnings = []
|
||||||
count = total = pixels = 0
|
count = total = pixels = 0
|
||||||
def visit(node):
|
def visit(node):
|
||||||
@@ -21,14 +23,14 @@ def enrich_document(document, file_path=None):
|
|||||||
if node.type in {'image','math_block','math_inline'} or node.attributes.get('static_png'):
|
if node.type in {'image','math_block','math_inline'} or node.attributes.get('static_png'):
|
||||||
count += 1
|
count += 1
|
||||||
try:
|
try:
|
||||||
if count > 64: raise ValueError('resource count')
|
if not unlimited and count > 64: raise ValueError('resource count')
|
||||||
if node.attributes.get('static_png'):
|
if node.attributes.get('static_png'):
|
||||||
raw = node.attributes['static_png']
|
raw = node.attributes['static_png']
|
||||||
elif node.type == 'image':
|
elif node.type == 'image':
|
||||||
src = str(node.attributes.get('src',''))
|
src = str(node.attributes.get('src',''))
|
||||||
if urlsplit(src).scheme or src.startswith('//'): raise ValueError('remote image')
|
if urlsplit(src).scheme or src.startswith('//'): raise ValueError('remote image')
|
||||||
path = (base / unquote(src)).resolve()
|
path = (base / unquote(src)).resolve()
|
||||||
if not path.is_relative_to(vault) or path.suffix.lower() not in {'.png','.jpg','.jpeg','.webp'} or path.stat().st_size > 2_000_000:
|
if not path.is_relative_to(vault) or path.suffix.lower() not in {'.png','.jpg','.jpeg','.webp'} or (not unlimited and path.stat().st_size > 2_000_000):
|
||||||
raise ValueError('image path or budget')
|
raise ValueError('image path or budget')
|
||||||
raw = path.read_bytes()
|
raw = path.read_bytes()
|
||||||
else:
|
else:
|
||||||
@@ -36,23 +38,24 @@ def enrich_document(document, file_path=None):
|
|||||||
depth = 0
|
depth = 0
|
||||||
for char in source:
|
for char in source:
|
||||||
depth += (char == '{') - (char == '}')
|
depth += (char == '{') - (char == '}')
|
||||||
if depth > 20: raise ValueError('math depth')
|
if not unlimited and depth > 20: raise ValueError('math depth')
|
||||||
if len(source) > 512 or depth != 0: raise ValueError('math budget')
|
if (not unlimited and len(source) > 512) or depth != 0: raise ValueError('math budget')
|
||||||
from matplotlib.mathtext import math_to_image
|
from matplotlib.mathtext import math_to_image
|
||||||
with _math_lock:
|
from matplotlib import rc_context
|
||||||
|
with _math_lock, rc_context({'savefig.transparent': bool(palette)}):
|
||||||
out = BytesIO()
|
out = BytesIO()
|
||||||
math_to_image('$'+source+'$', out, dpi=180, format='png', color='black')
|
math_to_image('$'+source+'$', out, dpi=180, format='png', color=palette['text'] if palette else 'black')
|
||||||
raw = out.getvalue()
|
raw = out.getvalue()
|
||||||
with Image.open(BytesIO(raw)) as image:
|
with Image.open(BytesIO(raw)) as image:
|
||||||
pixels += image.width * image.height
|
pixels += image.width * image.height
|
||||||
if pixels > 16_000_000: raise ValueError('document pixels')
|
if not unlimited and pixels > 16_000_000: raise ValueError('document pixels')
|
||||||
if image.width * image.height > 4_000_000: raise ValueError('image dimensions')
|
if not unlimited and image.width * image.height > 4_000_000: raise ValueError('image dimensions')
|
||||||
out = BytesIO()
|
out = BytesIO()
|
||||||
# Flatten alpha on white for portable print/Word output.
|
# Composite transparency over the PDF theme or the print/Word white surface.
|
||||||
rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,'white')
|
rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,palette['surface'] if palette else 'white')
|
||||||
background.alpha_composite(rgba); background.convert('RGB').save(out,'PNG')
|
background.alpha_composite(rgba); background.convert('RGB').save(out,'PNG')
|
||||||
png=out.getvalue();total += len(png)
|
png=out.getvalue();total += len(png)
|
||||||
if total > 8_000_000: raise ValueError('resource bytes')
|
if not unlimited and total > 8_000_000: raise ValueError('resource bytes')
|
||||||
node.attributes['static_png']=png
|
node.attributes['static_png']=png
|
||||||
except Exception:
|
except Exception:
|
||||||
node.attributes.pop('static_png', None)
|
node.attributes.pop('static_png', None)
|
||||||
@@ -66,26 +69,26 @@ def enrich_document(document, file_path=None):
|
|||||||
def source_hash(source):
|
def source_hash(source):
|
||||||
return hashlib.sha256(source.strip().encode()).hexdigest()
|
return hashlib.sha256(source.strip().encode()).hexdigest()
|
||||||
|
|
||||||
def validate_assets(assets):
|
def validate_assets(assets, unlimited=False):
|
||||||
result = {}
|
result = {}
|
||||||
total = pixels = 0
|
total = pixels = 0
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
try:
|
try:
|
||||||
raw = base64.b64decode(asset.png_base64, validate=True)
|
raw = base64.b64decode(asset.png_base64, validate=True)
|
||||||
total += len(raw)
|
total += len(raw)
|
||||||
if total > 8 * 1024 * 1024:
|
if not unlimited and total > 8 * 1024 * 1024:
|
||||||
raise ValueError('asset budget')
|
raise ValueError('asset budget')
|
||||||
with Image.open(BytesIO(raw)) as image:
|
with Image.open(BytesIO(raw)) as image:
|
||||||
pixels += image.width * image.height
|
pixels += image.width * image.height
|
||||||
if pixels > 16_000_000: raise ValueError('document pixel budget')
|
if not unlimited and pixels > 16_000_000: raise ValueError('document pixel budget')
|
||||||
if image.format != 'PNG' or image.width * image.height > 4_000_000:
|
if image.format != 'PNG' or (not unlimited and image.width * image.height > 4_000_000):
|
||||||
raise ValueError('image budget')
|
raise ValueError('image budget')
|
||||||
image.load()
|
image.load()
|
||||||
out = BytesIO()
|
out = BytesIO()
|
||||||
rgba = image.convert('RGBA')
|
rgba = image.convert('RGBA')
|
||||||
background = Image.new('RGBA', rgba.size, 'white')
|
background = Image.new('RGBA', rgba.size, 'white')
|
||||||
background.alpha_composite(rgba)
|
background.alpha_composite(rgba)
|
||||||
background.convert('RGB').save(out, 'PNG')
|
(rgba if unlimited else background.convert('RGB')).save(out, 'PNG')
|
||||||
key = (asset.kind, asset.source_hash)
|
key = (asset.kind, asset.source_hash)
|
||||||
if key in result:
|
if key in result:
|
||||||
raise ValueError('duplicate asset')
|
raise ValueError('duplicate asset')
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""导出器共享工具:URL 协议校验、函数图像预算与占位 warning 文案。
|
"""导出器共享工具:URL 协议校验、函数图像预算与占位 warning 文案。
|
||||||
|
|
||||||
html / pdf / docx 三个导出器共用同一套安全规则与函数图像资源预算,避免各写一份
|
导出器共享 URL 规则;HTML / DOCX 使用文档资源预算,PDF 不使用这些预算。
|
||||||
导致行为漂移。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,7 +26,7 @@ MAX_TOTAL_PLOT_NODES = 8000
|
|||||||
class FunctionPlotBudget:
|
class FunctionPlotBudget:
|
||||||
"""函数图像文档级资源预算:数量上限 + 累计 AST 节点上限。
|
"""函数图像文档级资源预算:数量上限 + 累计 AST 节点上限。
|
||||||
|
|
||||||
HTML 与 PDF 导出器在渲染每个 function-plot 图块前先问预算,超限即回退源码占位,
|
HTML 与 DOCX 导出器在渲染每个 function-plot 图块前先问预算,超限即回退源码占位,
|
||||||
不解析不采样,避免多图块组合复杂度耗尽内存/CPU。
|
不解析不采样,避免多图块组合复杂度耗尽内存/CPU。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
|||||||
from reportlab.platypus import (
|
from reportlab.platypus import (
|
||||||
Paragraph,
|
Paragraph,
|
||||||
Indenter,
|
Indenter,
|
||||||
Preformatted,
|
XPreformatted,
|
||||||
SimpleDocTemplate,
|
SimpleDocTemplate,
|
||||||
Spacer,
|
Spacer,
|
||||||
Table,
|
Table,
|
||||||
@@ -29,12 +29,11 @@ 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.themes import CALLOUTS, pdf_palette
|
||||||
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,
|
||||||
RAW_HTML_WARNING,
|
RAW_HTML_WARNING,
|
||||||
FunctionPlotBudget,
|
|
||||||
format_meta_value,
|
format_meta_value,
|
||||||
format_plot_diagnostic,
|
format_plot_diagnostic,
|
||||||
safe_url,
|
safe_url,
|
||||||
@@ -54,10 +53,11 @@ _HEADING_SIZES = {1: 20, 2: 16, 3: 14, 4: 12, 5: 11, 6: 10.5}
|
|||||||
_QUOTE_COLOR = "#57606a"
|
_QUOTE_COLOR = "#57606a"
|
||||||
|
|
||||||
|
|
||||||
def _make_styles() -> dict[str, ParagraphStyle]:
|
def _make_styles(palette) -> dict[str, ParagraphStyle]:
|
||||||
body = ParagraphStyle(
|
body = ParagraphStyle(
|
||||||
"pdf-body",
|
"pdf-body",
|
||||||
fontName=_FONT,
|
fontName=_FONT,
|
||||||
|
textColor=palette["text"],
|
||||||
fontSize=10.5,
|
fontSize=10.5,
|
||||||
leading=16,
|
leading=16,
|
||||||
spaceAfter=6,
|
spaceAfter=6,
|
||||||
@@ -67,7 +67,7 @@ def _make_styles() -> dict[str, ParagraphStyle]:
|
|||||||
"pdf-quote",
|
"pdf-quote",
|
||||||
parent=body,
|
parent=body,
|
||||||
leftIndent=14,
|
leftIndent=14,
|
||||||
textColor="#57606a",
|
textColor=palette["muted"],
|
||||||
spaceBefore=4,
|
spaceBefore=4,
|
||||||
spaceAfter=6,
|
spaceAfter=6,
|
||||||
)
|
)
|
||||||
@@ -78,8 +78,8 @@ def _make_styles() -> dict[str, ParagraphStyle]:
|
|||||||
leading=12,
|
leading=12,
|
||||||
leftIndent=6,
|
leftIndent=6,
|
||||||
rightIndent=6,
|
rightIndent=6,
|
||||||
backColor="#f6f8fa",
|
backColor=palette["code"],
|
||||||
borderColor="#d0d7de",
|
borderColor=palette["border"],
|
||||||
borderWidth=0.5,
|
borderWidth=0.5,
|
||||||
borderPadding=6,
|
borderPadding=6,
|
||||||
spaceBefore=4,
|
spaceBefore=4,
|
||||||
@@ -88,9 +88,9 @@ def _make_styles() -> dict[str, ParagraphStyle]:
|
|||||||
math = ParagraphStyle("pdf-math", parent=body, alignment=TA_CENTER, spaceBefore=6)
|
math = ParagraphStyle("pdf-math", parent=body, alignment=TA_CENTER, spaceBefore=6)
|
||||||
cell = ParagraphStyle("pdf-cell", parent=body, fontSize=10, leading=14, spaceAfter=0)
|
cell = ParagraphStyle("pdf-cell", parent=body, fontSize=10, leading=14, spaceAfter=0)
|
||||||
cell_head = ParagraphStyle(
|
cell_head = ParagraphStyle(
|
||||||
"pdf-cell-head", parent=cell, textColor="#1f2328", fontSize=10
|
"pdf-cell-head", parent=cell, textColor=palette["text"], fontSize=10
|
||||||
)
|
)
|
||||||
meta = ParagraphStyle("pdf-meta", parent=body, fontSize=8.5, leading=13, textColor="#57606a")
|
meta = ParagraphStyle("pdf-meta", parent=body, fontSize=8.5, leading=13, textColor=palette["muted"])
|
||||||
styles: dict[str, ParagraphStyle] = {
|
styles: dict[str, ParagraphStyle] = {
|
||||||
"body": body,
|
"body": body,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -109,6 +109,7 @@ def _make_styles() -> dict[str, ParagraphStyle]:
|
|||||||
leading=size * 1.4,
|
leading=size * 1.4,
|
||||||
spaceBefore=14 if level <= 2 else 10,
|
spaceBefore=14 if level <= 2 else 10,
|
||||||
spaceAfter=6,
|
spaceAfter=6,
|
||||||
|
keepWithNext=True,
|
||||||
)
|
)
|
||||||
return styles
|
return styles
|
||||||
|
|
||||||
@@ -118,17 +119,17 @@ class PdfExporter:
|
|||||||
|
|
||||||
def render(self, document: Document, options: ExportOptions) -> ExportResult:
|
def render(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||||
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
||||||
self._styles = _make_styles()
|
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
print_theme_warning(options, warnings, "PDF")
|
self._palette = pdf_palette(options, warnings)
|
||||||
|
self._styles = _make_styles(self._palette)
|
||||||
if _FONT == "STSong-Light": warnings.append("PDF 使用 CID 字体,阅读器需提供中文字体;可配置 APP_EXPORT_FONT 嵌入 TrueType 字体")
|
if _FONT == "STSong-Light": warnings.append("PDF 使用 CID 字体,阅读器需提供中文字体;可配置 APP_EXPORT_FONT 嵌入 TrueType 字体")
|
||||||
|
|
||||||
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
|
||||||
self._plot_budget = FunctionPlotBudget()
|
|
||||||
self._plot_renderer = FunctionPlotStaticRenderer()
|
self._plot_renderer = FunctionPlotStaticRenderer()
|
||||||
# 内容区宽度(左右各 20mm 边距),供函数图像缩放适配页面
|
# 内容区宽度(左右各 20mm 边距),供函数图像缩放适配页面
|
||||||
self._plot_width = page[0] - 40 * mm
|
self._plot_width = page[0] - 40 * mm - 12
|
||||||
|
self._plot_height = page[1] - 36 * mm - 12
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
doc = SimpleDocTemplate(
|
doc = SimpleDocTemplate(
|
||||||
buf,
|
buf,
|
||||||
@@ -144,7 +145,14 @@ class PdfExporter:
|
|||||||
self._render_header(document, options, story)
|
self._render_header(document, options, story)
|
||||||
self._render_children(document.children, story, warnings)
|
self._render_children(document.children, story, warnings)
|
||||||
|
|
||||||
doc.build(story)
|
def paint_page(canvas, template):
|
||||||
|
canvas.saveState()
|
||||||
|
canvas.setFillColor(self._palette['page'])
|
||||||
|
canvas.rect(0, 0, page[0], page[1], fill=1, stroke=0)
|
||||||
|
canvas.setFillColor(self._palette['surface'])
|
||||||
|
canvas.roundRect(12*mm, 10*mm, page[0]-24*mm, page[1]-20*mm, 5*mm, fill=1, stroke=0)
|
||||||
|
canvas.restoreState()
|
||||||
|
doc.build(story, onFirstPage=paint_page, onLaterPages=paint_page)
|
||||||
return ExportResult(content=buf.getvalue(), mime_type=_MIME, warnings=warnings)
|
return ExportResult(content=buf.getvalue(), mime_type=_MIME, warnings=warnings)
|
||||||
|
|
||||||
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
|
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||||
@@ -172,7 +180,7 @@ class PdfExporter:
|
|||||||
if node.attributes.get('static_png'):
|
if node.attributes.get('static_png'):
|
||||||
from reportlab.platypus import Image
|
from reportlab.platypus import Image
|
||||||
image = Image(BytesIO(node.attributes['static_png']))
|
image = Image(BytesIO(node.attributes['static_png']))
|
||||||
scale = min(1, self._plot_width / image.imageWidth, 600 / image.imageHeight)
|
scale = min(1, self._plot_width / image.imageWidth, self._plot_height / image.imageHeight)
|
||||||
image.drawWidth = image.imageWidth * scale
|
image.drawWidth = image.imageWidth * scale
|
||||||
image.drawHeight = image.imageHeight * scale
|
image.drawHeight = image.imageHeight * scale
|
||||||
story.append(image)
|
story.append(image)
|
||||||
@@ -194,9 +202,13 @@ class PdfExporter:
|
|||||||
def _block_callout(self, node, story, warnings):
|
def _block_callout(self, node, story, warnings):
|
||||||
kind = node.attributes['kind']
|
kind = node.attributes['kind']
|
||||||
icon, color = CALLOUTS[kind]
|
icon, color = CALLOUTS[kind]
|
||||||
|
from reportlab.lib.colors import HexColor
|
||||||
|
background = HexColor(self._palette['code'])
|
||||||
|
if .2126*background.red + .7152*background.green + .0722*background.blue < .5:
|
||||||
|
color = {'#0969da':'#a5d6ff','#7041a0':'#d2a8ff','#176f41':'#7ee787','#805400':'#f2cc60','#b42318':'#ffa198','#57606a':self._palette['muted']}[color]
|
||||||
title = self._render_inline(node.children[0].children,warnings)
|
title = self._render_inline(node.children[0].children,warnings)
|
||||||
style = ParagraphStyle('callout-'+kind,parent=self._styles['body'],textColor=color,
|
style = ParagraphStyle('callout-'+kind,parent=self._styles['body'],textColor=color,
|
||||||
backColor='#f6f8fa',borderColor=color,borderWidth=1,borderPadding=6,spaceBefore=8,spaceAfter=8)
|
backColor=self._palette['code'],borderColor=color,borderWidth=1,borderPadding=6,spaceBefore=8,spaceAfter=8)
|
||||||
story.append(Paragraph(_html.escape(icon)+' '+title,style))
|
story.append(Paragraph(_html.escape(icon)+' '+title,style))
|
||||||
self._render_children(node.children[1:],story,warnings)
|
self._render_children(node.children[1:],story,warnings)
|
||||||
|
|
||||||
@@ -209,7 +221,7 @@ class PdfExporter:
|
|||||||
Paragraph(self._render_inline(child.children, warnings), self._styles["quote"])
|
Paragraph(self._render_inline(child.children, warnings), self._styles["quote"])
|
||||||
)
|
)
|
||||||
elif child.type == "list":
|
elif child.type == "list":
|
||||||
self._block_list(child, story, warnings, indent=14, color=_QUOTE_COLOR)
|
self._block_list(child, story, warnings, indent=14, color=self._palette['muted'])
|
||||||
else:
|
else:
|
||||||
self._render_block(child, story, warnings)
|
self._render_block(child, story, warnings)
|
||||||
|
|
||||||
@@ -301,7 +313,7 @@ class PdfExporter:
|
|||||||
data.append(cells)
|
data.append(cells)
|
||||||
table = Table(data, repeatRows=head_row_count)
|
table = Table(data, repeatRows=head_row_count)
|
||||||
commands = [
|
commands = [
|
||||||
("GRID", (0, 0), (-1, -1), 0.5, "#d0d7de"),
|
("GRID", (0, 0), (-1, -1), 0.5, self._palette["border"]),
|
||||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||||
@@ -309,53 +321,42 @@ class PdfExporter:
|
|||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||||
]
|
]
|
||||||
if head_row_count:
|
if head_row_count:
|
||||||
commands.append(("BACKGROUND", (0, 0), (-1, head_row_count - 1), "#f6f8fa"))
|
commands.append(("BACKGROUND", (0, 0), (-1, head_row_count - 1), self._palette["code"]))
|
||||||
table.setStyle(TableStyle(commands))
|
table.setStyle(TableStyle(commands))
|
||||||
story.append(table)
|
story.append(table)
|
||||||
|
|
||||||
def _block_code_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
def _block_code_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||||
story.append(Preformatted(node.text, self._styles["code"]))
|
story.append(XPreformatted(_html.escape(node.text), self._styles["code"]))
|
||||||
|
|
||||||
def _block_thematic_break(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
def _block_thematic_break(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||||
story.append(Spacer(1, 4))
|
story.append(Spacer(1, 4))
|
||||||
story.append(HRFlowable(width="100%", color="#d0d7de", thickness=0.5))
|
story.append(HRFlowable(width="100%", color=self._palette["border"], thickness=0.5))
|
||||||
story.append(Spacer(1, 6))
|
story.append(Spacer(1, 6))
|
||||||
|
|
||||||
def _block_mermaid(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
def _block_mermaid(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||||
warnings.append(MERMAID_WARNING)
|
warnings.append(MERMAID_WARNING)
|
||||||
story.append(Preformatted(node.text, self._styles["code"]))
|
story.append(XPreformatted(_html.escape(node.text), self._styles["code"]))
|
||||||
|
|
||||||
def _block_function_plot(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
def _block_function_plot(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||||
# 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源
|
|
||||||
over = self._plot_budget.check_count()
|
|
||||||
if over is not None:
|
|
||||||
warnings.append(over)
|
|
||||||
story.append(Preformatted(node.text, self._styles["code"]))
|
|
||||||
return
|
|
||||||
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
|
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
|
||||||
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
|
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
|
||||||
try:
|
try:
|
||||||
request = StaticRenderRequest(
|
request = StaticRenderRequest(
|
||||||
kind="function_plot", source=node.text, theme=self._options.theme_id
|
kind="function_plot", source=node.text, theme=self._options.theme_id
|
||||||
)
|
)
|
||||||
parsed = self._plot_renderer.parse(request)
|
from app.plot.parser import parse_source
|
||||||
|
parsed = parse_source(request.source, unlimited=True)
|
||||||
for diag in parsed.diagnostics:
|
for diag in parsed.diagnostics:
|
||||||
warnings.append(format_plot_diagnostic(diag))
|
warnings.append(format_plot_diagnostic(diag))
|
||||||
if parsed.plot is None:
|
if parsed.plot is None:
|
||||||
story.append(Preformatted(node.text, self._styles["code"]))
|
story.append(XPreformatted(_html.escape(node.text), self._styles["code"]))
|
||||||
return
|
|
||||||
# 文档级累计复杂度预算:超出后回退占位,不再采样求值
|
|
||||||
over = self._plot_budget.check_nodes(parsed.plot.node_count)
|
|
||||||
if over is not None:
|
|
||||||
warnings.append(over)
|
|
||||||
story.append(Preformatted(node.text, self._styles["code"]))
|
|
||||||
return
|
return
|
||||||
# Drawing 本身即 Flowable,缩放后追加到 story,与 HTML 视觉一致
|
# Drawing 本身即 Flowable,缩放后追加到 story,与 HTML 视觉一致
|
||||||
drawing = render_drawing(parsed.plot, width=self._plot_width)
|
drawing = render_drawing(parsed.plot, width=self._plot_width, palette=self._palette, unlimited=True, max_height=self._plot_height)
|
||||||
story.append(drawing)
|
story.append(drawing)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
|
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
|
||||||
story.append(Preformatted(node.text, self._styles["code"]))
|
story.append(XPreformatted(_html.escape(node.text), self._styles["code"]))
|
||||||
|
|
||||||
def _block_math_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
def _block_math_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||||
story.append(Paragraph(f"$${_html.escape(node.text)}$$", self._styles["math"]))
|
story.append(Paragraph(f"$${_html.escape(node.text)}$$", self._styles["math"]))
|
||||||
@@ -393,7 +394,7 @@ class PdfExporter:
|
|||||||
if safe_href is None:
|
if safe_href is None:
|
||||||
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
|
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
|
||||||
return inner
|
return inner
|
||||||
return f'<a href="{_html.escape(safe_href)}">{inner}</a>'
|
return f'<a href="{_html.escape(safe_href)}" color="{self._palette["accent"]}">{inner}</a>'
|
||||||
if t == "image":
|
if t == "image":
|
||||||
src = str(node.attributes.get("src") or "")
|
src = str(node.attributes.get("src") or "")
|
||||||
alt = str(node.attributes.get("alt") or "")
|
alt = str(node.attributes.get("alt") or "")
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ def _evict_terminal() -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
|
async def _resolve_source(source: ExportSource, unlimited: bool = False) -> tuple[str, str, dict | None]:
|
||||||
"""把导出源解析为 (markdown, title, metadata);metadata 仅 note 源提供。"""
|
"""把导出源解析为 (markdown, title, metadata);metadata 仅 note 源提供。"""
|
||||||
if source.type == ExportSourceType.note:
|
if source.type == ExportSourceType.note:
|
||||||
note = await note_service.get_note(source.note_id)
|
note = await note_service.get_note(source.note_id)
|
||||||
@@ -157,7 +157,7 @@ async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
|
|||||||
"note not found",
|
"note not found",
|
||||||
{"note_id": source.note_id},
|
{"note_id": source.note_id},
|
||||||
)
|
)
|
||||||
if len(note.markdown) > MAX_MARKDOWN_CHARS:
|
if not unlimited and len(note.markdown) > MAX_MARKDOWN_CHARS:
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
400,
|
400,
|
||||||
"EXPORT_OPTIONS_INVALID",
|
"EXPORT_OPTIONS_INVALID",
|
||||||
@@ -175,7 +175,7 @@ async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
|
|||||||
markdown = source.markdown or ""
|
markdown = source.markdown or ""
|
||||||
if not markdown.strip():
|
if not markdown.strip():
|
||||||
raise ApiError(400, "EXPORT_OPTIONS_INVALID", "markdown source must not be empty")
|
raise ApiError(400, "EXPORT_OPTIONS_INVALID", "markdown source must not be empty")
|
||||||
if len(markdown) > MAX_MARKDOWN_CHARS:
|
if not unlimited and len(markdown) > MAX_MARKDOWN_CHARS:
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
400,
|
400,
|
||||||
"EXPORT_OPTIONS_INVALID",
|
"EXPORT_OPTIONS_INVALID",
|
||||||
@@ -187,10 +187,10 @@ async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
|
|||||||
|
|
||||||
async def create_export(request: ExportRequest) -> ExportJob:
|
async def create_export(request: ExportRequest) -> ExportJob:
|
||||||
"""创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。"""
|
"""创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。"""
|
||||||
markdown, title, metadata = await _resolve_source(request.source)
|
markdown, title, metadata = await _resolve_source(request.source, request.format == ExportFormat.pdf)
|
||||||
title = request.title or title
|
title = request.title or title
|
||||||
from app.export.assets import validate_assets
|
from app.export.assets import validate_assets
|
||||||
assets = await asyncio.to_thread(validate_assets, request.assets)
|
assets = await asyncio.to_thread(validate_assets, request.assets, request.format == ExportFormat.pdf)
|
||||||
|
|
||||||
if not _evict_terminal():
|
if not _evict_terminal():
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
@@ -283,12 +283,12 @@ async def _execute(
|
|||||||
document.attributes["metadata"] = metadata
|
document.attributes["metadata"] = metadata
|
||||||
|
|
||||||
from app.export.assets import enrich_document
|
from app.export.assets import enrich_document
|
||||||
resource_warnings = await asyncio.to_thread(enrich_document, document, (metadata or {}).get('file_path'))
|
resource_warnings = await asyncio.to_thread(enrich_document, document, (metadata or {}).get('file_path'), format == ExportFormat.pdf, options)
|
||||||
result = await asyncio.to_thread(_render_document, document, options, format)
|
result = await asyncio.to_thread(_render_document, document, options, format)
|
||||||
result.warnings[:0] = resource_warnings
|
result.warnings[:0] = resource_warnings
|
||||||
if cancel_event.is_set():
|
if cancel_event.is_set():
|
||||||
raise ExportCancelled()
|
raise ExportCancelled()
|
||||||
if len(result.content) > MAX_EXPORT_BYTES:
|
if format != ExportFormat.pdf and len(result.content) > MAX_EXPORT_BYTES:
|
||||||
raise ExportTooLarge()
|
raise ExportTooLarge()
|
||||||
|
|
||||||
ext = _extension_for(format)
|
ext = _extension_for(format)
|
||||||
|
|||||||
@@ -32,3 +32,13 @@ ALIASES = {'summary':'abstract','tldr':'abstract','hint':'tip',
|
|||||||
'check':'success','done':'success','help':'question','faq':'question',
|
'check':'success','done':'success','help':'question','faq':'question',
|
||||||
'caution':'warning','attention':'warning','fail':'failure','missing':'failure',
|
'caution':'warning','attention':'warning','fail':'failure','missing':'failure',
|
||||||
'error':'danger','cite':'quote'}
|
'error':'danger','cite':'quote'}
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_palette(options, warnings):
|
||||||
|
if options.palette is not None:
|
||||||
|
return options.palette.model_dump()
|
||||||
|
theme_id = options.theme_id
|
||||||
|
if theme_id not in PALETTES:
|
||||||
|
warnings.append(f'PDF 不支持主题 {theme_id},已使用 light 导出配色')
|
||||||
|
theme_id = 'light'
|
||||||
|
return dict(zip(('page','surface','text','muted','code','border','accent'), PALETTES[theme_id]))
|
||||||
|
|||||||
+12
-12
@@ -143,7 +143,7 @@ def _preprocess(expr: str) -> str:
|
|||||||
return _insert_implicit_multiplication(expr.replace("^", "**"))
|
return _insert_implicit_multiplication(expr.replace("^", "**"))
|
||||||
|
|
||||||
|
|
||||||
def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None) -> None:
|
def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None, unlimited: bool = False) -> None:
|
||||||
"""白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。
|
"""白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。
|
||||||
|
|
||||||
同时限制 AST 深度与节点总数,避免超长/超深表达式在递归校验或求值时触发
|
同时限制 AST 深度与节点总数,避免超长/超深表达式在递归校验或求值时触发
|
||||||
@@ -151,10 +151,10 @@ def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None)
|
|||||||
"""
|
"""
|
||||||
if counter is None:
|
if counter is None:
|
||||||
counter = [0]
|
counter = [0]
|
||||||
if depth > _MAX_AST_DEPTH:
|
if not unlimited and depth > _MAX_AST_DEPTH:
|
||||||
_unsafe(f"表达式嵌套过深(超过 {_MAX_AST_DEPTH} 层)")
|
_unsafe(f"表达式嵌套过深(超过 {_MAX_AST_DEPTH} 层)")
|
||||||
counter[0] += 1
|
counter[0] += 1
|
||||||
if counter[0] > _MAX_AST_NODES:
|
if not unlimited and counter[0] > _MAX_AST_NODES:
|
||||||
_unsafe(f"表达式过于复杂(节点数超过 {_MAX_AST_NODES})")
|
_unsafe(f"表达式过于复杂(节点数超过 {_MAX_AST_NODES})")
|
||||||
if isinstance(node, ast.Constant):
|
if isinstance(node, ast.Constant):
|
||||||
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
||||||
@@ -167,13 +167,13 @@ def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None)
|
|||||||
if isinstance(node, ast.BinOp):
|
if isinstance(node, ast.BinOp):
|
||||||
if not isinstance(node.op, _ALLOWED_BINOPS):
|
if not isinstance(node.op, _ALLOWED_BINOPS):
|
||||||
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
|
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
|
||||||
_check_node(node.left, depth + 1, counter)
|
_check_node(node.left, depth + 1, counter, unlimited)
|
||||||
_check_node(node.right, depth + 1, counter)
|
_check_node(node.right, depth + 1, counter, unlimited)
|
||||||
return
|
return
|
||||||
if isinstance(node, ast.UnaryOp):
|
if isinstance(node, ast.UnaryOp):
|
||||||
if not isinstance(node.op, _ALLOWED_UNARY):
|
if not isinstance(node.op, _ALLOWED_UNARY):
|
||||||
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
|
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
|
||||||
_check_node(node.operand, depth + 1, counter)
|
_check_node(node.operand, depth + 1, counter, unlimited)
|
||||||
return
|
return
|
||||||
if isinstance(node, ast.Call):
|
if isinstance(node, ast.Call):
|
||||||
if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS:
|
if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS:
|
||||||
@@ -184,12 +184,12 @@ def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None)
|
|||||||
if len(node.args) != 1:
|
if len(node.args) != 1:
|
||||||
_unsafe(f"{node.func.id} 需要 1 个参数,实际 {len(node.args)} 个")
|
_unsafe(f"{node.func.id} 需要 1 个参数,实际 {len(node.args)} 个")
|
||||||
for arg in node.args:
|
for arg in node.args:
|
||||||
_check_node(arg, depth + 1, counter)
|
_check_node(arg, depth + 1, counter, unlimited)
|
||||||
return
|
return
|
||||||
_unsafe(f"不支持的语法 {type(node).__name__}")
|
_unsafe(f"不支持的语法 {type(node).__name__}")
|
||||||
|
|
||||||
|
|
||||||
def parse_expression(expr: str) -> ast.Expression:
|
def parse_expression(expr: str, unlimited: bool = False) -> ast.Expression:
|
||||||
"""把数学表达式解析为已通过白名单校验的 AST(可直接交给 evaluate)。"""
|
"""把数学表达式解析为已通过白名单校验的 AST(可直接交给 evaluate)。"""
|
||||||
preprocessed = _preprocess(expr)
|
preprocessed = _preprocess(expr)
|
||||||
try:
|
try:
|
||||||
@@ -211,7 +211,7 @@ def parse_expression(expr: str) -> ast.Expression:
|
|||||||
message="表达式嵌套过深,无法解析",
|
message="表达式嵌套过深,无法解析",
|
||||||
)
|
)
|
||||||
) from exc
|
) from exc
|
||||||
_check_node(tree.body)
|
_check_node(tree.body, unlimited=unlimited)
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ def _parse_directive(line: str) -> tuple[str, str] | None:
|
|||||||
return key, value.strip()
|
return key, value.strip()
|
||||||
|
|
||||||
|
|
||||||
def parse_source(source: str) -> FunctionPlotParseResult:
|
def parse_source(source: str, unlimited: bool = False) -> FunctionPlotParseResult:
|
||||||
"""把 function-plot fenced block 源码解析为 FunctionPlot + 诊断。"""
|
"""把 function-plot fenced block 源码解析为 FunctionPlot + 诊断。"""
|
||||||
diagnostics: list[PlotDiagnostic] = []
|
diagnostics: list[PlotDiagnostic] = []
|
||||||
expressions: list[FunctionPlotExpression] = []
|
expressions: list[FunctionPlotExpression] = []
|
||||||
@@ -371,7 +371,7 @@ def parse_source(source: str) -> FunctionPlotParseResult:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tree = parse_expression(expr_text)
|
tree = parse_expression(expr_text, unlimited=unlimited)
|
||||||
except PlotParseError as exc:
|
except PlotParseError as exc:
|
||||||
exc.diagnostic.line = lineno
|
exc.diagnostic.line = lineno
|
||||||
diagnostics.append(exc.diagnostic)
|
diagnostics.append(exc.diagnostic)
|
||||||
@@ -380,7 +380,7 @@ def parse_source(source: str) -> FunctionPlotParseResult:
|
|||||||
total_nodes += _count_nodes(tree.body)
|
total_nodes += _count_nodes(tree.body)
|
||||||
expressions.append(FunctionPlotExpression(expression=expr_text))
|
expressions.append(FunctionPlotExpression(expression=expr_text))
|
||||||
# 表达式数量超限:整块回退并提前终止,避免对海量表达式做采样求值
|
# 表达式数量超限:整块回退并提前终止,避免对海量表达式做采样求值
|
||||||
if len(expressions) > _MAX_EXPRESSIONS:
|
if not unlimited and len(expressions) > _MAX_EXPRESSIONS:
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
PlotDiagnostic(
|
PlotDiagnostic(
|
||||||
severity="error",
|
severity="error",
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ def _sample_segments(
|
|||||||
return clipped
|
return clipped
|
||||||
|
|
||||||
|
|
||||||
def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
|
def compute_geometry(plot: FunctionPlot, unlimited: bool = False) -> PlotGeometry:
|
||||||
"""解析并计算几何,供 SVG 与 reportlab 后端复用。"""
|
"""解析并计算几何,供 SVG 与 reportlab 后端复用。"""
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
xmin, xmax = plot.domain
|
xmin, xmax = plot.domain
|
||||||
@@ -351,7 +351,7 @@ def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
|
|||||||
fns: list[tuple[object, object]] = []
|
fns: list[tuple[object, object]] = []
|
||||||
for expr in plot.expressions:
|
for expr in plot.expressions:
|
||||||
try:
|
try:
|
||||||
tree = parse_expression(expr.expression)
|
tree = parse_expression(expr.expression, unlimited=unlimited)
|
||||||
except PlotParseError as exc:
|
except PlotParseError as exc:
|
||||||
warnings.append(f"表达式无法渲染,已跳过:{expr.expression}({exc.diagnostic.message})")
|
warnings.append(f"表达式无法渲染,已跳过:{expr.expression}({exc.diagnostic.message})")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ _TICK_FONT_SIZE = 10
|
|||||||
_LABEL_FONT_SIZE = 12
|
_LABEL_FONT_SIZE = 12
|
||||||
|
|
||||||
|
|
||||||
def _build_drawing(geo: PlotGeometry) -> Drawing:
|
def _build_drawing(geo: PlotGeometry, palette=None) -> Drawing:
|
||||||
"""由共享几何构建矢量 Drawing(坐标翻转后仍沿用 SVG 的像素布局)。"""
|
"""由共享几何构建矢量 Drawing(坐标翻转后仍沿用 SVG 的像素布局)。"""
|
||||||
drawing = Drawing(geo.width, geo.height)
|
drawing = Drawing(geo.width, geo.height)
|
||||||
|
grid_color = HexColor(palette['border']) if palette else _GRID_COLOR
|
||||||
|
axis_color = HexColor(palette['muted']) if palette else _AXIS_COLOR
|
||||||
|
label_color = HexColor(palette['text']) if palette else _LABEL_COLOR
|
||||||
|
|
||||||
# SVG y-down → reportlab y-up:翻转像素 y
|
# SVG y-down → reportlab y-up:翻转像素 y
|
||||||
def sx(x: float) -> float:
|
def sx(x: float) -> float:
|
||||||
@@ -41,19 +44,19 @@ def _build_drawing(geo: PlotGeometry) -> Drawing:
|
|||||||
if geo.grid:
|
if geo.grid:
|
||||||
for x in geo.xticks:
|
for x in geo.xticks:
|
||||||
drawing.add(
|
drawing.add(
|
||||||
Line(sx(x), sy(geo.ymin), sx(x), sy(geo.ymax), strokeColor=_GRID_COLOR, strokeWidth=0.5)
|
Line(sx(x), sy(geo.ymin), sx(x), sy(geo.ymax), strokeColor=grid_color, strokeWidth=0.5)
|
||||||
)
|
)
|
||||||
for y in geo.yticks:
|
for y in geo.yticks:
|
||||||
drawing.add(
|
drawing.add(
|
||||||
Line(sx(geo.xmin), sy(y), sx(geo.xmax), sy(y), strokeColor=_GRID_COLOR, strokeWidth=0.5)
|
Line(sx(geo.xmin), sy(y), sx(geo.xmax), sy(y), strokeColor=grid_color, strokeWidth=0.5)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 坐标轴(过原点画在原点,否则贴边,与 SVG 一致)
|
# 坐标轴(过原点画在原点,否则贴边,与 SVG 一致)
|
||||||
drawing.add(
|
drawing.add(
|
||||||
Line(sx(geo.xmin), sy(geo.x_axis_y), sx(geo.xmax), sy(geo.x_axis_y), strokeColor=_AXIS_COLOR, strokeWidth=0.7)
|
Line(sx(geo.xmin), sy(geo.x_axis_y), sx(geo.xmax), sy(geo.x_axis_y), strokeColor=axis_color, strokeWidth=0.7)
|
||||||
)
|
)
|
||||||
drawing.add(
|
drawing.add(
|
||||||
Line(sx(geo.y_axis_x), sy(geo.ymin), sx(geo.y_axis_x), sy(geo.ymax), strokeColor=_AXIS_COLOR, strokeWidth=0.7)
|
Line(sx(geo.y_axis_x), sy(geo.ymin), sx(geo.y_axis_x), sy(geo.ymax), strokeColor=axis_color, strokeWidth=0.7)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 刻度数字(x 轴下方、y 轴左侧)
|
# 刻度数字(x 轴下方、y 轴左侧)
|
||||||
@@ -61,14 +64,14 @@ def _build_drawing(geo: PlotGeometry) -> Drawing:
|
|||||||
drawing.add(
|
drawing.add(
|
||||||
String(
|
String(
|
||||||
sx(x), sy(geo.x_axis_y) - 14, _fmt_num(x),
|
sx(x), sy(geo.x_axis_y) - 14, _fmt_num(x),
|
||||||
fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=_AXIS_COLOR, textAnchor="middle",
|
fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=axis_color, textAnchor="middle",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for y in geo.yticks:
|
for y in geo.yticks:
|
||||||
drawing.add(
|
drawing.add(
|
||||||
String(
|
String(
|
||||||
sx(geo.y_axis_x) - 6, sy(y) - 3, _fmt_num(y),
|
sx(geo.y_axis_x) - 6, sy(y) - 3, _fmt_num(y),
|
||||||
fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=_AXIS_COLOR, textAnchor="end",
|
fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=axis_color, textAnchor="end",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,7 +86,7 @@ def _build_drawing(geo: PlotGeometry) -> Drawing:
|
|||||||
drawing.add(
|
drawing.add(
|
||||||
String(
|
String(
|
||||||
geo.width / 2, 10, geo.xlabel,
|
geo.width / 2, 10, geo.xlabel,
|
||||||
fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=_LABEL_COLOR, textAnchor="middle",
|
fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=label_color, textAnchor="middle",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if geo.ylabel:
|
if geo.ylabel:
|
||||||
@@ -95,7 +98,7 @@ def _build_drawing(geo: PlotGeometry) -> Drawing:
|
|||||||
label.add(
|
label.add(
|
||||||
String(
|
String(
|
||||||
0, 0, geo.ylabel,
|
0, 0, geo.ylabel,
|
||||||
fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=_LABEL_COLOR, textAnchor="middle",
|
fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=label_color, textAnchor="middle",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
label.translate(16, geo.height / 2)
|
label.translate(16, geo.height / 2)
|
||||||
@@ -105,19 +108,25 @@ def _build_drawing(geo: PlotGeometry) -> Drawing:
|
|||||||
return drawing
|
return drawing
|
||||||
|
|
||||||
|
|
||||||
def render_drawing(plot: FunctionPlot, width: float | None = None) -> Drawing:
|
def render_drawing(plot: FunctionPlot, width: float | None = None, palette=None, unlimited=False, max_height=None) -> Drawing:
|
||||||
"""把已解析的 FunctionPlot 渲染为 reportlab Drawing(可直接追加到 platypus story)。
|
"""把已解析的 FunctionPlot 渲染为 reportlab Drawing(可直接追加到 platypus story)。
|
||||||
|
|
||||||
``width`` 为目标输出宽度(点),用于把 640px 的几何缩放到页面内容宽;省略则按
|
``width`` 为目标输出宽度(点),用于把 640px 的几何缩放到页面内容宽;省略则按
|
||||||
原始尺寸输出。缩放只影响 PDF 渲染,不改动共享几何。
|
原始尺寸输出。缩放只影响 PDF 渲染,不改动共享几何。
|
||||||
"""
|
"""
|
||||||
geo = compute_geometry(plot)
|
geo = compute_geometry(plot, unlimited=unlimited)
|
||||||
drawing = _build_drawing(geo)
|
if palette:
|
||||||
|
from reportlab.lib.colors import HexColor as color
|
||||||
|
bg = color(palette['surface'])
|
||||||
|
if .2126*bg.red + .7152*bg.green + .0722*bg.blue < .5:
|
||||||
|
colors = ['#79c0ff','#ff9b9b','#7ee787','#d2a8ff','#f2cc60','#ffa657']
|
||||||
|
geo.colors = [value if plot.expressions[i].color else colors[i % len(colors)] for i,value in enumerate(geo.colors)]
|
||||||
|
drawing = _build_drawing(geo, palette)
|
||||||
legend_height = ((len(plot.expressions)+1)//2)*24
|
legend_height = ((len(plot.expressions)+1)//2)*24
|
||||||
drawing.height += legend_height
|
drawing.height += legend_height
|
||||||
for index, expression in enumerate(plot.expressions):
|
for index, expression in enumerate(plot.expressions):
|
||||||
drawing.add(String(24+(index%2)*310,geo.height+legend_height-18-(index//2)*24,
|
drawing.add(String(24+(index%2)*310,geo.height+legend_height-18-(index//2)*24,
|
||||||
expression.label or 'y = '+expression.expression,fontName=_FONT,fontSize=12,fillColor=HexColor(geo.colors[index])))
|
expression.label or 'y = '+expression.expression,fontName=_FONT,fontSize=12,fillColor=HexColor(geo.colors[index])))
|
||||||
if width is not None and width > 0:
|
if width is not None and width > 0:
|
||||||
drawing.renderScale = min(1.0, width / geo.width)
|
drawing.renderScale = min(1.0, width / geo.width, max_height / drawing.height if max_height else 1.0)
|
||||||
return drawing
|
return drawing
|
||||||
|
|||||||
@@ -334,17 +334,16 @@ def test_pdf_exporter_function_plot_fallback_on_error() -> None:
|
|||||||
assert any("函数图像" in w for w in result.warnings)
|
assert any("函数图像" in w for w in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
def test_pdf_exporter_limits_function_plot_count() -> None:
|
def test_pdf_exporter_has_no_function_plot_count_quota() -> None:
|
||||||
from app.export.exporters.pdf import PdfExporter
|
from app.export.exporters.pdf import PdfExporter
|
||||||
|
|
||||||
blocks = "\n\n".join("```function-plot\ny = x\n```" for _ in range(20))
|
blocks = "\n\n".join("```function-plot\ny = x\n```" for _ in range(20))
|
||||||
result = asyncio.run(PdfExporter().export(parse_document(blocks), ExportOptions()))
|
result = asyncio.run(PdfExporter().export(parse_document(blocks), ExportOptions()))
|
||||||
assert result.content[:4] == b"%PDF"
|
assert result.content[:4] == b"%PDF"
|
||||||
# 超出数量上限的图块回退占位并记 warning
|
assert not any("函数图像" in w for w in result.warnings)
|
||||||
assert any("数量超过上限" in w for w in result.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pdf_exporter_limits_total_plot_nodes(monkeypatch) -> None:
|
def test_pdf_exporter_has_no_total_plot_node_quota(monkeypatch) -> None:
|
||||||
import app.export.exporters._common as common_mod
|
import app.export.exporters._common as common_mod
|
||||||
from app.export.exporters.pdf import PdfExporter
|
from app.export.exporters.pdf import PdfExporter
|
||||||
|
|
||||||
@@ -352,7 +351,7 @@ def test_pdf_exporter_limits_total_plot_nodes(monkeypatch) -> None:
|
|||||||
md = "```function-plot\ny = x\n```\n\n```function-plot\ny = x + x + x + x\n```"
|
md = "```function-plot\ny = x\n```\n\n```function-plot\ny = x + x + x + x\n```"
|
||||||
result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions()))
|
result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions()))
|
||||||
assert result.content[:4] == b"%PDF"
|
assert result.content[:4] == b"%PDF"
|
||||||
assert any("累计复杂度" in w for w in result.warnings)
|
assert not any("函数图像" in w for w in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
def test_docx_exporter_embeds_plot_and_warns_missing_mermaid() -> None:
|
def test_docx_exporter_embeds_plot_and_warns_missing_mermaid() -> None:
|
||||||
@@ -831,7 +830,8 @@ def test_callout_formats(name):
|
|||||||
xml = z.read("word/document.xml").decode()
|
xml = z.read("word/document.xml").decode()
|
||||||
assert all(word in xml for word in ["Title", "Body", "item", "second", "w:shd"])
|
assert all(word in xml for word in ["Title", "Body", "item", "second", "w:shd"])
|
||||||
result = PdfExporter().render(doc, ExportOptions(theme_id="sepia"))
|
result = PdfExporter().render(doc, ExportOptions(theme_id="sepia"))
|
||||||
assert result.content.startswith(b"%PDF") and len(result.warnings) == 1
|
assert result.content.startswith(b"%PDF")
|
||||||
|
assert not any("浅色打印" in warning for warning in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("fold", ["", "+", "-"])
|
@pytest.mark.parametrize("fold", ["", "+", "-"])
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""PDF theme and resource policy regressions; no real providers or user files."""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from app.contracts import ExportAsset, ExportOptions, ExportRequest
|
||||||
|
from app.export.assets import validate_assets, enrich_document, source_hash
|
||||||
|
from app.export.exporters.pdf import PdfExporter
|
||||||
|
from app.export.markdown import parse_document
|
||||||
|
from app.export.themes import PALETTES
|
||||||
|
from app.export import service
|
||||||
|
|
||||||
|
|
||||||
|
def png_asset(size=(40,30), source='graph LR; A-->B'):
|
||||||
|
out=BytesIO(); Image.new('RGBA',size,(0,0,0,0)).save(out,'PNG')
|
||||||
|
return ExportAsset(kind='mermaid',source_hash=source_hash(source),png_base64=base64.b64encode(out.getvalue()).decode())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('theme',list(PALETTES))
|
||||||
|
def test_pdf_theme_colors_are_written_on_every_page(theme):
|
||||||
|
import re, zlib
|
||||||
|
palette=PALETTES[theme]
|
||||||
|
doc=parse_document(('## Section\n\nText body\n\n> Quoted text\n\n```python\nprint(1)\n```\n\n')*30)
|
||||||
|
result=PdfExporter().render(doc,ExportOptions(theme_id=theme))
|
||||||
|
streams=[]
|
||||||
|
for match in re.finditer(rb'stream\r?\n(.*?)endstream',result.content,re.S):
|
||||||
|
try: streams.append(zlib.decompress(base64.a85decode(match[1].strip().removesuffix(b'~>'))))
|
||||||
|
except Exception: pass
|
||||||
|
from reportlab.lib.rl_accel import fp_str
|
||||||
|
command=(fp_str(*[int(palette[0][i:i+2],16)/255 for i in (1,3,5)])+' rg').encode()
|
||||||
|
pages=[s for s in streams if b'BT' in s and b'/F' in s]
|
||||||
|
assert len(pages)>1
|
||||||
|
assert all(command in s for s in pages)
|
||||||
|
assert not any('浅色打印' in w for w in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_accepts_asset_contract_beyond_previous_count_and_size():
|
||||||
|
assets=[png_asset(source=str(i)) for i in range(65)]
|
||||||
|
assets[0]=assets[0].model_copy(update={'png_base64':'A'*2800004})
|
||||||
|
values=dict(source={'type':'markdown','markdown':'content'},assets=assets)
|
||||||
|
ExportRequest(format='pdf',**values)
|
||||||
|
with pytest.raises(ValidationError): ExportRequest(format='html',**values)
|
||||||
|
with pytest.raises(ValidationError): ExportRequest(format='docx',**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_large_png_still_requires_valid_format():
|
||||||
|
asset=png_asset((2100,2000))
|
||||||
|
assert validate_assets([asset],unlimited=True)
|
||||||
|
with pytest.raises(Exception): validate_assets([asset])
|
||||||
|
with pytest.raises(Exception): validate_assets([asset.model_copy(update={'png_base64':'invalid'})],unlimited=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_embeds_more_than_64_resources_with_theme_background():
|
||||||
|
doc=parse_document(('```mermaid\ngraph LR; A-->B\n```\n\n')*65)
|
||||||
|
from app.export.assets import attach_assets
|
||||||
|
attach_assets(doc,validate_assets([png_asset()],unlimited=True))
|
||||||
|
assert not enrich_document(doc,unlimited=True,options=ExportOptions(theme_id='dark'))
|
||||||
|
assert all('static_png' in node.attributes for node in doc.children)
|
||||||
|
with Image.open(BytesIO(doc.children[-1].attributes['static_png'])) as image:
|
||||||
|
assert image.getpixel((0,0)) == (13,17,23)
|
||||||
|
assert PdfExporter().render(doc,ExportOptions(theme_id='dark')).content.startswith(b'%PDF')
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_pipeline_ignores_source_and_output_quotas(monkeypatch):
|
||||||
|
monkeypatch.setattr(service,'MAX_MARKDOWN_CHARS',8)
|
||||||
|
monkeypatch.setattr(service,'MAX_EXPORT_BYTES',8)
|
||||||
|
async def run():
|
||||||
|
job=await service.create_export(ExportRequest(format='pdf',source={'type':'markdown','markdown':'Beyond the previous quota.'}))
|
||||||
|
done=await service.wait_for_export(job.job_id)
|
||||||
|
assert done.status.value=='completed'
|
||||||
|
assert service.get_export_file(job.job_id).stat().st_size>8
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await service.create_export(ExportRequest(format='html',source={'type':'markdown','markdown':'Beyond the previous quota.'}))
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_accepts_more_than_16_curves_and_keeps_expression_safety():
|
||||||
|
doc=parse_document('```function-plot\n'+'\n'.join(f'y=x+{i}' for i in range(17))+'\n```')
|
||||||
|
result=PdfExporter().render(doc,ExportOptions(theme_id='dark'))
|
||||||
|
assert not any('函数图像' in w for w in result.warnings)
|
||||||
|
unsafe=PdfExporter().render(parse_document('```function-plot\ny=__import__("os")\n```'),ExportOptions())
|
||||||
|
assert any('函数图像' in w for w in unsafe.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_custom_palette_and_math_color():
|
||||||
|
palette=dict(zip(('page','surface','text','muted','code','border','accent'),PALETTES['midnight-purple']))
|
||||||
|
options=ExportOptions(theme_id='my-theme',palette=palette)
|
||||||
|
doc=parse_document('Formula $x^2$')
|
||||||
|
assert not enrich_document(doc,unlimited=True,options=options)
|
||||||
|
math=next(n for n in doc.children[0].children if n.type=='math_inline')
|
||||||
|
with Image.open(BytesIO(math.attributes['static_png'])) as image:
|
||||||
|
assert image.getpixel((0,0))==(25,19,34)
|
||||||
|
assert not any('主题' in w for w in PdfExporter().render(doc,options).warnings)
|
||||||
|
with pytest.raises(ValidationError): ExportOptions(palette={**palette,'text':'url(file:///private)'})
|
||||||
@@ -1629,8 +1629,12 @@ CUDA 组件:`GET /api/local-models/runtime-components/cuda` 返回 status、st
|
|||||||
|
|
||||||
`POST /api/plots/function`:请求`{source, theme_id}`,source最多20000字符;响应`{result: {content,mime_type,width,height,warnings} | null, diagnostics: [{severity,code,message,line}], node_count}`。语法错误为200诊断、请求字段违规422。共享plot白名单解释器,不执行eval;每块16表达式、8000累计节点、并发2。主题映射当前六个Theme ID并提供CSS图表Token;未知主题回退light。
|
`POST /api/plots/function`:请求`{source, theme_id}`,source最多20000字符;响应`{result: {content,mime_type,width,height,warnings} | null, diagnostics: [{severity,code,message,line}], node_count}`。语法错误为200诊断、请求字段违规422。共享plot白名单解释器,不执行eval;每块16表达式、8000累计节点、并发2。主题映射当前六个Theme ID并提供CSS图表Token;未知主题回退light。
|
||||||
|
|
||||||
`ExportRequest`新增可选title(最多200字符)、assets(最多64);`source.file_path`为未保存快照中相对图片的基准位置,不能用于任意文件读。每个asset为`{kind: mermaid|math_block|math_inline|image, source_hash: 64位sha256十六进制, png_base64}`;摘要为strip后UTF-8源码(image为src)的SHA256。只接受有效PNG并重编码,每图4百万像素、总16百万像素/8MiB;重复kind/hash或超限返回422 EXPORT_ASSET_INVALID。摘要失配不替换当前节点,不接受客户端SVG/XML/URL执行。
|
`ExportRequest`新增可选title(最多200字符)、assets(HTML/DOCX 最多64,PDF 不设数量上限);`source.file_path`为未保存快照中相对图片的基准位置,不能用于任意文件读。每个asset为`{kind: mermaid|math_block|math_inline|image, source_hash: 64位sha256十六进制, png_base64}`;摘要为strip后UTF-8源码(image为src)的SHA256。只接受有效PNG并重编码;HTML/DOCX 每图4百万像素、总16百万像素/8MiB,PDF 不使用这些预算。重复kind/hash或无效PNG返回422 EXPORT_ASSET_INVALID。摘要失配不替换当前节点,不接受客户端SVG/XML/URL执行。
|
||||||
|
|
||||||
未带资源的公式由受限MathText转换(512字符/20层/64资源),仅解析Vault范围内PNG/JPEG/WebP(单文件2MB),拒绝远端与越界路径。资源失败保留源码/替代文字和warning。PDF/DOCX采用浅色打印样式;HTML保留有限主题调色板,不复刻任意主题CSS。DOCX图片为静态内容,不提供可编辑公式对象。
|
未带资源的公式由 MathText 转换,仅解析 Vault 范围内 PNG/JPEG/WebP,拒绝远端与越界路径。HTML/DOCX 保留 512 字符/20 层/64 资源、单文件 2MB 的预算;PDF 不使用这些预算,也不限制导出源长度、产物字节数、函数图数量、表达式数量及累计复杂度。表达式白名单、有效图片校验、数值采样的收敛控制和队列并发调度仍保留。资源无法表示时保留源码/替代文字和 warning。
|
||||||
|
|
||||||
|
PDF 使用当前主题配色。`ExportOptions.palette` 可选,包含 page/surface/text/muted/code/border/accent 七个必填 `#RRGGBB` 值,由客户端在点击导出时冻结,用于自定义主题。未传 palette 时按 theme_id 解析六套内置配色,未知 ID 回退 light 并警告。PDF 页背景、正文、代码、表格、引用、链接、公式、Mermaid 和函数图均主题化;不会执行主题 CSS。DOCX 仍采用浅色打印样式;HTML 保留有限主题调色板。DOCX 图片为静态内容,不提供可编辑公式对象。
|
||||||
|
|
||||||
|
关闭导出窗口仅停止 UI 轮询,已发起的导出继续。主动取消在准备阶段停止提交;创建请求期间取消会等待任务 ID,调用后台取消接口并读取实际状态。
|
||||||
|
|
||||||
RAG `retrieval.fusion`接受rrf(默认)或weighted(归一化FTS/vector各50%),参数写入config_snapshot。真实本地单查询Embedding可命中有界进程缓存,provenance.query_embedding_cache为hit/miss;比较延迟须分别报告冷暖样本。HashEmbedding仍仅为确定性单元测试,不是当前生产检索模型。
|
RAG `retrieval.fusion`接受rrf(默认)或weighted(归一化FTS/vector各50%),参数写入config_snapshot。真实本地单查询Embedding可命中有界进程缓存,provenance.query_embedding_cache为hit/miss;比较延迟须分别报告冷暖样本。HashEmbedding仍仅为确定性单元测试,不是当前生产检索模型。
|
||||||
|
|||||||
@@ -156,3 +156,14 @@ PDF、DOCX 保持浅色打印样式;选择其他主题时返回明确 warning
|
|||||||
警告框识别与工作区一致:标记与标题之间可不留空格,类型允许数字、下划线和连字符;自定义类型回退 note 配色并保留自定义标题,省略标题时使用类型名称首字母大写。
|
警告框识别与工作区一致:标记与标题之间可不留空格,类型允许数字、下划线和连字符;自定义类型回退 note 配色并保留自定义标题,省略标题时使用类型名称首字母大写。
|
||||||
|
|
||||||
警告框、普通引用、列表及交叉嵌套中的 Markdown 表格均启用容器内部解析,HTML 输出 table、PDF 输出 Table、DOCX 输出原生表格。测试逐一检查单元格内容和产物结构。每个 HTML 警告框独立初始化颜色变量,避免 NOTE 等类型继承外层 WARNING 的颜色;已在五套内置导出主题中检查嵌套配色及表格显示。
|
警告框、普通引用、列表及交叉嵌套中的 Markdown 表格均启用容器内部解析,HTML 输出 table、PDF 输出 Table、DOCX 输出原生表格。测试逐一检查单元格内容和产物结构。每个 HTML 警告框独立初始化颜色变量,避免 NOTE 等类型继承外层 WARNING 的颜色;已在五套内置导出主题中检查嵌套配色及表格显示。
|
||||||
|
|
||||||
|
|
||||||
|
### PDF 主题与资源策略更新(2026-09-07)
|
||||||
|
|
||||||
|
当前 PDF 行为以此节为准,覆盖上文早期 PR 的浅色打印和资源预算说明。
|
||||||
|
|
||||||
|
PDF 使用导出按钮点击时的主题配色快照,支持六种内置/社区主题与自定义主题的七项颜色。页背景、正文、引用、代码块、表格、链接、语义提示块、Mermaid、公式与函数图均参与主题适配;公式使用透明底再合成主题表面色,深色曲线使用较亮的默认色。标题随下一块分页,代码保留背景与边框。PDF 不执行 CSS 装饰或主题脚本。
|
||||||
|
|
||||||
|
PDF 取消导出源/产物大小限制、Mermaid 数量和像素预算、请求资源数量和字节预算、Vault 图片大小/累计预算、MathText 长度/深度预算、函数图数量/表达式数量及 AST 预算。HTML/DOCX、在线预览仍使用原有限制;有效图片、Vault 路径、表达式语法校验和数值求解退出条件仍生效。无限制指移除应用导出配额,实际文件规模仍受浏览器、解析器和可用内存约束。
|
||||||
|
|
||||||
|
关闭窗口仅结束 UI 生命周期,导出流程继续;主动取消仍取消提交中的后台任务。回归测试见 `ExportDialog.spec.ts`、`exportService.spec.ts`、`test_pdf_theme_resources.py`。
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# PDF 主题与资源限制修复验收
|
||||||
|
|
||||||
|
工作目录:`G:/OSProject/NotesAgent`;分支:`feat/phase2-completion`。
|
||||||
|
|
||||||
|
## 完成内容
|
||||||
|
|
||||||
|
1. 修复关闭窗口误取消提交中的导出任务,保留主动取消。
|
||||||
|
2. PDF 移除前后端导出数量、大小、图片像素及累计资源配额,详见接口契约。
|
||||||
|
3. PDF 读取点击导出时的主题颜色快照,覆盖六套主题和自定义调色板,移除强制浅色打印提示。
|
||||||
|
4. 公式透明底、Mermaid 主题变量、矢量函数图、代码背景与边框、表格、提示块和页面背景都使用对应主题颜色。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 全量后端:879 项通过,1 条既有 Starlette/httpx 弃用警告。
|
||||||
|
- 全量前端:82 文件、448 项通过。
|
||||||
|
- 分页/代码背景最终调整后:相关后端 108 项通过。
|
||||||
|
- 前端生产构建通过,仍有既有大分包提示。
|
||||||
|
- 超旧限制回归:17 个 Mermaid、65 个文档资源、单图超过 400 万像素、超过旧源/产物阈值、17 条函数表达式与20个函数图。
|
||||||
|
- 真实前端 Mermaid 栅格化 + 后端导出流水线生成 light/dark/sepia/paper-moments/ocean-blue/midnight-purple 六份 PDF;Poppler 渲染并检查全部12页,未出现白底公式、不可读深色文字、图表裁切或孤立章节标题。
|
||||||
|
- 验证数据与截图:本机 `.local-plans/pdf-theme/`;全部接口拦截为测试数据,未访问真实模型或用户笔记。
|
||||||
|
|
||||||
|
本次未改动用户已有的三份笔记修改。主题适配使用颜色与语义样式,不将任意主题 CSS/脚本直接用于 PDF。
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"theme": "light",
|
||||||
|
"pages": 2,
|
||||||
|
"bytes": 67245,
|
||||||
|
"sha256": "5b6efed909df971f455253958ceadce47a7bd198b52ca1a1c755d9d25c51dc05",
|
||||||
|
"palette": {
|
||||||
|
"page": "#ffffff",
|
||||||
|
"surface": "#ffffff",
|
||||||
|
"text": "#1f2328",
|
||||||
|
"muted": "#656d76",
|
||||||
|
"code": "#f7f8fa",
|
||||||
|
"border": "#e4e7eb",
|
||||||
|
"accent": "#5b67f1"
|
||||||
|
},
|
||||||
|
"warnings": [],
|
||||||
|
"visual_review": "passed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"theme": "dark",
|
||||||
|
"pages": 2,
|
||||||
|
"bytes": 66788,
|
||||||
|
"sha256": "b09da4dc97246b7444f17d2b5756dea6dda004981cbcbd360bc2de3d26bd00cf",
|
||||||
|
"palette": {
|
||||||
|
"page": "#0d1117",
|
||||||
|
"surface": "#161b22",
|
||||||
|
"text": "#e6edf3",
|
||||||
|
"muted": "#8b949e",
|
||||||
|
"code": "#161b22",
|
||||||
|
"border": "#30363d",
|
||||||
|
"accent": "#7d8bff"
|
||||||
|
},
|
||||||
|
"warnings": [],
|
||||||
|
"visual_review": "passed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"theme": "sepia",
|
||||||
|
"pages": 2,
|
||||||
|
"bytes": 66836,
|
||||||
|
"sha256": "abc329f9691ba46d8ddd25e4b3621b63fc4cc8baba8823800c47d68feb910e0c",
|
||||||
|
"palette": {
|
||||||
|
"page": "#fbf3df",
|
||||||
|
"surface": "#fff8e8",
|
||||||
|
"text": "#40372b",
|
||||||
|
"muted": "#746653",
|
||||||
|
"code": "#f4e8ca",
|
||||||
|
"border": "#ddcfad",
|
||||||
|
"accent": "#8a5b32"
|
||||||
|
},
|
||||||
|
"warnings": [],
|
||||||
|
"visual_review": "passed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"theme": "paper-moments",
|
||||||
|
"pages": 2,
|
||||||
|
"bytes": 66808,
|
||||||
|
"sha256": "e1929fbec0486b59abcc808fab557f4103ec102ad70e6fb9f0b26f84414c0fe4",
|
||||||
|
"palette": {
|
||||||
|
"page": "#faf7ee",
|
||||||
|
"surface": "#fffdf5",
|
||||||
|
"text": "#493f35",
|
||||||
|
"muted": "#6e6053",
|
||||||
|
"code": "#f3eee3",
|
||||||
|
"border": "#b5a693",
|
||||||
|
"accent": "#875343"
|
||||||
|
},
|
||||||
|
"warnings": [],
|
||||||
|
"visual_review": "passed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"theme": "ocean-blue",
|
||||||
|
"pages": 2,
|
||||||
|
"bytes": 67260,
|
||||||
|
"sha256": "1e567daeb2ba8bcb5c13af465f174865a3662ade444c58cd09af66ae99233346",
|
||||||
|
"palette": {
|
||||||
|
"page": "#ffffff",
|
||||||
|
"surface": "#ffffff",
|
||||||
|
"text": "#1e293b",
|
||||||
|
"muted": "#64748b",
|
||||||
|
"code": "#f8fafc",
|
||||||
|
"border": "#e2e8f0",
|
||||||
|
"accent": "#0077b6"
|
||||||
|
},
|
||||||
|
"warnings": [],
|
||||||
|
"visual_review": "passed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"theme": "midnight-purple",
|
||||||
|
"pages": 2,
|
||||||
|
"bytes": 66788,
|
||||||
|
"sha256": "fd5efcedec0d7f4892fb987e0e4ba9052aa88b7361159cbb2ca3b7e00dde495c",
|
||||||
|
"palette": {
|
||||||
|
"page": "#1a1b26",
|
||||||
|
"surface": "#24283b",
|
||||||
|
"text": "#c0caf5",
|
||||||
|
"muted": "#9aa5ce",
|
||||||
|
"code": "#24283b",
|
||||||
|
"border": "#3b3f5c",
|
||||||
|
"accent": "#9d4edd"
|
||||||
|
},
|
||||||
|
"warnings": [],
|
||||||
|
"visual_review": "passed"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {mount,flushPromises} from '@vue/test-utils'
|
||||||
|
import {it,expect,vi} from 'vitest'
|
||||||
|
import ExportDialog from './ExportDialog.vue'
|
||||||
|
import {apiClient} from '@/services/apiClient'
|
||||||
|
vi.mock('@/stores/editor',()=>({useEditorStore:()=>({content:'# snapshot',currentFilePath:'note.md'})}))
|
||||||
|
vi.mock('@/stores/theme',()=>({useThemeStore:()=>({currentThemeId:'light'})}))
|
||||||
|
vi.mock('@/services/mermaidService',()=>({renderMermaid:vi.fn()}))
|
||||||
|
vi.mock('@/services/apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
|
||||||
|
it('closing the dialog after submitting preserves the background export',async()=>{
|
||||||
|
let finish!:(value:unknown)=>void
|
||||||
|
vi.mocked(apiClient.get).mockImplementation(async(path:string)=>path==='/api/exports'?{items:[]}:{job_id:'closing-job',status:'cancelled',warnings:[],error:null,file:null})
|
||||||
|
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
|
||||||
|
const wrapper=mount(ExportDialog,{global:{stubs:{AppDialog:{template:'<div><slot /></div>'}}}})
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(b=>b.text()==='开始导出')!.trigger('click')
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.anything())
|
||||||
|
wrapper.unmount()
|
||||||
|
finish({job_id:'closing-job',status:'queued',warnings:[],error:null,file:null})
|
||||||
|
await flushPromises()
|
||||||
|
expect(apiClient.post).not.toHaveBeenCalledWith('/api/exports/closing-job/cancel')
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import AppDialog from '@/components/common/AppDialog.vue'
|
import AppDialog from '@/components/common/AppDialog.vue'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { exportService, type ExportFormat, type ExportJob } from '@/services/exportService'
|
import { exportService, captureExportPalette, type ExportFormat, type ExportJob } from '@/services/exportService'
|
||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
const editor = useEditorStore(), theme = useThemeStore()
|
const editor = useEditorStore(), theme = useThemeStore()
|
||||||
const format = ref<ExportFormat>('html'), page = ref('A4'), title = ref(true)
|
const format = ref<ExportFormat>('html'), page = ref('A4'), title = ref(true)
|
||||||
@@ -19,7 +19,7 @@ async function start() {
|
|||||||
preparing.value = true; error.value = ''; controller = new AbortController()
|
preparing.value = true; error.value = ''; controller = new AbortController()
|
||||||
const snapshot = editor.content, name = editor.currentFilePath?.split('/').pop()?.replace(/\.md$/i, '') ?? '笔记'
|
const snapshot = editor.content, name = editor.currentFilePath?.split('/').pop()?.replace(/\.md$/i, '') ?? '笔记'
|
||||||
try {
|
try {
|
||||||
const job = await exportService.create(snapshot, name, format.value, { theme_id: theme.currentThemeId, include_title: title.value, page_size: page.value }, controller.signal, editor.currentFilePath ?? undefined)
|
const job = await exportService.create(snapshot, name, format.value, { theme_id: theme.currentThemeId, include_title: title.value, page_size: page.value, palette: captureExportPalette() }, controller.signal, editor.currentFilePath ?? undefined)
|
||||||
if (!disposed) jobs.value.unshift(job)
|
if (!disposed) jobs.value.unshift(job)
|
||||||
} catch (e) { error.value = e instanceof DOMException && e.name === 'AbortError' ? '已取消导出' : String(e) }
|
} catch (e) { error.value = e instanceof DOMException && e.name === 'AbortError' ? '已取消导出' : String(e) }
|
||||||
finally { preparing.value = false }
|
finally { preparing.value = false }
|
||||||
@@ -28,7 +28,7 @@ async function action(job: ExportJob, download = false) {
|
|||||||
try { if (download) await exportService.download(job); else await exportService.cancel(job.id) } catch (e) { error.value = String(e) }
|
try { if (download) await exportService.download(job); else await exportService.cancel(job.id) } catch (e) { error.value = String(e) }
|
||||||
}
|
}
|
||||||
onMounted(refresh)
|
onMounted(refresh)
|
||||||
onBeforeUnmount(() => { disposed = true; clearTimeout(timer); controller?.abort() })
|
onBeforeUnmount(() => { disposed = true; clearTimeout(timer) })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppDialog label="导出笔记" @close="emit('close')"><section class="modal export-modal">
|
<AppDialog label="导出笔记" @close="emit('close')"><section class="modal export-modal">
|
||||||
@@ -36,7 +36,8 @@ onBeforeUnmount(() => { disposed = true; clearTimeout(timer); controller?.abort(
|
|||||||
<label for="export-format">格式</label><select id="export-format" v-model="format"><option value="html">HTML</option><option value="pdf">PDF</option><option value="docx">DOCX</option></select>
|
<label for="export-format">格式</label><select id="export-format" v-model="format"><option value="html">HTML</option><option value="pdf">PDF</option><option value="docx">DOCX</option></select>
|
||||||
<label>纸张 <select v-model="page"><option>A4</option><option>Letter</option></select></label>
|
<label>纸张 <select v-model="page"><option>A4</option><option>Letter</option></select></label>
|
||||||
<label><input v-model="title" type="checkbox">包含标题</label>
|
<label><input v-model="title" type="checkbox">包含标题</label>
|
||||||
<p v-if="format !== 'html'">PDF / DOCX 使用浅色打印样式。</p>
|
<p v-if="format === 'docx'">DOCX 使用浅色打印样式。</p>
|
||||||
|
<p v-if="format === 'pdf'">PDF 使用当前主题配色。</p>
|
||||||
<button class="button-primary" :disabled="preparing || !editor.content.trim()" @click="start">{{ preparing ? '准备图表…' : '开始导出' }}</button>
|
<button class="button-primary" :disabled="preparing || !editor.content.trim()" @click="start">{{ preparing ? '准备图表…' : '开始导出' }}</button>
|
||||||
<button v-if="preparing" @click="controller?.abort()">取消准备</button>
|
<button v-if="preparing" @click="controller?.abort()">取消准备</button>
|
||||||
<p v-if="error" role="alert">{{ error }}</p>
|
<p v-if="error" role="alert">{{ error }}</p>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
|
|||||||
vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()}))
|
vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()}))
|
||||||
import {renderMermaid} from './mermaidService'
|
import {renderMermaid} from './mermaidService'
|
||||||
import {apiClient} from './apiClient'
|
import {apiClient} from './apiClient'
|
||||||
import {exportService} from './exportService'
|
import {exportService,captureExportPalette} from './exportService'
|
||||||
describe('export snapshot contract',()=>{
|
describe('export snapshot contract',()=>{
|
||||||
it('submits the unsaved Markdown snapshot and maps warnings and filename',async()=>{
|
it('submits the unsaved Markdown snapshot and maps warnings and filename',async()=>{
|
||||||
vi.mocked(apiClient.post).mockResolvedValue({job_id:'job',status:'queued',warnings:['print palette'],file:{file_name:'note.pdf'},error:null})
|
vi.mocked(apiClient.post).mockResolvedValue({job_id:'job',status:'queued',warnings:['print palette'],file:{file_name:'note.pdf'},error:null})
|
||||||
@@ -62,3 +62,25 @@ it.each(['mermaid','Mermaid','mermaid title="Flow"'])('prepares a static asset f
|
|||||||
expect(renderMermaid).toHaveBeenCalledWith('flowchart LR\n A-->B',{mode:'raster',theme:'light'})
|
expect(renderMermaid).toHaveBeenCalledWith('flowchart LR\n A-->B',{mode:'raster',theme:'light'})
|
||||||
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[expect.objectContaining({kind:'mermaid',png_base64:'YWJj',source_hash:expect.stringMatching(/^[a-f0-9]{64}$/)})]}))
|
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[expect.objectContaining({kind:'mermaid',png_base64:'YWJj',source_hash:expect.stringMatching(/^[a-f0-9]{64}$/)})]}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('PDF prepares more than 16 Mermaid assets with the frozen palette',async()=>{
|
||||||
|
vi.stubGlobal('crypto',webcrypto)
|
||||||
|
vi.stubGlobal('Image',class {src='';decode(){return Promise.resolve()}})
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue({fillStyle:'',fillRect:vi.fn(),drawImage:vi.fn()} as never)
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype,'toDataURL').mockReturnValue('data:image/png;base64,YWJj')
|
||||||
|
vi.mocked(renderMermaid).mockResolvedValue({svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"></svg>',warnings:[]} as never)
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue(queued)
|
||||||
|
const palette={page:'#010409',surface:'#161b22',text:'#e6edf3',muted:'#b1bac4',code:'#21262d',border:'#57606a',accent:'#79c0ff'}
|
||||||
|
const markdown=Array.from({length:17},(_,i)=>'```mermaid\nflowchart LR\n A'+i+'-->B\n```').join('\n\n')
|
||||||
|
await exportService.create(markdown,'many','pdf',{...reviewOptions,theme_id:'dark',palette})
|
||||||
|
expect(renderMermaid).toHaveBeenCalledTimes(17)
|
||||||
|
expect(renderMermaid).toHaveBeenCalledWith(expect.any(String),{mode:'raster',theme:'dark',unlimited:true,palette})
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:expect.arrayContaining(Array.from({length:17},()=>expect.anything())),options:expect.objectContaining({palette})}))
|
||||||
|
await expect(exportService.create(markdown,'many','html',reviewOptions)).rejects.toThrow('最多 16')
|
||||||
|
})
|
||||||
|
it('captures custom theme CSS as a portable palette',()=>{
|
||||||
|
const values={'background-primary':'#010409','surface-primary':'rgb(22, 27, 34)','text-primary':'#e6edf3','text-secondary':'#b1bac4','background-secondary':'#21262d','border-default':'#57606a','accent-primary':'#79c0ff'}
|
||||||
|
for(const [key,value] of Object.entries(values))document.documentElement.style.setProperty('--color-'+key,value)
|
||||||
|
try { expect(captureExportPalette()).toMatchObject({surface:'#161b22',text:'#e6edf3',accent:'#79c0ff'}) }
|
||||||
|
finally { for(const key of Object.keys(values))document.documentElement.style.removeProperty('--color-'+key) }
|
||||||
|
})
|
||||||
|
|||||||
@@ -10,13 +10,39 @@ interface JobWire {
|
|||||||
}
|
}
|
||||||
export interface ExportJob { id: string; status: JobWire['status']; warnings: string[]; error: string | null; fileName?: string }
|
export interface ExportJob { id: string; status: JobWire['status']; warnings: string[]; error: string | null; fileName?: string }
|
||||||
const mapJob = (w: JobWire): ExportJob => ({ id: w.job_id, status: w.status, warnings: w.warnings, error: w.error, fileName: w.file?.file_name })
|
const mapJob = (w: JobWire): ExportJob => ({ id: w.job_id, status: w.status, warnings: w.warnings, error: w.error, fileName: w.file?.file_name })
|
||||||
export async function rasterize(svg: string, signal?: AbortSignal): Promise<string> {
|
export type ExportPalette = Record<'page' | 'surface' | 'text' | 'muted' | 'code' | 'border' | 'accent', string>
|
||||||
|
export function captureExportPalette(): ExportPalette | undefined {
|
||||||
|
const style = getComputedStyle(document.documentElement)
|
||||||
|
const tokens = { page:'background-primary', surface:'surface-primary', text:'text-primary', muted:'text-secondary', code:'background-secondary', border:'border-default', accent:'accent-primary' }
|
||||||
|
const entries = Object.entries(tokens).map(([key, token]) => {
|
||||||
|
const value = style.getPropertyValue(`--color-${token}`).trim()
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(value)) return [key,value]
|
||||||
|
if (/^#[0-9a-f]{3}$/i.test(value)) return [key, '#' + [...value.slice(1)].map(c => c+c).join('')]
|
||||||
|
const rgb = value.match(/^rgb\(\s*(\d+)[, ]+\s*(\d+)[, ]+\s*(\d+)\s*\)$/)
|
||||||
|
if (rgb) return [key, '#' + rgb.slice(1,4).map(v => Number(v).toString(16).padStart(2,'0')).join('')]
|
||||||
|
// Resolve named colors, color-mix/OKLCH and alpha through the browser's
|
||||||
|
// color implementation before freezing a portable RGB palette.
|
||||||
|
if (typeof CSS !== 'undefined' && CSS.supports('color', value)) {
|
||||||
|
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (context) {
|
||||||
|
context.fillStyle = '#ffffff'; context.fillRect(0,0,1,1)
|
||||||
|
context.fillStyle = value; context.fillRect(0,0,1,1)
|
||||||
|
const pixel = context.getImageData(0,0,1,1).data
|
||||||
|
return [key, '#' + [...pixel.slice(0,3)].map(v => v.toString(16).padStart(2,'0')).join('')]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [key, '']
|
||||||
|
})
|
||||||
|
return entries.every(([,value]) => value) ? Object.fromEntries(entries) as ExportPalette : undefined
|
||||||
|
}
|
||||||
|
export async function rasterize(svg: string, signal?: AbortSignal, unlimited = false, background = '#ffffff'): Promise<string> {
|
||||||
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
||||||
const root = doc.documentElement
|
const root = doc.documentElement
|
||||||
const box = root.getAttribute('viewBox')?.split(/[ ,]+/).map(Number)
|
const box = root.getAttribute('viewBox')?.split(/[ ,]+/).map(Number)
|
||||||
const width = box?.[2] || 800, height = box?.[3] || 600
|
const width = box?.[2] || 800, height = box?.[3] || 600
|
||||||
if (!Number.isFinite(width + height) || width <= 0 || height <= 0) throw new Error('图表尺寸无效')
|
if (!Number.isFinite(width + height) || width <= 0 || height <= 0) throw new Error('图表尺寸无效')
|
||||||
const scale = Math.min(4, Math.max(2, 1200 / width), Math.sqrt(4_000_000 / (width * height)))
|
const scale = Math.min(4, Math.max(2, 1200 / width), unlimited ? Infinity : Math.sqrt(4_000_000 / (width * height)))
|
||||||
root.setAttribute('width', String(Math.floor(width * scale))); root.setAttribute('height', String(Math.floor(height * scale)))
|
root.setAttribute('width', String(Math.floor(width * scale))); root.setAttribute('height', String(Math.floor(height * scale)))
|
||||||
root.style.maxWidth = 'none'
|
root.style.maxWidth = 'none'
|
||||||
const data = new XMLSerializer().serializeToString(root)
|
const data = new XMLSerializer().serializeToString(root)
|
||||||
@@ -24,7 +50,7 @@ export async function rasterize(svg: string, signal?: AbortSignal): Promise<stri
|
|||||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(data)}`
|
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(data)}`
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const abort = () => reject(new DOMException('Aborted', 'AbortError'))
|
const abort = () => reject(new DOMException('Aborted', 'AbortError'))
|
||||||
const timer = setTimeout(() => reject(new Error('图表图片解码超时')), 15000)
|
const timer = unlimited ? undefined : setTimeout(() => reject(new Error('图表图片解码超时')), 15000)
|
||||||
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort) }
|
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort) }
|
||||||
if (signal?.aborted) { cleanup(); abort(); return }
|
if (signal?.aborted) { cleanup(); abort(); return }
|
||||||
signal?.addEventListener('abort', abort, { once: true })
|
signal?.addEventListener('abort', abort, { once: true })
|
||||||
@@ -33,7 +59,7 @@ export async function rasterize(svg: string, signal?: AbortSignal): Promise<stri
|
|||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = Math.floor(width * scale); canvas.height = Math.floor(height * scale)
|
canvas.width = Math.floor(width * scale); canvas.height = Math.floor(height * scale)
|
||||||
const context = canvas.getContext('2d')!
|
const context = canvas.getContext('2d')!
|
||||||
context.fillStyle = '#ffffff'; context.fillRect(0, 0, canvas.width, canvas.height)
|
context.fillStyle = background; context.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
||||||
return canvas.toDataURL('image/png').split(',')[1]!
|
return canvas.toDataURL('image/png').split(',')[1]!
|
||||||
}
|
}
|
||||||
@@ -41,17 +67,18 @@ export async function hashSource(source: string) {
|
|||||||
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source.trim())))].map(v => v.toString(16).padStart(2, '0')).join('')
|
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source.trim())))].map(v => v.toString(16).padStart(2, '0')).join('')
|
||||||
}
|
}
|
||||||
export const exportService = {
|
export const exportService = {
|
||||||
async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string }, signal?: AbortSignal, filePath?: string) {
|
async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string; palette?: ExportPalette }, signal?: AbortSignal, filePath?: string) {
|
||||||
const blocks: string[] = []
|
const blocks: string[] = []
|
||||||
const parser = new Marked()
|
const parser = new Marked()
|
||||||
parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang?.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid') blocks.push(token.text) })
|
parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang?.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid') blocks.push(token.text) })
|
||||||
const assets = []
|
const assets = []
|
||||||
for (const source of [...new Set(blocks)]) {
|
for (const source of [...new Set(blocks)]) {
|
||||||
signal?.throwIfAborted()
|
signal?.throwIfAborted()
|
||||||
if (assets.length >= 16) throw new Error('每次导出最多 16 个 Mermaid 图表')
|
if (format !== 'pdf' && assets.length >= 16) throw new Error('每次导出最多 16 个 Mermaid 图表')
|
||||||
const result = await renderMermaid(source, { mode: 'raster', theme: 'light' })
|
const pdf = format === 'pdf'
|
||||||
|
const result = await renderMermaid(source, pdf ? { mode: 'raster', theme: ['dark','midnight-purple'].includes(options.theme_id) ? 'dark' : 'light', palette: options.palette, unlimited: true } : { mode: 'raster', theme: 'light' })
|
||||||
if (result.warnings.length) throw new Error(`Mermaid 无法导出:${result.warnings.join('; ')}`)
|
if (result.warnings.length) throw new Error(`Mermaid 无法导出:${result.warnings.join('; ')}`)
|
||||||
assets.push({ kind: 'mermaid', source_hash: await hashSource(source), png_base64: await rasterize(result.svg, signal) })
|
assets.push({ kind: 'mermaid', source_hash: await hashSource(source), png_base64: await rasterize(result.svg, signal, pdf, pdf ? options.palette?.surface ?? (['dark','midnight-purple'].includes(options.theme_id) ? '#161b22' : '#ffffff') : '#ffffff') })
|
||||||
}
|
}
|
||||||
signal?.throwIfAborted()
|
signal?.throwIfAborted()
|
||||||
// Keep the response handle when cancellation arrives during submission:
|
// Keep the response handle when cancellation arrives during submission:
|
||||||
|
|||||||
@@ -29,12 +29,25 @@ export function mermaidThemeVariables(dark: boolean, useDocument = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureInitialized(theme: 'light' | 'dark', raster = false) {
|
async function ensureInitialized(theme: 'light' | 'dark', raster = false, palette?: Record<string,string>, unlimited = false) {
|
||||||
const mermaid = await loadMermaid()
|
const mermaid = await loadMermaid()
|
||||||
|
const dark = palette ? [1,3,5].reduce((sum,index,i) => sum + parseInt(palette.surface!.slice(index,index+2),16) * [0.2126,0.7152,0.0722][i]!,0) < 128 : theme === 'dark'
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
theme: 'base',
|
theme: 'base',
|
||||||
themeVariables: mermaidThemeVariables(theme === 'dark', !raster),
|
themeVariables: palette ? {
|
||||||
|
...mermaidThemeVariables(dark, false), background: palette.surface,
|
||||||
|
primaryColor: palette.code, primaryTextColor: palette.text, primaryBorderColor: palette.border,
|
||||||
|
secondaryColor: palette.code, secondaryTextColor: palette.text, secondaryBorderColor: palette.border,
|
||||||
|
tertiaryColor: palette.code, tertiaryTextColor: palette.text, tertiaryBorderColor: palette.border,
|
||||||
|
textColor: palette.text, lineColor: palette.muted, mainBkg: palette.code, nodeBorder: palette.border,
|
||||||
|
clusterBkg: palette.surface, clusterBorder: palette.border, edgeLabelBackground: palette.surface,
|
||||||
|
actorBkg: palette.code, actorBorder: palette.border, actorTextColor: palette.text, actorLineColor: palette.muted,
|
||||||
|
signalColor: palette.muted, signalTextColor: palette.text, labelBoxBkgColor: palette.surface,
|
||||||
|
labelBoxBorderColor: palette.border, labelTextColor: palette.text, noteBkgColor: palette.code,
|
||||||
|
noteTextColor: palette.text, noteBorderColor: palette.border, activationBkgColor: palette.code, activationBorderColor: palette.border,
|
||||||
|
} : mermaidThemeVariables(theme === 'dark', !raster),
|
||||||
|
...(unlimited ? { maxTextSize: Number.MAX_SAFE_INTEGER, maxEdges: Number.MAX_SAFE_INTEGER } : {}),
|
||||||
securityLevel: 'strict',
|
securityLevel: 'strict',
|
||||||
fontFamily: raster ? 'Arial, Microsoft YaHei, sans-serif' : 'var(--font-ui-sans)',
|
fontFamily: raster ? 'Arial, Microsoft YaHei, sans-serif' : 'var(--font-ui-sans)',
|
||||||
flowchart: { useMaxWidth: true, htmlLabels: !raster },
|
flowchart: { useMaxWidth: true, htmlLabels: !raster },
|
||||||
@@ -65,18 +78,18 @@ export interface MermaidParseError {
|
|||||||
|
|
||||||
let renderCounter = 0
|
let renderCounter = 0
|
||||||
|
|
||||||
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster' } = {}): Promise<MermaidRenderResult> {
|
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean } = {}): Promise<MermaidRenderResult> {
|
||||||
return serialized(() => renderMermaidNow(source, options))
|
return serialized(() => renderMermaidNow(source, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderMermaidNow(
|
async function renderMermaidNow(
|
||||||
source: string,
|
source: string,
|
||||||
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster' } = {}
|
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean } = {}
|
||||||
): Promise<MermaidRenderResult> {
|
): Promise<MermaidRenderResult> {
|
||||||
const theme = options.theme ?? 'light'
|
const theme = options.theme ?? 'light'
|
||||||
const id = `mermaid-${Date.now()}-${++renderCounter}`
|
const id = `mermaid-${Date.now()}-${++renderCounter}`
|
||||||
try {
|
try {
|
||||||
const mermaid = await ensureInitialized(theme, options.mode === 'raster')
|
const mermaid = await ensureInitialized(theme, options.mode === 'raster', options.palette, options.unlimited)
|
||||||
const result = await mermaid.render(id, source)
|
const result = await mermaid.render(id, source)
|
||||||
const parser = new DOMParser()
|
const parser = new DOMParser()
|
||||||
const doc = parser.parseFromString(result.svg, 'image/svg+xml')
|
const doc = parser.parseFromString(result.svg, 'image/svg+xml')
|
||||||
|
|||||||
Reference in New Issue
Block a user