diff --git a/backend/app/export/markdown.py b/backend/app/export/markdown.py index 436ebda..7f83986 100644 --- a/backend/app/export/markdown.py +++ b/backend/app/export/markdown.py @@ -178,6 +178,8 @@ class _AstMapper: type="link", node_id=self.next_id(), attributes=attributes, children=self.map_inline(token.get("children", [])), ) + if kind == "inline_html": + return DocumentNode(type="text", node_id=self.next_id(), text=token.get("raw", ""), attributes={"raw_html": True}) if kind == "codespan": return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", "")) if kind == "image": diff --git a/backend/app/export/service.py b/backend/app/export/service.py index e001bcc..70c351a 100644 --- a/backend/app/export/service.py +++ b/backend/app/export/service.py @@ -411,12 +411,23 @@ async def preview_resources(request: ExportRequest): from app.export.assets import enrich_document from app.plot.parser import parse_source from app.plot.render import render_svg - from app.export.document import Document + from app.export.document import Document, DocumentNode + from html.parser import HTMLParser markdown, _, metadata = await _resolve_source(request.source, True) def prepare(): document = parse_document(markdown) images, plots = [], [] + class HtmlImages(HTMLParser): + def handle_starttag(self, tag, attrs): + if tag == 'img': + src = dict(attrs).get('src') + if src: + visit(DocumentNode(type='image', node_id='html-image', attributes={'src':src})) def visit(node): + if node.type == 'html_block' or node.attributes.get('raw_html'): + parser = HtmlImages(convert_charrefs=True) + parser.feed(node.text) + parser.close() if node.type == 'image': warnings = enrich_document(Document(node_id='pdf-resources', children=[node]), (metadata or {}).get('file_path'), True, request.options, preserve_alpha=True) raw = node.attributes.get('static_png') diff --git a/backend/tests/test_pdf_browser.py b/backend/tests/test_pdf_browser.py index 1a42030..531a8fb 100644 --- a/backend/tests/test_pdf_browser.py +++ b/backend/tests/test_pdf_browser.py @@ -45,3 +45,25 @@ def test_browser_prints_css_without_executing_document_scripts(tmp_path): text=subprocess.check_output(['pdftotext',str(pdf),'-']).decode('utf-8') assert 'Theme Snapshot' in text assert 'EXECUTED' not in text + + +@pytest.mark.parametrize('source', ['
', 'inline image']) +def test_preview_embeds_html_images(source): + from app.config import get_settings + from PIL import Image + import base64 + folder=get_settings().vault_path/'notes'/'assets' + folder.mkdir(parents=True) + Image.new('RGBA',(2,2),(10,20,30,128)).save(folder/'a&b.png') + resources=asyncio.run(service.preview_resources(ExportRequest(format='pdf',source={'type':'markdown','markdown':source,'file_path':'notes/test.md'}))) + image=resources['images'][0] + assert image['source']=='assets/a&b.png' + assert base64.b64decode(image['data'].split(',')[1]).startswith(b'\x89PNG') + assert image['warnings']==[] + + +def test_html_images_keep_path_validation_and_code_is_not_an_image(): + source='\n\ninline \n\n``\n\n```html\n\n```' + resources=asyncio.run(service.preview_resources(ExportRequest(format='pdf',source={'type':'markdown','markdown':source}))) + assert [image['source'] for image in resources['images']]==['../../private.png','https://example.com/a.png'] + assert all(image['data'] is None and image['warnings'] for image in resources['images']) diff --git a/docs/development/PDF实际主题样式修复验收-2026-09-07.md b/docs/development/PDF实际主题样式修复验收-2026-09-07.md index 307cc39..c934a0f 100644 --- a/docs/development/PDF实际主题样式修复验收-2026-09-07.md +++ b/docs/development/PDF实际主题样式修复验收-2026-09-07.md @@ -16,3 +16,10 @@ PDF 快照使用与可视编辑器相同的 `splitNoteMetadata` 解析当前内容,在正文前输出笔记属性、标题和标签,保留 `.note-metadata` 结构及 Vue scoped 样式属性。标签输入框和移除按钮不进入导出。识别出的 YAML 不再作为 Markdown 正文渲染;不支持的元数据仍按编辑器规则保留原文。不额外读取磁盘,因此导出包含当前未保存的元数据。 验证:PDF 快照与导出服务 15 项测试通过,前端类型检查及生产构建通过(保留已有大分块提示)。六种主题均实际生成 PDF,并检查包含元数据栏的第一页:paper-moments、dark、light、sepia、ocean-blue、midnight-purple。纸张主题的胶带、缝线及标签配色正常,其他主题的元数据背景、边框和文字配色正常。 + + +## 合并审阅问题修复 + +- 只有标题、标签而正文为空或仅有空白时,跳过资源准备请求,继续生成元数据栏,避免空 Markdown 触发 422。 +- PDF 资源准备覆盖块级及行内 HTML 的 img,使用 HTMLParser 处理标签和属性实体,再复用 Vault 图片路径与格式校验。行内 HTML 在 AST 中单独标记;行内代码和代码块不会被作为 HTML 图片收集。 +- 回归覆盖纯元数据、空白正文、HTML 图片实体与相对路径、越界及远程资源、代码示例排除。后端相关 114 项、前端相关 18 项通过,vue-tsc 类型检查通过。pytest 仅提示本机缓存目录不可写,测试本身通过。 diff --git a/frontend/src/services/pdfSnapshotService.spec.ts b/frontend/src/services/pdfSnapshotService.spec.ts index a454943..2025e3b 100644 --- a/frontend/src/services/pdfSnapshotService.spec.ts +++ b/frontend/src/services/pdfSnapshotService.spec.ts @@ -58,3 +58,19 @@ it('does not invent a metadata bar for plain notes or unsupported frontmatter',a expect(renderMarkdown).toHaveBeenCalledWith(source,expect.anything()) } }) + +it('exports metadata-only notes without submitting an empty resource request',async()=>{ + for(const tail of ['', '\n \n']) { + const html=await preparePdfSnapshot('---\ntitle: Metadata only\ntags: [draft]\n---\n'+tail,'note',{theme_id:'light',include_title:false,page_size:'A4'}) + const doc=new DOMParser().parseFromString(html,'text/html') + expect(doc.querySelector('.note-metadata h1')?.textContent).toBe('Metadata only') + expect(doc.querySelector('.metadata-tag')?.textContent).toBe('draft') + } + expect(apiClient.post).not.toHaveBeenCalled() +}) +it('embeds prepared HTML images without retaining local URLs',async()=>{ + vi.mocked(renderMarkdown).mockResolvedValueOnce('

