From 9f097ea629fcc1abf34205360000481aec82f3b6 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Mon, 7 Sep 2026 14:04:03 +0800 Subject: [PATCH] fix: preserve exports on close and support themed PDF without export quotas --- backend/app/contracts.py | 22 +++- backend/app/export/assets.py | 41 +++---- backend/app/export/exporters/_common.py | 5 +- backend/app/export/exporters/pdf.py | 79 ++++++------- backend/app/export/service.py | 14 +-- backend/app/export/themes.py | 10 ++ backend/app/plot/parser.py | 24 ++-- backend/app/plot/render.py | 4 +- backend/app/plot/render_reportlab.py | 35 +++--- backend/tests/test_export.py | 12 +- backend/tests/test_pdf_theme_resources.py | 96 ++++++++++++++++ docs/contracts/第二阶段接口契约-开发版.md | 8 +- docs/development/Export开发说明.md | 11 ++ .../PDF主题与资源限制修复验收-2026-09-07.md | 22 ++++ .../evidence/pdf-theme-20260907/results.json | 104 ++++++++++++++++++ .../src/features/editor/ExportDialog.spec.ts | 22 ++++ frontend/src/features/editor/ExportDialog.vue | 9 +- frontend/src/services/exportService.spec.ts | 24 +++- frontend/src/services/exportService.ts | 43 ++++++-- frontend/src/services/mermaidService.ts | 23 +++- 20 files changed, 485 insertions(+), 123 deletions(-) create mode 100644 backend/tests/test_pdf_theme_resources.py create mode 100644 docs/development/PDF主题与资源限制修复验收-2026-09-07.md create mode 100644 docs/development/evidence/pdf-theme-20260907/results.json create mode 100644 frontend/src/features/editor/ExportDialog.spec.ts diff --git a/backend/app/contracts.py b/backend/app/contracts.py index dc3fd30..07e9cc4 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -1426,7 +1426,18 @@ class ExportSource(Contract): 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): + palette: ExportPalette | None = None theme_id: str = "light" include_title: bool = True include_metadata: bool = False @@ -1437,16 +1448,23 @@ class ExportOptions(Contract): class ExportAsset(Contract): kind: Literal['mermaid', 'math_block', 'math_inline', 'image'] source_hash: str = Field(pattern=r'^[a-f0-9]{64}$') - png_base64: str = Field(max_length=2800000) + png_base64: str 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) source: ExportSource format: ExportFormat 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): phase: str diff --git a/backend/app/export/assets.py b/backend/app/export/assets.py index 5415345..9aca754 100644 --- a/backend/app/export/assets.py +++ b/backend/app/export/assets.py @@ -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 hashlib import threading @@ -8,12 +8,14 @@ from app.errors import ApiError _math_lock = threading.Lock() -def enrich_document(document, file_path=None): - """Embed local vault images and bounded MathText. Unsupported TeX stays explicit.""" +def enrich_document(document, file_path=None, unlimited=False, options=None): + """Embed Vault images and MathText, with format-specific quotas and palette.""" from app.config import get_settings from urllib.parse import unquote, urlsplit vault = get_settings().vault_path.resolve() 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 = [] count = total = pixels = 0 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'): count += 1 try: - if count > 64: raise ValueError('resource count') + if not unlimited and count > 64: raise ValueError('resource count') if node.attributes.get('static_png'): raw = node.attributes['static_png'] elif node.type == 'image': src = str(node.attributes.get('src','')) if urlsplit(src).scheme or src.startswith('//'): raise ValueError('remote image') 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') raw = path.read_bytes() else: @@ -36,23 +38,24 @@ def enrich_document(document, file_path=None): depth = 0 for char in source: depth += (char == '{') - (char == '}') - if depth > 20: raise ValueError('math depth') - if len(source) > 512 or depth != 0: raise ValueError('math budget') + if not unlimited and depth > 20: raise ValueError('math depth') + if (not unlimited and len(source) > 512) or depth != 0: raise ValueError('math budget') 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() - 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() with Image.open(BytesIO(raw)) as image: pixels += image.width * image.height - if pixels > 16_000_000: raise ValueError('document pixels') - if image.width * image.height > 4_000_000: raise ValueError('image dimensions') + if not unlimited and pixels > 16_000_000: raise ValueError('document pixels') + if not unlimited and image.width * image.height > 4_000_000: raise ValueError('image dimensions') out = BytesIO() - # Flatten alpha on white for portable print/Word output. - rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,'white') + # Composite transparency over the PDF theme or the print/Word white surface. + 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') 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 except Exception: node.attributes.pop('static_png', None) @@ -66,26 +69,26 @@ def enrich_document(document, file_path=None): def source_hash(source): return hashlib.sha256(source.strip().encode()).hexdigest() -def validate_assets(assets): +def validate_assets(assets, unlimited=False): result = {} total = pixels = 0 for asset in assets: try: raw = base64.b64decode(asset.png_base64, validate=True) total += len(raw) - if total > 8 * 1024 * 1024: + if not unlimited and total > 8 * 1024 * 1024: raise ValueError('asset budget') with Image.open(BytesIO(raw)) as image: pixels += image.width * image.height - if pixels > 16_000_000: raise ValueError('document pixel budget') - if image.format != 'PNG' or image.width * image.height > 4_000_000: + if not unlimited and pixels > 16_000_000: raise ValueError('document pixel budget') + if image.format != 'PNG' or (not unlimited and image.width * image.height > 4_000_000): raise ValueError('image budget') image.load() out = BytesIO() rgba = image.convert('RGBA') background = Image.new('RGBA', rgba.size, 'white') 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) if key in result: raise ValueError('duplicate asset') diff --git a/backend/app/export/exporters/_common.py b/backend/app/export/exporters/_common.py index cbf41ed..f2b1cf0 100644 --- a/backend/app/export/exporters/_common.py +++ b/backend/app/export/exporters/_common.py @@ -1,7 +1,6 @@ """导出器共享工具:URL 协议校验、函数图像预算与占位 warning 文案。 -html / pdf / docx 三个导出器共用同一套安全规则与函数图像资源预算,避免各写一份 -导致行为漂移。 +导出器共享 URL 规则;HTML / DOCX 使用文档资源预算,PDF 不使用这些预算。 """ from __future__ import annotations @@ -27,7 +26,7 @@ MAX_TOTAL_PLOT_NODES = 8000 class FunctionPlotBudget: """函数图像文档级资源预算:数量上限 + 累计 AST 节点上限。 - HTML 与 PDF 导出器在渲染每个 function-plot 图块前先问预算,超限即回退源码占位, + HTML 与 DOCX 导出器在渲染每个 function-plot 图块前先问预算,超限即回退源码占位, 不解析不采样,避免多图块组合复杂度耗尽内存/CPU。 """ diff --git a/backend/app/export/exporters/pdf.py b/backend/app/export/exporters/pdf.py index 2ba7949..b771eef 100644 --- a/backend/app/export/exporters/pdf.py +++ b/backend/app/export/exporters/pdf.py @@ -20,7 +20,7 @@ from reportlab.pdfbase.cidfonts import UnicodeCIDFont from reportlab.platypus import ( Paragraph, Indenter, - Preformatted, + XPreformatted, SimpleDocTemplate, Spacer, Table, @@ -29,12 +29,11 @@ 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.themes import CALLOUTS, pdf_palette from app.export.document import Document, DocumentNode, ExportResult from app.export.exporters._common import ( MERMAID_WARNING, RAW_HTML_WARNING, - FunctionPlotBudget, format_meta_value, format_plot_diagnostic, safe_url, @@ -54,10 +53,11 @@ _HEADING_SIZES = {1: 20, 2: 16, 3: 14, 4: 12, 5: 11, 6: 10.5} _QUOTE_COLOR = "#57606a" -def _make_styles() -> dict[str, ParagraphStyle]: +def _make_styles(palette) -> dict[str, ParagraphStyle]: body = ParagraphStyle( "pdf-body", fontName=_FONT, + textColor=palette["text"], fontSize=10.5, leading=16, spaceAfter=6, @@ -67,7 +67,7 @@ def _make_styles() -> dict[str, ParagraphStyle]: "pdf-quote", parent=body, leftIndent=14, - textColor="#57606a", + textColor=palette["muted"], spaceBefore=4, spaceAfter=6, ) @@ -78,8 +78,8 @@ def _make_styles() -> dict[str, ParagraphStyle]: leading=12, leftIndent=6, rightIndent=6, - backColor="#f6f8fa", - borderColor="#d0d7de", + backColor=palette["code"], + borderColor=palette["border"], borderWidth=0.5, borderPadding=6, spaceBefore=4, @@ -88,9 +88,9 @@ def _make_styles() -> dict[str, ParagraphStyle]: math = ParagraphStyle("pdf-math", parent=body, alignment=TA_CENTER, spaceBefore=6) cell = ParagraphStyle("pdf-cell", parent=body, fontSize=10, leading=14, spaceAfter=0) 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] = { "body": body, "title": title, @@ -109,6 +109,7 @@ def _make_styles() -> dict[str, ParagraphStyle]: leading=size * 1.4, spaceBefore=14 if level <= 2 else 10, spaceAfter=6, + keepWithNext=True, ) return styles @@ -118,17 +119,17 @@ class PdfExporter: def render(self, document: Document, options: ExportOptions) -> ExportResult: """同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。""" - self._styles = _make_styles() 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 字体") page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4) self._options = options - self._plot_budget = FunctionPlotBudget() self._plot_renderer = FunctionPlotStaticRenderer() # 内容区宽度(左右各 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() doc = SimpleDocTemplate( buf, @@ -144,7 +145,14 @@ class PdfExporter: self._render_header(document, options, story) 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) async def export(self, document: Document, options: ExportOptions) -> ExportResult: @@ -172,7 +180,7 @@ class PdfExporter: 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) + scale = min(1, self._plot_width / image.imageWidth, self._plot_height / image.imageHeight) image.drawWidth = image.imageWidth * scale image.drawHeight = image.imageHeight * scale story.append(image) @@ -194,9 +202,13 @@ class PdfExporter: def _block_callout(self, node, story, warnings): kind = node.attributes['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) 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)) self._render_children(node.children[1:],story,warnings) @@ -209,7 +221,7 @@ class PdfExporter: Paragraph(self._render_inline(child.children, warnings), self._styles["quote"]) ) 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: self._render_block(child, story, warnings) @@ -301,7 +313,7 @@ class PdfExporter: data.append(cells) table = Table(data, repeatRows=head_row_count) 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"), ("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), @@ -309,53 +321,42 @@ class PdfExporter: ("BOTTOMPADDING", (0, 0), (-1, -1), 4), ] 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)) story.append(table) 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: 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)) def _block_mermaid(self, node: DocumentNode, story: list, warnings: list[str]) -> None: 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: - # 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源 - over = self._plot_budget.check_count() - if over is not None: - warnings.append(over) - story.append(Preformatted(node.text, self._styles["code"])) - return # 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning, # 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。 try: request = StaticRenderRequest( 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: warnings.append(format_plot_diagnostic(diag)) if parsed.plot is None: - story.append(Preformatted(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"])) + story.append(XPreformatted(_html.escape(node.text), self._styles["code"])) return # 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) except Exception as 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: story.append(Paragraph(f"$${_html.escape(node.text)}$$", self._styles["math"])) @@ -393,7 +394,7 @@ class PdfExporter: if safe_href is None: warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}") return inner - return f'{inner}' + return f'{inner}' if t == "image": src = str(node.attributes.get("src") or "") alt = str(node.attributes.get("alt") or "") diff --git a/backend/app/export/service.py b/backend/app/export/service.py index dfe7b31..f2fffe7 100644 --- a/backend/app/export/service.py +++ b/backend/app/export/service.py @@ -146,7 +146,7 @@ def _evict_terminal() -> bool: 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 源提供。""" if source.type == ExportSourceType.note: 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_id": source.note_id}, ) - if len(note.markdown) > MAX_MARKDOWN_CHARS: + if not unlimited and len(note.markdown) > MAX_MARKDOWN_CHARS: raise ApiError( 400, "EXPORT_OPTIONS_INVALID", @@ -175,7 +175,7 @@ async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]: markdown = source.markdown or "" if not markdown.strip(): 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( 400, "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: """创建导出任务,立即返回 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 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(): raise ApiError( @@ -283,12 +283,12 @@ async def _execute( document.attributes["metadata"] = metadata 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.warnings[:0] = resource_warnings if cancel_event.is_set(): raise ExportCancelled() - if len(result.content) > MAX_EXPORT_BYTES: + if format != ExportFormat.pdf and len(result.content) > MAX_EXPORT_BYTES: raise ExportTooLarge() ext = _extension_for(format) diff --git a/backend/app/export/themes.py b/backend/app/export/themes.py index bf833cc..42dde44 100644 --- a/backend/app/export/themes.py +++ b/backend/app/export/themes.py @@ -32,3 +32,13 @@ 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'} + + +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])) diff --git a/backend/app/plot/parser.py b/backend/app/plot/parser.py index 4dab231..f9dcf8c 100644 --- a/backend/app/plot/parser.py +++ b/backend/app/plot/parser.py @@ -143,7 +143,7 @@ def _preprocess(expr: str) -> str: 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。 同时限制 AST 深度与节点总数,避免超长/超深表达式在递归校验或求值时触发 @@ -151,10 +151,10 @@ def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None) """ if counter is None: counter = [0] - if depth > _MAX_AST_DEPTH: + if not unlimited and depth > _MAX_AST_DEPTH: _unsafe(f"表达式嵌套过深(超过 {_MAX_AST_DEPTH} 层)") counter[0] += 1 - if counter[0] > _MAX_AST_NODES: + if not unlimited and counter[0] > _MAX_AST_NODES: _unsafe(f"表达式过于复杂(节点数超过 {_MAX_AST_NODES})") if isinstance(node, ast.Constant): 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 not isinstance(node.op, _ALLOWED_BINOPS): _unsafe(f"不支持的运算符 {type(node.op).__name__}") - _check_node(node.left, depth + 1, counter) - _check_node(node.right, depth + 1, counter) + _check_node(node.left, depth + 1, counter, unlimited) + _check_node(node.right, depth + 1, counter, unlimited) return if isinstance(node, ast.UnaryOp): if not isinstance(node.op, _ALLOWED_UNARY): _unsafe(f"不支持的运算符 {type(node.op).__name__}") - _check_node(node.operand, depth + 1, counter) + _check_node(node.operand, depth + 1, counter, unlimited) return if isinstance(node, ast.Call): 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: _unsafe(f"{node.func.id} 需要 1 个参数,实际 {len(node.args)} 个") for arg in node.args: - _check_node(arg, depth + 1, counter) + _check_node(arg, depth + 1, counter, unlimited) return _unsafe(f"不支持的语法 {type(node).__name__}") -def parse_expression(expr: str) -> ast.Expression: +def parse_expression(expr: str, unlimited: bool = False) -> ast.Expression: """把数学表达式解析为已通过白名单校验的 AST(可直接交给 evaluate)。""" preprocessed = _preprocess(expr) try: @@ -211,7 +211,7 @@ def parse_expression(expr: str) -> ast.Expression: message="表达式嵌套过深,无法解析", ) ) from exc - _check_node(tree.body) + _check_node(tree.body, unlimited=unlimited) return tree @@ -279,7 +279,7 @@ def _parse_directive(line: str) -> tuple[str, str] | None: return key, value.strip() -def parse_source(source: str) -> FunctionPlotParseResult: +def parse_source(source: str, unlimited: bool = False) -> FunctionPlotParseResult: """把 function-plot fenced block 源码解析为 FunctionPlot + 诊断。""" diagnostics: list[PlotDiagnostic] = [] expressions: list[FunctionPlotExpression] = [] @@ -371,7 +371,7 @@ def parse_source(source: str) -> FunctionPlotParseResult: continue try: - tree = parse_expression(expr_text) + tree = parse_expression(expr_text, unlimited=unlimited) except PlotParseError as exc: exc.diagnostic.line = lineno diagnostics.append(exc.diagnostic) @@ -380,7 +380,7 @@ def parse_source(source: str) -> FunctionPlotParseResult: total_nodes += _count_nodes(tree.body) expressions.append(FunctionPlotExpression(expression=expr_text)) # 表达式数量超限:整块回退并提前终止,避免对海量表达式做采样求值 - if len(expressions) > _MAX_EXPRESSIONS: + if not unlimited and len(expressions) > _MAX_EXPRESSIONS: diagnostics.append( PlotDiagnostic( severity="error", diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py index 40f494c..1685ecb 100644 --- a/backend/app/plot/render.py +++ b/backend/app/plot/render.py @@ -339,7 +339,7 @@ def _sample_segments( return clipped -def compute_geometry(plot: FunctionPlot) -> PlotGeometry: +def compute_geometry(plot: FunctionPlot, unlimited: bool = False) -> PlotGeometry: """解析并计算几何,供 SVG 与 reportlab 后端复用。""" warnings: list[str] = [] xmin, xmax = plot.domain @@ -351,7 +351,7 @@ def compute_geometry(plot: FunctionPlot) -> PlotGeometry: fns: list[tuple[object, object]] = [] for expr in plot.expressions: try: - tree = parse_expression(expr.expression) + tree = parse_expression(expr.expression, unlimited=unlimited) except PlotParseError as exc: warnings.append(f"表达式无法渲染,已跳过:{expr.expression}({exc.diagnostic.message})") continue diff --git a/backend/app/plot/render_reportlab.py b/backend/app/plot/render_reportlab.py index 576dc9f..e6b0f7a 100644 --- a/backend/app/plot/render_reportlab.py +++ b/backend/app/plot/render_reportlab.py @@ -26,9 +26,12 @@ _TICK_FONT_SIZE = 10 _LABEL_FONT_SIZE = 12 -def _build_drawing(geo: PlotGeometry) -> Drawing: +def _build_drawing(geo: PlotGeometry, palette=None) -> Drawing: """由共享几何构建矢量 Drawing(坐标翻转后仍沿用 SVG 的像素布局)。""" 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 def sx(x: float) -> float: @@ -41,19 +44,19 @@ def _build_drawing(geo: PlotGeometry) -> Drawing: if geo.grid: for x in geo.xticks: 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: 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 一致) 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( - 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 轴左侧) @@ -61,14 +64,14 @@ def _build_drawing(geo: PlotGeometry) -> Drawing: drawing.add( String( 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: drawing.add( String( 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( String( 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: @@ -95,7 +98,7 @@ def _build_drawing(geo: PlotGeometry) -> Drawing: label.add( String( 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) @@ -105,19 +108,25 @@ def _build_drawing(geo: PlotGeometry) -> 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)。 ``width`` 为目标输出宽度(点),用于把 640px 的几何缩放到页面内容宽;省略则按 原始尺寸输出。缩放只影响 PDF 渲染,不改动共享几何。 """ - geo = compute_geometry(plot) - drawing = _build_drawing(geo) + geo = compute_geometry(plot, unlimited=unlimited) + 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 drawing.height += legend_height for index, expression in enumerate(plot.expressions): 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]))) 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 diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py index affd158..f4d8995 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -334,17 +334,16 @@ def test_pdf_exporter_function_plot_fallback_on_error() -> None: 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 blocks = "\n\n".join("```function-plot\ny = x\n```" for _ in range(20)) result = asyncio.run(PdfExporter().export(parse_document(blocks), ExportOptions())) assert result.content[:4] == b"%PDF" - # 超出数量上限的图块回退占位并记 warning - assert any("数量超过上限" in w for w in result.warnings) + assert not 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 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```" result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions())) 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: @@ -831,7 +830,8 @@ def test_callout_formats(name): 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 + assert result.content.startswith(b"%PDF") + assert not any("浅色打印" in warning for warning in result.warnings) @pytest.mark.parametrize("fold", ["", "+", "-"]) diff --git a/backend/tests/test_pdf_theme_resources.py b/backend/tests/test_pdf_theme_resources.py new file mode 100644 index 0000000..d4e0469 --- /dev/null +++ b/backend/tests/test_pdf_theme_resources.py @@ -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)'}) diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index 5fb4ef8..1832dc0 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -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。 -`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仍仅为确定性单元测试,不是当前生产检索模型。 diff --git a/docs/development/Export开发说明.md b/docs/development/Export开发说明.md index f9116d7..91d81cd 100644 --- a/docs/development/Export开发说明.md +++ b/docs/development/Export开发说明.md @@ -156,3 +156,14 @@ PDF、DOCX 保持浅色打印样式;选择其他主题时返回明确 warning 警告框识别与工作区一致:标记与标题之间可不留空格,类型允许数字、下划线和连字符;自定义类型回退 note 配色并保留自定义标题,省略标题时使用类型名称首字母大写。 警告框、普通引用、列表及交叉嵌套中的 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`。 diff --git a/docs/development/PDF主题与资源限制修复验收-2026-09-07.md b/docs/development/PDF主题与资源限制修复验收-2026-09-07.md new file mode 100644 index 0000000..dd627ea --- /dev/null +++ b/docs/development/PDF主题与资源限制修复验收-2026-09-07.md @@ -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。 diff --git a/docs/development/evidence/pdf-theme-20260907/results.json b/docs/development/evidence/pdf-theme-20260907/results.json new file mode 100644 index 0000000..ee2e72d --- /dev/null +++ b/docs/development/evidence/pdf-theme-20260907/results.json @@ -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" + } +] \ No newline at end of file diff --git a/frontend/src/features/editor/ExportDialog.spec.ts b/frontend/src/features/editor/ExportDialog.spec.ts new file mode 100644 index 0000000..385257c --- /dev/null +++ b/frontend/src/features/editor/ExportDialog.spec.ts @@ -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:'
'}}}}) + 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') +}) diff --git a/frontend/src/features/editor/ExportDialog.vue b/frontend/src/features/editor/ExportDialog.vue index 883876a..4065d46 100644 --- a/frontend/src/features/editor/ExportDialog.vue +++ b/frontend/src/features/editor/ExportDialog.vue @@ -3,7 +3,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue' import AppDialog from '@/components/common/AppDialog.vue' import { useEditorStore } from '@/stores/editor' 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 editor = useEditorStore(), theme = useThemeStore() const format = ref('html'), page = ref('A4'), title = ref(true) @@ -19,7 +19,7 @@ async function start() { preparing.value = true; error.value = ''; controller = new AbortController() const snapshot = editor.content, name = editor.currentFilePath?.split('/').pop()?.replace(/\.md$/i, '') ?? '笔记' 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) } catch (e) { error.value = e instanceof DOMException && e.name === 'AbortError' ? '已取消导出' : String(e) } 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) } } onMounted(refresh) -onBeforeUnmount(() => { disposed = true; clearTimeout(timer); controller?.abort() }) +onBeforeUnmount(() => { disposed = true; clearTimeout(timer) })