feat(phase2): 完成第二阶段评测、函数图与多格式导出 #44
@@ -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":
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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&b.png"></div>', 'inline <img src="assets/a&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&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&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=')
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user