feat(phase2): 完成第二阶段评测、函数图与多格式导出 #44

Merged
Kronecker merged 10 commits from feat/phase2-completion into main 2026-09-07 15:11:04 +08:00
6 changed files with 60 additions and 2 deletions
Showing only changes of commit ca5b52bc8a - Show all commits
+2
View File
@@ -178,6 +178,8 @@ class _AstMapper:
type="link", node_id=self.next_id(), attributes=attributes, type="link", node_id=self.next_id(), attributes=attributes,
children=self.map_inline(token.get("children", [])), 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": if kind == "codespan":
return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", "")) return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", ""))
if kind == "image": if kind == "image":
+12 -1
View File
@@ -411,12 +411,23 @@ async def preview_resources(request: ExportRequest):
from app.export.assets import enrich_document from app.export.assets import enrich_document
from app.plot.parser import parse_source from app.plot.parser import parse_source
from app.plot.render import render_svg 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) markdown, _, metadata = await _resolve_source(request.source, True)
def prepare(): def prepare():
document = parse_document(markdown) document = parse_document(markdown)
images, plots = [], [] 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): 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': 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) 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') raw = node.attributes.get('static_png')
+22
View File
@@ -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') text=subprocess.check_output(['pdftotext',str(pdf),'-']).decode('utf-8')
assert 'Theme Snapshot' in text assert 'Theme Snapshot' in text
assert 'EXECUTED' not in text assert 'EXECUTED' not in text
@pytest.mark.parametrize('source', ['<div><IMG SRC="assets/a&amp;b.png"></div>', 'inline <img src="assets/a&amp;b.png"/> 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='<img src="../../private.png">\n\ninline <img src="https://example.com/a.png">\n\n`<img src="code.png">`\n\n```html\n<img src="fenced.png">\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'])
@@ -16,3 +16,10 @@
PDF 快照使用与可视编辑器相同的 `splitNoteMetadata` 解析当前内容,在正文前输出笔记属性、标题和标签,保留 `.note-metadata` 结构及 Vue scoped 样式属性。标签输入框和移除按钮不进入导出。识别出的 YAML 不再作为 Markdown 正文渲染;不支持的元数据仍按编辑器规则保留原文。不额外读取磁盘,因此导出包含当前未保存的元数据。 PDF 快照使用与可视编辑器相同的 `splitNoteMetadata` 解析当前内容,在正文前输出笔记属性、标题和标签,保留 `.note-metadata` 结构及 Vue scoped 样式属性。标签输入框和移除按钮不进入导出。识别出的 YAML 不再作为 Markdown 正文渲染;不支持的元数据仍按编辑器规则保留原文。不额外读取磁盘,因此导出包含当前未保存的元数据。
验证:PDF 快照与导出服务 15 项测试通过,前端类型检查及生产构建通过(保留已有大分块提示)。六种主题均实际生成 PDF,并检查包含元数据栏的第一页:paper-moments、dark、light、sepia、ocean-blue、midnight-purple。纸张主题的胶带、缝线及标签配色正常,其他主题的元数据背景、边框和文字配色正常。 验证: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 仅提示本机缓存目录不可写,测试本身通过。
@@ -58,3 +58,19 @@ it('does not invent a metadata bar for plain notes or unsupported frontmatter',a
expect(renderMarkdown).toHaveBeenCalledWith(source,expect.anything()) 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('<p><img src="assets/a&amp;b.png"></p>')
vi.mocked(apiClient.post).mockResolvedValueOnce({images:[{source:'assets/a&b.png',data:'data:image/png;base64,aGVsbG8=',warnings:[]}],plots:[]})
const html=await preparePdfSnapshot('<img src="assets/a&amp;b.png">','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=')
})
+1 -1
View File
@@ -78,7 +78,7 @@ export async function preparePdfSnapshot(markdown:string,title:string,options:Op
const scope=scopeAttributes(VisualMarkdownEditor) const scope=scopeAttributes(VisualMarkdownEditor)
// Match the editor DOM and scoped styles, with read-only metadata controls. // Match the editor DOM and scoped styles, with read-only metadata controls.
const metadataHtml=metadata ? `<section class="note-metadata"${scope} aria-label="${escape(t('笔记属性','Note properties'))}"><span class="metadata-caption"${scope}>${escape(t('笔记属性','Note properties'))}</span>${metadata.title ? `<h1${scope}>${escape(metadata.title)}</h1>` : ''}<div class="metadata-tags"${scope}><span class="metadata-label"${scope}>${escape(t('标签','Tags'))}</span>${metadata.tags.map(tag=>`<span class="metadata-tag"${scope}><span${scope}>${escape(tag)}</span></span>`).join('')}</div></section>` : '' const metadataHtml=metadata ? `<section class="note-metadata"${scope} aria-label="${escape(t('笔记属性','Note properties'))}"><span class="metadata-caption"${scope}>${escape(t('笔记属性','Note properties'))}</span>${metadata.title ? `<h1${scope}>${escape(metadata.title)}</h1>` : ''}<div class="metadata-tags"${scope}><span class="metadata-label"${scope}>${escape(t('标签','Tags'))}</span>${metadata.tags.map(tag=>`<span class="metadata-tag"${scope}><span${scope}>${escape(tag)}</span></span>`).join('')}</div></section>` : ''
const resources=await apiClient.post<Resources>('/api/exports/preview-resources',{format:'pdf',source:{type:'markdown',markdown:body,file_path:filePath},options}) const resources:Resources=body.trim() ? await apiClient.post<Resources>('/api/exports/preview-resources',{format:'pdf',source:{type:'markdown',markdown:body,file_path:filePath},options}) : {images:[],plots:[]}
signal?.throwIfAborted() signal?.throwIfAborted()
const rendered=await renderMarkdown(body,{themeId:options.theme_id,theme:dark?'dark':'light',preferences,pdf:{mermaidVariables:diagramVariables,plot:async source=>{ 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 const plot=resources.plots.find(p=>p.source.trim()===source.trim()); if(!plot?.svg)throw Error(plot?.warnings.join('; ')||'函数图像无法导出');return plot