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', ['

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('
','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 ? `` : ''
- const resources=await apiClient.post