') + vi.mocked(apiClient.post).mockResolvedValueOnce({images:[{source:'assets/a&b.png',data:'data:image/png;base64,aGVsbG8=',warnings:[]}],plots:[]}) + const html=await preparePdfSnapshot('','note',{theme_id:'light',include_title:false,page_size:'A4'}) + expect(new DOMParser().parseFromString(html,'text/html').querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,aGVsbG8=') +}) diff --git a/frontend/src/services/pdfSnapshotService.ts b/frontend/src/services/pdfSnapshotService.ts index 61213f6..e7a1284 100644 --- a/frontend/src/services/pdfSnapshotService.ts +++ b/frontend/src/services/pdfSnapshotService.ts @@ -78,7 +78,7 @@ export async function preparePdfSnapshot(markdown:string,title:string,options:Op const scope=scopeAttributes(VisualMarkdownEditor) // Match the editor DOM and scoped styles, with read-only metadata controls. const metadataHtml=metadata ? `
${escape(t('笔记属性','Note properties'))}${metadata.title ? `${escape(metadata.title)}` : ''}
` : '' - const resources=await apiClient.post('/api/exports/preview-resources',{format:'pdf',source:{type:'markdown',markdown:body,file_path:filePath},options}) + const resources:Resources=body.trim() ? await apiClient.post('/api/exports/preview-resources',{format:'pdf',source:{type:'markdown',markdown:body,file_path:filePath},options}) : {images:[],plots:[]} signal?.throwIfAborted() const rendered=await renderMarkdown(body,{themeId:options.theme_id,theme:dark?'dark':'light',preferences,pdf:{mermaidVariables:diagramVariables,plot:async source=>{ const plot=resources.plots.find(p=>p.source.trim()===source.trim()); if(!plot?.svg)throw Error(plot?.warnings.join('; ')||'函数图像无法导出');return plot